janus-ar 8.0.0 → 8.1.0

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +142 -125
  4. data/README.md +153 -131
  5. data/janus-ar.gemspec +38 -36
  6. data/lib/janus-ar/active_record/connection_adapters/janus_mysql2_adapter.rb +32 -149
  7. data/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter.rb +32 -149
  8. data/lib/janus-ar/adapter_extensions.rb +112 -0
  9. data/lib/janus-ar/client.rb +6 -6
  10. data/lib/janus-ar/context.rb +79 -80
  11. data/lib/janus-ar/db_console_config.rb +17 -17
  12. data/lib/janus-ar/logging/logger.rb +15 -15
  13. data/lib/janus-ar/logging/subscriber.rb +27 -27
  14. data/lib/janus-ar/query_director.rb +87 -54
  15. data/lib/janus-ar/railtie.rb +15 -0
  16. data/lib/janus-ar/version.rb +17 -17
  17. data/lib/janus-ar.rb +24 -22
  18. metadata +10 -30
  19. data/.bundle/config +0 -5
  20. data/.github/dependabot.yml +0 -7
  21. data/.github/workflows/ci.yml +0 -60
  22. data/.github/workflows/publish.yml +0 -25
  23. data/.gitignore +0 -2
  24. data/.rspec +0 -3
  25. data/.rubocop.yml +0 -26
  26. data/assets/.gitkeep +0 -1
  27. data/assets/janus-logo.png +0 -0
  28. data/bin/release.sh +0 -3
  29. data/spec/lib/janus-ar/active_record/connection_adapters/janus_mysql_adapter_spec.rb +0 -82
  30. data/spec/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter_spec.rb +0 -82
  31. data/spec/lib/janus-ar/client_spec.rb +0 -7
  32. data/spec/lib/janus-ar/context_spec.rb +0 -46
  33. data/spec/lib/janus-ar/logging/logger_spec.rb +0 -31
  34. data/spec/lib/janus-ar/query_director_spec.rb +0 -59
  35. data/spec/shared_examples/a_mysql_like_server.rb +0 -85
  36. data/spec/spec_helper.rb +0 -46
@@ -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,17 +1,17 @@
1
- # frozen_string_literal: true
2
-
3
- module Janus
4
- class DbConsoleConfig
5
- def initialize(config)
6
- @_config = config.configuration_hash
7
- end
8
-
9
- def configuration_hash
10
- @_config[:janus]['replica'].symbolize_keys
11
- end
12
-
13
- def database
14
- @_config[:database]
15
- end
16
- end
17
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Janus
4
+ class DbConsoleConfig
5
+ def initialize(config)
6
+ @_config = config.configuration_hash
7
+ end
8
+
9
+ def configuration_hash
10
+ @_config[:janus]['replica'].symbolize_keys
11
+ end
12
+
13
+ def database
14
+ @_config[:database]
15
+ end
16
+ end
17
+ end
@@ -1,15 +1,15 @@
1
- # frozen_string_literal: true
2
-
3
- module Janus
4
- module Logging
5
- class Logger
6
- class << self
7
- def log(message, format = :info)
8
- logger&.send(format, "[Janus] #{message}")
9
- end
10
-
11
- attr_accessor :logger
12
- end
13
- end
14
- end
15
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Janus
4
+ module Logging
5
+ class Logger
6
+ class << self
7
+ def log(message, format = :info)
8
+ logger&.send(format, "[Janus] #{message}")
9
+ end
10
+
11
+ attr_accessor :logger
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,27 +1,27 @@
1
- # frozen_string_literal: true
2
-
3
- module Janus
4
- module Logging
5
- module Subscriber
6
- IGNORE_PAYLOAD_NAMES = %w(SCHEMA EXPLAIN).freeze
7
-
8
- def sql(event)
9
- name = event.payload[:name]
10
- unless IGNORE_PAYLOAD_NAMES.include?(name)
11
- name = [current_wrapper_name(event), name].compact.join(' ')
12
- event.payload[:name] = name
13
- end
14
- super(event)
15
- end
16
-
17
- protected
18
-
19
- def current_wrapper_name(_event)
20
- connection = Janus::Context.last_used_connection
21
- return nil unless connection
22
-
23
- "[#{connection}]"
24
- end
25
- end
26
- end
27
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Janus
4
+ module Logging
5
+ module Subscriber
6
+ IGNORE_PAYLOAD_NAMES = %w(SCHEMA EXPLAIN).freeze
7
+
8
+ def sql(event)
9
+ name = event.payload[:name]
10
+ unless IGNORE_PAYLOAD_NAMES.include?(name)
11
+ name = [current_wrapper_name(event), name].compact.join(' ')
12
+ event.payload[:name] = name
13
+ end
14
+ super(event)
15
+ end
16
+
17
+ protected
18
+
19
+ def current_wrapper_name(_event)
20
+ connection = Janus::Context.last_used_connection
21
+ return nil unless connection
22
+
23
+ "[#{connection}]"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,54 +1,87 @@
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
+ LOCK_FUNCTIONS = %w(
9
+ nextval currval lastval get_lock release_lock is_free_lock is_used_lock
10
+ pg_advisory_lock pg_advisory_unlock
11
+ ).freeze
12
+
13
+ SQL_PRIMARY_MATCHERS = [
14
+ /\A\s*select\b.*\bfor\s+(update|share)\b/im,
15
+ /\A\s*select\b.*\block\s+in\s+share\s+mode\b/im,
16
+ /\A\s*select\b.*\b(#{LOCK_FUNCTIONS.join('|')})\s*\(/im,
17
+ /\A\s*show\b/i,
18
+ ].freeze
19
+ SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze
20
+ SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze
21
+ SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze
22
+
23
+ # Leading whitespace and SQL comments are stripped before matching so that an
24
+ # annotated statement (e.g. `/* app:web */ INSERT ...`) is classified by the
25
+ # statement itself rather than by the comment.
26
+ LEADING_NOISE = %r{\A(?:\s+|/\*.*?\*/|--[^\n]*(?:\n|\z)|\#[^\n]*(?:\n|\z))+}m
27
+
28
+ def initialize(sql, open_transactions)
29
+ @_sql = sql
30
+ @_open_transactions = open_transactions
31
+ end
32
+
33
+ def where_to_send?
34
+ if should_send_to_all?
35
+ ALL
36
+ elsif can_go_to_replica?
37
+ REPLICA
38
+ else
39
+ PRIMARY
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def should_send_to_all?
46
+ match_any?(SQL_ALL_MATCHERS) && !match_any?(SQL_SKIP_ALL_MATCHERS)
47
+ end
48
+
49
+ # A replica may only serve a statement we positively recognise as a read and
50
+ # that nothing else forces onto the primary. Everything we do not recognise
51
+ # as a read defaults to the primary, which is the safe direction for a
52
+ # write/read proxy: a misrouted read only costs a little primary load, while
53
+ # a misrouted write is an error (or worse) against a read-only replica.
54
+ #
55
+ # Because this is only reached for a confirmed read, there is no need to also
56
+ # test for a write here.
57
+ def can_go_to_replica?
58
+ read_query? && !should_go_to_primary?
59
+ end
60
+
61
+ def read_query?
62
+ match_any?(SQL_REPLICA_MATCHERS)
63
+ end
64
+
65
+ def should_go_to_primary?
66
+ Janus::Context.use_primary? ||
67
+ @_open_transactions.positive? ||
68
+ match_any?(SQL_PRIMARY_MATCHERS)
69
+ end
70
+
71
+ def match_any?(matchers)
72
+ matchers.any? { |matcher| normalized_sql.match?(matcher) }
73
+ end
74
+
75
+ # Avoid copying the statement when there is no leading comment/whitespace to
76
+ # strip, which is the common case for ActiveRecord-generated SQL.
77
+ def normalized_sql
78
+ @normalized_sql ||= strip_leading_noise
79
+ end
80
+
81
+ def strip_leading_noise
82
+ return @_sql unless LEADING_NOISE.match?(@_sql)
83
+
84
+ @_sql.sub(LEADING_NOISE, '')
85
+ end
86
+ end
87
+ 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 = 1
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
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)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janus-ar
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.0
4
+ version: 8.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lloyd Watkin
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-01-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -120,28 +119,28 @@ dependencies:
120
119
  requirements:
121
120
  - - "~>"
122
121
  - !ruby/object:Gem::Version
123
- version: 1.69.2
122
+ version: 1.90.0
124
123
  type: :development
125
124
  prerelease: false
126
125
  version_requirements: !ruby/object:Gem::Requirement
127
126
  requirements:
128
127
  - - "~>"
129
128
  - !ruby/object:Gem::Version
130
- version: 1.69.2
129
+ version: 1.90.0
131
130
  - !ruby/object:Gem::Dependency
132
131
  name: rubocop-rails
133
132
  requirement: !ruby/object:Gem::Requirement
134
133
  requirements:
135
134
  - - "~>"
136
135
  - !ruby/object:Gem::Version
137
- version: 2.28.0
136
+ version: 2.37.0
138
137
  type: :development
139
138
  prerelease: false
140
139
  version_requirements: !ruby/object:Gem::Requirement
141
140
  requirements:
142
141
  - - "~>"
143
142
  - !ruby/object:Gem::Version
144
- version: 2.28.0
143
+ version: 2.37.0
145
144
  - !ruby/object:Gem::Dependency
146
145
  name: rubocop-rspec
147
146
  requirement: !ruby/object:Gem::Requirement
@@ -187,52 +186,34 @@ dependencies:
187
186
  description: Read/Write proxy for ActiveRecord using primary/replica databases
188
187
  email:
189
188
  - lloyd@olioex.com
190
- executables:
191
- - release.sh
189
+ executables: []
192
190
  extensions: []
193
191
  extra_rdoc_files: []
194
192
  files:
195
- - ".bundle/config"
196
- - ".github/dependabot.yml"
197
- - ".github/workflows/ci.yml"
198
- - ".github/workflows/publish.yml"
199
- - ".gitignore"
200
- - ".rspec"
201
- - ".rubocop.yml"
202
193
  - CODE_OF_CONDUCT.md
203
194
  - Gemfile
204
195
  - Gemfile.lock
205
196
  - LICENSE
206
197
  - README.md
207
198
  - Rakefile
208
- - assets/.gitkeep
209
- - assets/janus-logo.png
210
- - bin/release.sh
211
199
  - janus-ar.gemspec
212
200
  - lib/janus-ar.rb
213
201
  - lib/janus-ar/active_record/connection_adapters/janus_mysql2_adapter.rb
214
202
  - lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter.rb
203
+ - lib/janus-ar/adapter_extensions.rb
215
204
  - lib/janus-ar/client.rb
216
205
  - lib/janus-ar/context.rb
217
206
  - lib/janus-ar/db_console_config.rb
218
207
  - lib/janus-ar/logging/logger.rb
219
208
  - lib/janus-ar/logging/subscriber.rb
220
209
  - lib/janus-ar/query_director.rb
210
+ - lib/janus-ar/railtie.rb
221
211
  - lib/janus-ar/version.rb
222
- - spec/lib/janus-ar/active_record/connection_adapters/janus_mysql_adapter_spec.rb
223
- - spec/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter_spec.rb
224
- - spec/lib/janus-ar/client_spec.rb
225
- - spec/lib/janus-ar/context_spec.rb
226
- - spec/lib/janus-ar/logging/logger_spec.rb
227
- - spec/lib/janus-ar/query_director_spec.rb
228
- - spec/shared_examples/a_mysql_like_server.rb
229
- - spec/spec_helper.rb
230
212
  homepage: https://github.com/olioex/janus-ar
231
213
  licenses:
232
214
  - MIT
233
215
  metadata:
234
216
  source_code_uri: https://github.com/olioex/janus-ar
235
- post_install_message:
236
217
  rdoc_options: []
237
218
  require_paths:
238
219
  - lib
@@ -247,8 +228,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
247
228
  - !ruby/object:Gem::Version
248
229
  version: '0'
249
230
  requirements: []
250
- rubygems_version: 3.5.23
251
- signing_key:
231
+ rubygems_version: 3.6.9
252
232
  specification_version: 4
253
233
  summary: Read/Write proxy for ActiveRecord using primary/replica databases
254
234
  test_files: []
data/.bundle/config DELETED
@@ -1,5 +0,0 @@
1
- ---
2
- BUNDLE_JOBS: "4"
3
- BUNDLE_BUILD__UNICODE: "--with-cflags=-Wno-incompatible-function-pointer-types"
4
- BUNDLE_BUILD__MYSQL2: "--with-ldflags=-L/opt/homebrew/opt/zstd/lib"
5
- BUNDLE_FROZEN: "false"
@@ -1,7 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: "bundler"
4
- directory: "/"
5
- schedule:
6
- interval: "weekly"
7
-