Skip to content

Commit

Permalink
Experimental Trilogy support (#13)
Browse files Browse the repository at this point in the history
* Experimental `triology` support

* Add `Janus::QueryDirector`

* Update README.md

* Update test config

* Clear context before test run

* Rubocop tidy

* Trying to get things to run via Github Actions

* Increase `Metrics/AbcSize

* Setup test users for trilogy support

* Whoops!

* Update connection strings

* Return to required, debugging on CI isn't fun!

* MySQL 8

* Add notes to readme
  • Loading branch information
lloydwatkin authored Apr 29, 2024
1 parent 11b10c0 commit 665f450
Show file tree
Hide file tree
Showing 16 changed files with 514 additions and 128 deletions.
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ jobs:
name: Ruby ${{ matrix.ruby }}
services:
mysql:
image: mysql:5.7
image: mysql:8
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: password
MYSQL_USER: primary
MYSQL_PASSWORD: primary_password
MYSQL_USER: test
MYSQL_PASSWORD: test_password
ports:
- 3306:3306
options: >-
Expand All @@ -37,7 +37,12 @@ jobs:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: |
mysql -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 -e "CREATE USER 'replica'@'%' IDENTIFIED BY 'replica_password';GRANT SELECT ON test.* TO 'replica'@'%';FLUSH PRIVILEGES;"
mysql -e "CREATE USER 'replica'@'%' IDENTIFIED WITH mysql_native_password BY 'replica_password';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1
mysql -e "GRANT SELECT ON test.* TO 'replica'@'%'" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1
mysql -e "CREATE USER 'primary'@'%' IDENTIFIED WITH mysql_native_password BY 'primary_password';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1
mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'primary'@'%';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1
mysql -e "FLUSH PRIVILEGES;" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1
- run: |
bundle exec rspec
env:
Expand Down
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ Style/GlobalVars:
- 'spec/**/*'

Metrics/AbcSize:
Max: 22
Max: 25
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ GEM
rubocop (>= 0.90.0)
ruby-progressbar (1.13.0)
timeout (0.4.1)
trilogy (2.8.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
Expand All @@ -121,6 +122,7 @@ DEPENDENCIES
rubocop-rails (~> 2.24.0)
rubocop-rspec
rubocop-thread_safety
trilogy

BUNDLED WITH
2.4.22
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
[![CI](https://github.com/OLIOEX/janus-ar/actions/workflows/ci.yml/badge.svg)](https://github.com/OLIOEX/janus-ar/actions/workflows/ci.yml)
[![Gem Version](https://badge.fury.io/rb/janus-ar.svg)](https://badge.fury.io/rb/janus-ar)

Janus ActiveRecord is generic primary/replica proxy for ActiveRecord 7.1+ and MySQL. It handles the switching of connections between primary and replica database servers. It comes with an ActiveRecord database adapter implementation.
Janus ActiveRecord is generic primary/replica proxy for ActiveRecord 7.1+ and MySQL (via `mysql2` and `trilogy`). It handles the switching of connections between primary and replica database servers. It comes with an ActiveRecord database adapter implementation.

Note: Trilogy support is experimental at this stage.

Janus is heavily inspired by [Makara](https://github.com/instacart/makara) from TaskRabbit and then Instacart. Unfortunately this project is unmaintained and broke for us with Rails 7.1. This is an attempt to start afresh on the project. It is definitely not as fully featured as Makara at this stage.

Learn more about its origins: [https://tech.olioex.com/ruby/2024/04/16/introducing-janus.html](https://tech.olioex.com/ruby/2024/04/16/introducing-janus.html).

Notes: GEM is currently tested with MySQL 8, Ruby 3.2, ActiveRecord 7.1+

## Installation

Use the current version of the gem from [rubygems](https://rubygems.org/gems/janus-ar) in your `Gemfile`.
Expand Down Expand Up @@ -48,6 +52,18 @@ development:
password: ithappenstobedifferent
host: replica-host.local
```
Note: For `trilogy` please use adapter "janus_trilogy". You'll probably need to add the following to your configuration to have it connect:

```yml
ssl: true
ssl_mode: 'REQUIRED'
tls_min_version: 3
```

`tls_min_version` here refers to TLS1.2.

Otherwise you will get an error like the following (see https://github.com/trilogy-libraries/trilogy/issues/26):
> trilogy_auth_recv: caching_sha2_password requires either TCP with TLS or a unix socket: TRILOGY_UNSUPPORTED"

### Forcing connections

Expand Down
1 change: 1 addition & 0 deletions janus-ar.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Gem::Specification.new do |gem|
gem.add_development_dependency 'activerecord', '>= 7.1.0'
gem.add_development_dependency 'activesupport', '>= 7.1.0'
gem.add_development_dependency 'mysql2'
gem.add_development_dependency 'trilogy'
gem.add_development_dependency 'pry'
gem.add_development_dependency 'rake'
gem.add_development_dependency 'rspec', '~> 3'
Expand Down
82 changes: 38 additions & 44 deletions lib/active_record/connection_adapters/janus_mysql2_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,6 @@ module ActiveRecord
module ConnectionAdapters
class JanusMysql2Adapter < ActiveRecord::ConnectionAdapters::Mysql2Adapter
FOUND_ROWS = 'FOUND_ROWS'
SQL_PRIMARY_MATCHERS = [
/\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i,
/\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i,
/\A\s*show/i
].freeze
SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze
SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze
SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze
WRITE_PREFIXES = %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE FLUSH).freeze

attr_reader :config

Expand All @@ -56,30 +47,46 @@ def initialize(*args)
update_config
end

def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :raw_execute)
super
when :replica
send_to_replica(sql, connection: :replica, method: :raw_execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super
end
end

def execute(sql)
if should_send_to_all?(sql)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :execute)
return super(sql)
super(sql)
when :replica
send_to_replica(sql, connection: :replica, method: :execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super(sql)
end
return send_to_replica(sql, connection: :replica, method: :execute) if can_go_to_replica?(sql)

Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)

super(sql)
end

def execute_and_free(sql, name = nil, async: false)
if should_send_to_all?(sql)
send_to_replica(sql, name, connection: :all)
return super(sql, name, async:)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :execute)
super(sql, name, async:)
when :replica
send_to_replica(sql, connection: :replica, method: :execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super(sql, name, async:)
end
return send_to_replica(sql, connection: :replica) if can_go_to_replica?(sql)

Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)

super(sql, name, async:)
end

def connect!(...)
Expand Down Expand Up @@ -108,41 +115,28 @@ def replica_connection

private

def should_send_to_all?(sql)
SQL_ALL_MATCHERS.any? { |matcher| sql =~ matcher } && SQL_SKIP_ALL_MATCHERS.none? { |matcher| sql =~ matcher }
end

def can_go_to_replica?(sql)
!should_go_to_primary?(sql)
end

def should_go_to_primary?(sql)
Janus::Context.use_primary? ||
write_query?(sql) ||
open_transactions.positive? ||
SQL_PRIMARY_MATCHERS.any? { |matcher| sql =~ matcher }
def where_to_send?(sql)
Janus::QueryDirector.new(sql, open_transactions).where_to_send?
end

def send_to_replica(sql, connection: nil, method: :exec_query)
Janus::Context.used_connection(connection) if connection
if method == :execute
replica_connection.execute(sql)
elsif method == :raw_execute
replica_connection.execute(sql)
else
replica_connection.exec_query(sql)
end
end

def write_query?(sql)
WRITE_PREFIXES.include?(sql.upcase.split(' ').first)
end

def update_config
@config[:flags] ||= 0

if @config[:flags].is_a? Array
@config[:flags].push FOUND_ROWS
else
@config[:flags] |= ::Mysql2::Client::FOUND_ROWS
@config[:flags] |= ::Janus::Client::FOUND_ROWS
end
end
end
Expand Down
144 changes: 144 additions & 0 deletions lib/active_record/connection_adapters/janus_trilogy_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# frozen_string_literal: true

require 'active_record/connection_adapters/abstract_adapter'
require 'active_record/connection_adapters/trilogy_adapter'
require_relative '../../janus'

module ActiveRecord
module ConnectionHandling
def janus_trilogy_connection(config)
ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter.new(config)
end
end
end

module ActiveRecord
class Base
def self.janus_trilogy_adapter_class
ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter
end
end
end

module ActiveRecord
module ConnectionAdapters
class JanusTrilogyAdapter < ActiveRecord::ConnectionAdapters::TrilogyAdapter
FOUND_ROWS = 'FOUND_ROWS'

attr_reader :config

class << self
def dbconsole(config, options = {})
connection_config = Janus::DbConsoleConfig.new(config)

super(connection_config, options)
end
end

def initialize(*args)
args[0][:janus]['replica']['database'] = args[0][:database]
args[0][:janus]['primary']['database'] = args[0][:database]

@replica_config = args[0][:janus]['replica'].symbolize_keys
args[0] = args[0][:janus]['primary'].symbolize_keys

super(*args)
@connection_parameters ||= args[0]
update_config
end

def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :execute)
super
when :replica
send_to_replica(sql, connection: :replica, method: :execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super
end
end

def execute(sql)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :execute)
super(sql)
when :replica
send_to_replica(sql, connection: :replica, method: :execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super(sql)
end
end

def execute_and_free(sql, name = nil, async: false)
case where_to_send?(sql)
when :all
send_to_replica(sql, connection: :all, method: :execute)
super(sql, name, async:)
when :replica
send_to_replica(sql, connection: :replica, method: :execute)
else
Janus::Context.stick_to_primary if write_query?(sql)
Janus::Context.used_connection(:primary)
super(sql, name, async:)
end
end

def connect!(...)
replica_connection.connect!(...)
super
end

def reconnect!(...)
replica_connection.reconnect!(...)
super
end

def disconnect!(...)
replica_connection.disconnect!(...)
super
end

def clear_cache!(...)
replica_connection.clear_cache!(...)
super
end

def replica_connection
@replica_connection ||= ActiveRecord::ConnectionAdapters::TrilogyAdapter.new(@replica_config)
end

private

def where_to_send?(sql)
Janus::QueryDirector.new(sql, open_transactions).where_to_send?
end

def send_to_replica(sql, connection: nil, method: :exec_query)
Janus::Context.used_connection(connection) if connection
if method == :execute
replica_connection.execute(sql)
elsif method == :raw_execute
replica_connection.execute(sql)
else
replica_connection.exec_query(sql)
end
end

def update_config
@config[:flags] ||= 0

if @config[:flags].is_a? Array
@config[:flags].push FOUND_ROWS
else
@config[:flags] |= ::Janus::Client::FOUND_ROWS
end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/janus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

module Janus
autoload :Context, 'janus/context'
autoload :Client, 'janus/client'
autoload :QueryDirector, 'janus/query_director'
autoload :VERSION, 'janus/version'
autoload :DbConsoleConfig, 'janus/db_console_config'

Expand Down
6 changes: 6 additions & 0 deletions lib/janus/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Janus
class Client
FOUND_ROWS = 2
end
end
Loading

0 comments on commit 665f450

Please sign in to comment.