janus-ar 8.0.0 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,149 +1,32 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_record/connection_adapters/abstract_adapter'
4
- require 'active_record/connection_adapters/trilogy_adapter'
5
- require_relative '../../../janus-ar'
6
-
7
- module ActiveRecord
8
- module ConnectionHandling
9
- def janus_trilogy_connection(config)
10
- ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter.new(config)
11
- end
12
- end
13
- end
14
-
15
- module ActiveRecord
16
- class Base
17
- def self.janus_trilogy_adapter_class
18
- ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter
19
- end
20
- end
21
- end
22
-
23
- module ActiveRecord
24
- module ConnectionAdapters
25
- class JanusTrilogyAdapter < ActiveRecord::ConnectionAdapters::TrilogyAdapter
26
- FOUND_ROWS = 'FOUND_ROWS'
27
-
28
- attr_reader :config
29
-
30
- class << self
31
- def dbconsole(config, options = {})
32
- connection_config = Janus::DbConsoleConfig.new(config)
33
-
34
- super(connection_config, options)
35
- end
36
- end
37
-
38
- def initialize(*args)
39
- args[0][:janus]['replica']['database'] = args[0][:database]
40
- args[0][:janus]['primary']['database'] = args[0][:database]
41
-
42
- @replica_config = args[0][:janus]['replica'].symbolize_keys
43
- args[0] = args[0][:janus]['primary'].symbolize_keys
44
-
45
- super(*args)
46
- @connection_parameters ||= args[0]
47
- update_config
48
- end
49
-
50
- def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false,
51
- materialize_transactions: true, batch: false)
52
- case where_to_send?(sql)
53
- when :all
54
- send_to_replica(sql, connection: :all, method: :execute)
55
- super
56
- when :replica
57
- send_to_replica(sql, connection: :replica, method: :execute)
58
- else
59
- Janus::Context.stick_to_primary if write_query?(sql)
60
- Janus::Context.used_connection(:primary)
61
- super
62
- end
63
- end
64
-
65
- def execute(sql)
66
- case where_to_send?(sql)
67
- when :all
68
- send_to_replica(sql, connection: :all, method: :execute)
69
- super(sql)
70
- when :replica
71
- send_to_replica(sql, connection: :replica, method: :execute)
72
- else
73
- Janus::Context.stick_to_primary if write_query?(sql)
74
- Janus::Context.used_connection(:primary)
75
- super(sql)
76
- end
77
- end
78
-
79
- def execute_and_free(sql, name = nil, async: false)
80
- case where_to_send?(sql)
81
- when :all
82
- send_to_replica(sql, connection: :all, method: :execute)
83
- super(sql, name, async:)
84
- when :replica
85
- send_to_replica(sql, connection: :replica, method: :execute)
86
- else
87
- Janus::Context.stick_to_primary if write_query?(sql)
88
- Janus::Context.used_connection(:primary)
89
- super(sql, name, async:)
90
- end
91
- end
92
-
93
- def with_connection(_args = {})
94
- self
95
- end
96
-
97
- def connect!(...)
98
- replica_connection.connect!(...)
99
- super
100
- end
101
-
102
- def reconnect!(...)
103
- replica_connection.reconnect!(...)
104
- super
105
- end
106
-
107
- def disconnect!(...)
108
- replica_connection.disconnect!(...)
109
- super
110
- end
111
-
112
- def clear_cache!(...)
113
- replica_connection.clear_cache!(...)
114
- super
115
- end
116
-
117
- def replica_connection
118
- @replica_connection ||= ActiveRecord::ConnectionAdapters::TrilogyAdapter.new(@replica_config)
119
- end
120
-
121
- private
122
-
123
- def where_to_send?(sql)
124
- Janus::QueryDirector.new(sql, open_transactions).where_to_send?
125
- end
126
-
127
- def send_to_replica(sql, connection: nil, method: :exec_query)
128
- Janus::Context.used_connection(connection) if connection
129
- if method == :execute
130
- replica_connection.execute(sql)
131
- elsif method == :raw_execute
132
- replica_connection.execute(sql)
133
- else
134
- replica_connection.exec_query(sql)
135
- end
136
- end
137
-
138
- def update_config
139
- @config[:flags] ||= 0
140
-
141
- if @config[:flags].is_a? Array
142
- @config[:flags].push FOUND_ROWS
143
- else
144
- @config[:flags] |= ::Janus::Client::FOUND_ROWS
145
- end
146
- end
147
- end
148
- end
149
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/abstract_adapter'
4
+ require 'active_record/connection_adapters/trilogy_adapter'
5
+ require_relative '../../../janus-ar'
6
+ require_relative '../../adapter_extensions'
7
+
8
+ module ActiveRecord
9
+ module ConnectionHandling
10
+ def janus_trilogy_connection(config)
11
+ ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter.new(config)
12
+ end
13
+ end
14
+
15
+ class Base
16
+ def self.janus_trilogy_adapter_class
17
+ ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter
18
+ end
19
+ end
20
+
21
+ module ConnectionAdapters
22
+ class JanusTrilogyAdapter < ActiveRecord::ConnectionAdapters::TrilogyAdapter
23
+ include Janus::AdapterExtensions
24
+
25
+ private
26
+
27
+ def replica_adapter_class
28
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Janus
4
+ # Behaviour shared by the Janus MySQL2 and Trilogy adapters.
5
+ #
6
+ # Each Janus adapter subclasses the matching ActiveRecord adapter and owns the
7
+ # *primary* connection (reached via `super`). This module routes every
8
+ # statement to the primary, a lazily created replica connection, or both, and
9
+ # keeps Janus::Context up to date.
10
+ #
11
+ # Including adapters only need to implement #replica_adapter_class.
12
+ module AdapterExtensions
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ module ClassMethods
18
+ def dbconsole(config, options = {})
19
+ super(Janus::DbConsoleConfig.new(config), options)
20
+ end
21
+ end
22
+
23
+ attr_reader :config
24
+
25
+ def initialize(*args)
26
+ config = args[0]
27
+ config[:janus]['replica']['database'] = config[:database]
28
+ config[:janus]['primary']['database'] = config[:database]
29
+
30
+ @replica_config = config[:janus]['replica'].symbolize_keys
31
+ args[0] = config[:janus]['primary'].symbolize_keys
32
+
33
+ super
34
+ @connection_parameters ||= args[0]
35
+ end
36
+
37
+ # The argument lists below intentionally use anonymous splats and a bare
38
+ # `super`: ActiveRecord's `raw_execute`/`execute` signatures differ between
39
+ # versions, so we forward whatever we are given unchanged rather than
40
+ # restating (and pinning ourselves to) the current signature.
41
+ def raw_execute(sql, *, **)
42
+ case where_to_send?(sql)
43
+ when :all
44
+ send_to_replica(sql, :all)
45
+ super
46
+ when :replica
47
+ send_to_replica(sql, :replica)
48
+ else
49
+ mark_primary(sql)
50
+ super
51
+ end
52
+ end
53
+
54
+ def execute(sql, *, **)
55
+ case where_to_send?(sql)
56
+ when :all
57
+ send_to_replica(sql, :all)
58
+ super
59
+ when :replica
60
+ send_to_replica(sql, :replica)
61
+ else
62
+ mark_primary(sql)
63
+ super
64
+ end
65
+ end
66
+
67
+ def connect!(...)
68
+ replica_connection.connect!(...)
69
+ super
70
+ end
71
+
72
+ def reconnect!(...)
73
+ replica_connection.reconnect!(...)
74
+ super
75
+ end
76
+
77
+ def disconnect!(...)
78
+ replica_connection.disconnect!(...)
79
+ super
80
+ end
81
+
82
+ def clear_cache!(...)
83
+ replica_connection.clear_cache!(...)
84
+ super
85
+ end
86
+
87
+ def replica_connection
88
+ @replica_connection ||= replica_adapter_class.new(@replica_config)
89
+ end
90
+
91
+ private
92
+
93
+ def mark_primary(sql)
94
+ Janus::Context.stick_to_primary if write_query?(sql)
95
+ Janus::Context.used_connection(:primary)
96
+ end
97
+
98
+ def where_to_send?(sql)
99
+ Janus::QueryDirector.new(sql, open_transactions).where_to_send?
100
+ end
101
+
102
+ def send_to_replica(sql, connection)
103
+ Janus::Context.used_connection(connection)
104
+ replica_connection.execute(sql)
105
+ end
106
+ end
107
+ end
@@ -1,80 +1,79 @@
1
- # frozen_string_literal: true
2
-
3
- module Janus
4
- class Context
5
- THREAD_KEY = :janus_ar_context
6
-
7
- # Stores the staged data with an expiration time based on the current time,
8
- # and clears any expired entries. Returns true if any changes were made to
9
- # the current store
10
- def initialize(primary: false, expiry: nil)
11
- @primary = primary
12
- @expiry = expiry
13
- @last_used_connection = :primary
14
- end
15
-
16
- def stick_to_primary
17
- @primary = true
18
- end
19
-
20
- def potential_write
21
- stick_to_primary
22
- end
23
-
24
- def release_all
25
- @primary = false
26
- @expiry = nil
27
- @last_used_connection = nil
28
- end
29
-
30
- def use_primary?
31
- @primary
32
- end
33
-
34
- def used_connection(connection)
35
- @last_used_connection = connection
36
- end
37
-
38
- attr_reader :last_used_connection
39
-
40
- class << self
41
- def stick_to_primary
42
- current.stick_to_primary
43
- end
44
-
45
- def release_all
46
- current.release_all
47
- end
48
-
49
- def used_connection(connection)
50
- current.used_connection(connection)
51
- end
52
-
53
- def use_primary?
54
- current.use_primary?
55
- end
56
-
57
- def last_used_connection
58
- current.last_used_connection
59
- end
60
-
61
- protected
62
-
63
- def current
64
- fetch(THREAD_KEY) { new }
65
- end
66
-
67
- def fetch(key)
68
- get(key) || set(key, yield)
69
- end
70
-
71
- def get(key)
72
- Thread.current.thread_variable_get(key)
73
- end
74
-
75
- def set(key, value)
76
- Thread.current.thread_variable_set(key, value)
77
- end
78
- end
79
- end
80
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/isolated_execution_state'
4
+
5
+ module Janus
6
+ # Per-execution state that records whether the current unit of work has been
7
+ # pinned to the primary (e.g. after a write, so subsequent reads stay
8
+ # consistent). State is stored in ActiveSupport::IsolatedExecutionState so it
9
+ # follows the application's configured isolation level (thread or fiber),
10
+ # matching ActiveRecord itself.
11
+ #
12
+ # Because pooled threads/fibers are reused across requests and jobs, the
13
+ # context MUST be released between units of work or a thread that performed a
14
+ # single write would keep routing every later read to the primary. The Rails
15
+ # integration (see Janus::Railtie) does this automatically; outside Rails,
16
+ # call Janus::Context.release_all yourself (e.g. in a Sidekiq middleware).
17
+ class Context
18
+ STATE_KEY = :janus_ar_context
19
+
20
+ def initialize(primary: false)
21
+ @primary = primary
22
+ @last_used_connection = :primary
23
+ end
24
+
25
+ def stick_to_primary
26
+ @primary = true
27
+ end
28
+
29
+ def release_all
30
+ @primary = false
31
+ @last_used_connection = nil
32
+ end
33
+
34
+ def use_primary?
35
+ @primary
36
+ end
37
+
38
+ def used_connection(connection)
39
+ @last_used_connection = connection
40
+ end
41
+
42
+ attr_reader :last_used_connection
43
+
44
+ class << self
45
+ def stick_to_primary
46
+ current.stick_to_primary
47
+ end
48
+
49
+ def release_all
50
+ current.release_all
51
+ end
52
+
53
+ def used_connection(connection)
54
+ current.used_connection(connection)
55
+ end
56
+
57
+ def use_primary?
58
+ current.use_primary?
59
+ end
60
+
61
+ def last_used_connection
62
+ current.last_used_connection
63
+ end
64
+
65
+ # Release the context at the start of every unit of work wrapped by the
66
+ # given ActiveSupport executor (web requests, ActiveJob and
67
+ # Sidekiq-on-Rails jobs all run inside it).
68
+ def install_reset_hook(executor)
69
+ executor.to_run { Janus::Context.release_all }
70
+ end
71
+
72
+ protected
73
+
74
+ def current
75
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] ||= new
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,54 +1,81 @@
1
- # frozen_string_literal: true
2
- module Janus
3
- class QueryDirector
4
- ALL = :all
5
- REPLICA = :replica
6
- PRIMARY = :primary
7
-
8
- SQL_PRIMARY_MATCHERS = [
9
- /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i,
10
- /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i,
11
- /\A\s*show/i
12
- ].freeze
13
- SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze
14
- SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze
15
- SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze
16
- WRITE_PREFIXES = %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE BEGIN SAVEPOINT FLUSH).freeze
17
-
18
- def initialize(sql, open_transactions)
19
- @_sql = sql
20
- @_open_transactions = open_transactions
21
- end
22
-
23
- def where_to_send?
24
- if should_send_to_all?
25
- ALL
26
- elsif can_go_to_replica?
27
- REPLICA
28
- else
29
- PRIMARY
30
- end
31
- end
32
-
33
- private
34
-
35
- def should_send_to_all?
36
- SQL_ALL_MATCHERS.any? { |matcher| @_sql =~ matcher } && SQL_SKIP_ALL_MATCHERS.none? { |matcher| @_sql =~ matcher }
37
- end
38
-
39
- def can_go_to_replica?
40
- !should_go_to_primary?
41
- end
42
-
43
- def should_go_to_primary?
44
- Janus::Context.use_primary? ||
45
- write_query? ||
46
- @_open_transactions.positive? ||
47
- SQL_PRIMARY_MATCHERS.any? { |matcher| @_sql =~ matcher }
48
- end
49
-
50
- def write_query?
51
- WRITE_PREFIXES.include?(@_sql.upcase.split(' ').first)
52
- end
53
- end
54
- end
1
+ # frozen_string_literal: true
2
+ module Janus
3
+ class QueryDirector
4
+ ALL = :all
5
+ REPLICA = :replica
6
+ PRIMARY = :primary
7
+
8
+ SQL_PRIMARY_MATCHERS = [
9
+ /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i,
10
+ /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i,
11
+ /\A\s*show/i
12
+ ].freeze
13
+ SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze
14
+ SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze
15
+ SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze
16
+
17
+ # Leading whitespace and SQL comments are stripped before matching so that an
18
+ # annotated statement (e.g. `/* app:web */ INSERT ...`) is classified by the
19
+ # statement itself rather than by the comment.
20
+ LEADING_NOISE = %r{\A(?:\s+|/\*.*?\*/|--[^\n]*(?:\n|\z)|\#[^\n]*(?:\n|\z))+}m
21
+
22
+ def initialize(sql, open_transactions)
23
+ @_sql = sql
24
+ @_open_transactions = open_transactions
25
+ end
26
+
27
+ def where_to_send?
28
+ if should_send_to_all?
29
+ ALL
30
+ elsif can_go_to_replica?
31
+ REPLICA
32
+ else
33
+ PRIMARY
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def should_send_to_all?
40
+ match_any?(SQL_ALL_MATCHERS) && !match_any?(SQL_SKIP_ALL_MATCHERS)
41
+ end
42
+
43
+ # A replica may only serve a statement we positively recognise as a read and
44
+ # that nothing else forces onto the primary. Everything we do not recognise
45
+ # as a read defaults to the primary, which is the safe direction for a
46
+ # write/read proxy: a misrouted read only costs a little primary load, while
47
+ # a misrouted write is an error (or worse) against a read-only replica.
48
+ #
49
+ # Because this is only reached for a confirmed read, there is no need to also
50
+ # test for a write here.
51
+ def can_go_to_replica?
52
+ read_query? && !should_go_to_primary?
53
+ end
54
+
55
+ def read_query?
56
+ match_any?(SQL_REPLICA_MATCHERS)
57
+ end
58
+
59
+ def should_go_to_primary?
60
+ Janus::Context.use_primary? ||
61
+ @_open_transactions.positive? ||
62
+ match_any?(SQL_PRIMARY_MATCHERS)
63
+ end
64
+
65
+ def match_any?(matchers)
66
+ matchers.any? { |matcher| normalized_sql.match?(matcher) }
67
+ end
68
+
69
+ # Avoid copying the statement when there is no leading comment/whitespace to
70
+ # strip, which is the common case for ActiveRecord-generated SQL.
71
+ def normalized_sql
72
+ @normalized_sql ||= strip_leading_noise
73
+ end
74
+
75
+ def strip_leading_noise
76
+ return @_sql unless LEADING_NOISE.match?(@_sql)
77
+
78
+ @_sql.sub(LEADING_NOISE, '')
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Janus
6
+ class Railtie < ::Rails::Railtie
7
+ # Clear Janus' per-request primary stickiness at the start of every unit of
8
+ # work wrapped by the Rails executor (web requests, ActiveJob and
9
+ # Sidekiq-on-Rails jobs). Without this, a pooled thread that performs a
10
+ # write keeps routing reads to the primary for the rest of its life.
11
+ initializer 'janus.clear_context_per_execution' do |app|
12
+ Janus::Context.install_reset_hook(app.executor)
13
+ end
14
+ end
15
+ end
@@ -1,17 +1,17 @@
1
- # frozen_string_literal: true
2
-
3
- module Janus
4
- unless defined?(::Janus::VERSION)
5
- module VERSION
6
- MAJOR = 8
7
- MINOR = 0
8
- PATCH = 0
9
- PRE = nil
10
-
11
- def self.to_s
12
- [MAJOR, MINOR, PATCH, PRE].compact.join('.')
13
- end
14
- end
15
- end
16
- ::Janus::VERSION
17
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Janus
4
+ unless defined?(::Janus::VERSION)
5
+ module VERSION
6
+ MAJOR = 8
7
+ MINOR = 0
8
+ PATCH = 1
9
+ PRE = nil
10
+
11
+ def self.to_s
12
+ [MAJOR, MINOR, PATCH, PRE].compact.join('.')
13
+ end
14
+ end
15
+ end
16
+ ::Janus::VERSION
17
+ end
data/lib/janus-ar.rb CHANGED
@@ -1,22 +1,24 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_support'
4
-
5
- module Janus
6
- autoload :Context, 'janus-ar/context'
7
- autoload :Client, 'janus-ar/client'
8
- autoload :QueryDirector, 'janus-ar/query_director'
9
- autoload :VERSION, 'janus-ar/version'
10
- autoload :DbConsoleConfig, 'janus-ar/db_console_config'
11
-
12
- module Logging
13
- autoload :Subscriber, 'janus-ar/logging/subscriber'
14
- autoload :Logger, 'janus-ar/logging/logger'
15
- end
16
- end
17
-
18
- ActiveSupport.on_load(:active_record) do
19
- ActiveRecord::LogSubscriber.log_subscribers.each do |subscriber|
20
- subscriber.extend Janus::Logging::Subscriber
21
- end
22
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module Janus
6
+ autoload :Context, 'janus-ar/context'
7
+ autoload :Client, 'janus-ar/client'
8
+ autoload :QueryDirector, 'janus-ar/query_director'
9
+ autoload :VERSION, 'janus-ar/version'
10
+ autoload :DbConsoleConfig, 'janus-ar/db_console_config'
11
+
12
+ module Logging
13
+ autoload :Subscriber, 'janus-ar/logging/subscriber'
14
+ autoload :Logger, 'janus-ar/logging/logger'
15
+ end
16
+ end
17
+
18
+ ActiveSupport.on_load(:active_record) do
19
+ ActiveRecord::LogSubscriber.log_subscribers.each do |subscriber|
20
+ subscriber.extend Janus::Logging::Subscriber
21
+ end
22
+ end
23
+
24
+ require 'janus-ar/railtie' if defined?(Rails::Railtie)