janus-ar 7.2.2 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +67 -59
- data/.gitignore +1 -0
- data/.rubocop.yml +29 -26
- data/Gemfile.lock +142 -124
- data/README.md +130 -129
- data/bin/release.sh +1 -0
- data/gemfiles/activerecord_8_0.gemfile +7 -0
- data/gemfiles/activerecord_8_1.gemfile +7 -0
- data/janus-ar.gemspec +36 -36
- data/lib/janus-ar/active_record/connection_adapters/janus_mysql2_adapter.rb +32 -147
- data/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter.rb +32 -148
- data/lib/janus-ar/adapter_extensions.rb +107 -0
- data/lib/janus-ar/context.rb +79 -80
- data/lib/janus-ar/query_director.rb +81 -54
- data/lib/janus-ar/railtie.rb +15 -0
- data/lib/janus-ar/version.rb +17 -17
- data/lib/janus-ar.rb +24 -22
- data/spec/lib/janus-ar/active_record/connection_adapters/janus_mysql_adapter_spec.rb +78 -82
- data/spec/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter_spec.rb +77 -82
- data/spec/lib/janus-ar/context_spec.rb +118 -46
- data/spec/lib/janus-ar/db_console_config_spec.rb +28 -0
- data/spec/lib/janus-ar/logging/subscriber_spec.rb +58 -0
- data/spec/lib/janus-ar/query_director_spec.rb +141 -59
- data/spec/shared_examples/a_mysql_like_server.rb +70 -0
- metadata +25 -16
|
@@ -1,59 +1,141 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
RSpec.describe Janus::QueryDirector do
|
|
4
|
-
describe 'Constants' do
|
|
5
|
-
it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] }
|
|
6
|
-
it {
|
|
7
|
-
expect(described_class::SQL_PRIMARY_MATCHERS).to eq(
|
|
8
|
-
[
|
|
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
|
-
]
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) }
|
|
16
|
-
it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) }
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
context '
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Janus::QueryDirector do
|
|
4
|
+
describe 'Constants' do
|
|
5
|
+
it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] }
|
|
6
|
+
it {
|
|
7
|
+
expect(described_class::SQL_PRIMARY_MATCHERS).to eq(
|
|
8
|
+
[
|
|
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
|
+
]
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) }
|
|
16
|
+
it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) }
|
|
17
|
+
|
|
18
|
+
it { expect(described_class::ALL).to eq :all }
|
|
19
|
+
it { expect(described_class::REPLICA).to eq :replica }
|
|
20
|
+
it { expect(described_class::PRIMARY).to eq :primary }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#where_to_send?' do
|
|
24
|
+
let(:open_transactions) { 0 }
|
|
25
|
+
|
|
26
|
+
before(:each) { Janus::Context.release_all }
|
|
27
|
+
|
|
28
|
+
context 'with reads' do
|
|
29
|
+
{
|
|
30
|
+
'plain select' => 'SELECT * FROM users',
|
|
31
|
+
'lower case select' => 'select * from users',
|
|
32
|
+
'CTE select' => 'WITH recent AS (SELECT * FROM users) SELECT * FROM recent',
|
|
33
|
+
'leading whitespace' => "\n\t SELECT 1",
|
|
34
|
+
}.each do |label, query|
|
|
35
|
+
it "routes a #{label} to the replica" do
|
|
36
|
+
expect(described_class.new(query, 0).where_to_send?).to eq(:replica)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context 'with writes' do
|
|
42
|
+
# Every one of these used to be sent to the replica because the router
|
|
43
|
+
# defaulted unknown statements there. They are genuine writes and must
|
|
44
|
+
# reach the primary.
|
|
45
|
+
%w(
|
|
46
|
+
INSERT UPDATE DELETE REPLACE RENAME CALL LOAD OPTIMIZE ANALYZE REPAIR
|
|
47
|
+
CREATE DROP TRUNCATE ALTER
|
|
48
|
+
).each do |verb|
|
|
49
|
+
it "routes #{verb} to the primary" do
|
|
50
|
+
sql = "#{verb} something that is not a read"
|
|
51
|
+
expect(described_class.new(sql, 0).where_to_send?).to eq(:primary)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'routes a write annotated with a leading comment to the primary' do
|
|
56
|
+
sql = '/* app:web,controller:orders */ INSERT INTO orders (id) VALUES (1)'
|
|
57
|
+
expect(described_class.new(sql, 0).where_to_send?).to eq(:primary)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'routes a read annotated with a leading comment to the replica' do
|
|
61
|
+
sql = '/* app:web */ SELECT * FROM orders'
|
|
62
|
+
expect(described_class.new(sql, 0).where_to_send?).to eq(:replica)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'with comments and whitespace around the statement' do
|
|
67
|
+
{
|
|
68
|
+
'a line (--) comment then a read' => "-- audit trail\nSELECT * FROM orders",
|
|
69
|
+
'a hash (#) comment then a read' => "# cache key\nSELECT * FROM orders",
|
|
70
|
+
'several stacked leading comments then a read' => "/* a */ /* b */\n\t SELECT 1",
|
|
71
|
+
'tabs and newlines then a read' => "\n\t SELECT 1",
|
|
72
|
+
}.each do |label, query|
|
|
73
|
+
it "routes #{label} to the replica" do
|
|
74
|
+
expect(described_class.new(query, 0).where_to_send?).to eq(:replica)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
'a line (--) comment then a write' => "-- triggered by job 42\nUPDATE orders SET state = 1",
|
|
80
|
+
'a hash (#) comment then a write' => "# backfill\nDELETE FROM orders",
|
|
81
|
+
'a block comment then a write' => "/* migration */ ALTER TABLE orders ADD COLUMN x INT",
|
|
82
|
+
}.each do |label, query|
|
|
83
|
+
it "routes #{label} to the primary" do
|
|
84
|
+
expect(described_class.new(query, 0).where_to_send?).to eq(:primary)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
context 'with locking reads' do
|
|
90
|
+
it 'routes SELECT ... FOR UPDATE to the primary' do
|
|
91
|
+
expect(described_class.new('SELECT * FROM users FOR UPDATE', 0).where_to_send?).to eq(:primary)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'routes SELECT ... LOCK IN SHARE MODE to the primary' do
|
|
95
|
+
expect(described_class.new('SELECT * FROM users LOCK IN SHARE MODE', 0).where_to_send?).to eq(:primary)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'routes advisory lock reads to the primary' do
|
|
99
|
+
expect(described_class.new("SELECT get_lock('x', 0)", 0).where_to_send?).to eq(:primary)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
context 'with SHOW' do
|
|
104
|
+
it 'routes SHOW statements to the primary' do
|
|
105
|
+
expect(described_class.new('SHOW TABLES', 0).where_to_send?).to eq(:primary)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context 'with SET' do
|
|
110
|
+
it 'routes SET to all connections' do
|
|
111
|
+
expect(described_class.new('SET sql_mode = ?', 0).where_to_send?).to eq(:all)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'does not broadcast SET LOCAL to all connections' do
|
|
115
|
+
expect(described_class.new('SET LOCAL sql_mode = ?', 0).where_to_send?).to eq(:primary)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
context 'when the context is stuck to the primary' do
|
|
120
|
+
before(:each) { Janus::Context.stick_to_primary }
|
|
121
|
+
|
|
122
|
+
it 'routes an otherwise replica-bound read to the primary' do
|
|
123
|
+
expect(described_class.new('SELECT * FROM users', 0).where_to_send?).to eq(:primary)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context 'when inside a transaction' do
|
|
128
|
+
let(:open_transactions) { 1 }
|
|
129
|
+
|
|
130
|
+
it 'routes reads to the primary so they can see uncommitted writes' do
|
|
131
|
+
expect(described_class.new('SELECT * FROM users', open_transactions).where_to_send?).to eq(:primary)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
context 'with an unrecognised / empty statement' do
|
|
136
|
+
it 'defaults blank input to the primary' do
|
|
137
|
+
expect(described_class.new(' ', 0).where_to_send?).to eq(:primary)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -82,4 +82,74 @@ RSpec.shared_examples 'a mysql like server' do
|
|
|
82
82
|
expect(Janus::Context.last_used_connection).to eq :replica
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
|
+
|
|
86
|
+
describe 'ActiveRecord compatibility' do
|
|
87
|
+
before(:each) do
|
|
88
|
+
create_test_table
|
|
89
|
+
Janus::Context.release_all
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'accepts the optional name argument on #execute' do
|
|
93
|
+
expect do
|
|
94
|
+
ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;", 'CustomName')
|
|
95
|
+
end.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'returns a usable result through the exec_query read path' do
|
|
99
|
+
ActiveRecord::Base.connection.execute("INSERT INTO `#{table_name}` SET `id` = 7;")
|
|
100
|
+
Janus::Context.release_all
|
|
101
|
+
$query_logger.flush_all
|
|
102
|
+
|
|
103
|
+
result = ActiveRecord::Base.connection.exec_query("SELECT `id` FROM `#{table_name}`")
|
|
104
|
+
|
|
105
|
+
expect(result.rows).to eq [[7]]
|
|
106
|
+
expect($query_logger.queries.first).to include '[replica]'
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe 'Transactions' do
|
|
111
|
+
before(:each) do
|
|
112
|
+
create_test_table
|
|
113
|
+
Janus::Context.release_all
|
|
114
|
+
$query_logger.flush_all
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'routes reads inside a transaction to the primary' do
|
|
118
|
+
ActiveRecord::Base.transaction do
|
|
119
|
+
ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
selects = $query_logger.queries.select { |q| q.downcase.include?("select * from `#{table_name}`") }
|
|
123
|
+
expect(selects).not_to be_empty
|
|
124
|
+
expect(selects).to all(include('[primary]'))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'keeps later reads on the primary until the context is released' do
|
|
128
|
+
ActiveRecord::Base.transaction do
|
|
129
|
+
ActiveRecord::Base.connection.execute("INSERT INTO `#{table_name}` SET `id` = 1;")
|
|
130
|
+
end
|
|
131
|
+
$query_logger.flush_all
|
|
132
|
+
|
|
133
|
+
ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
|
|
134
|
+
expect($query_logger.queries.last).to include '[primary]'
|
|
135
|
+
|
|
136
|
+
Janus::Context.release_all
|
|
137
|
+
ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
|
|
138
|
+
expect($query_logger.queries.last).to include '[replica]'
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe 'SET statements' do
|
|
143
|
+
before(:each) { Janus::Context.release_all }
|
|
144
|
+
|
|
145
|
+
it 'sends a session SET down the broadcast (:all) path without error' do
|
|
146
|
+
expect do
|
|
147
|
+
ActiveRecord::Base.connection.execute("SET SESSION time_zone = '+00:00'")
|
|
148
|
+
end.not_to raise_error
|
|
149
|
+
|
|
150
|
+
# `:all` means the statement ran against the replica connection too, not
|
|
151
|
+
# just the primary - a write would have been marked `:primary`.
|
|
152
|
+
expect(Janus::Context.last_used_connection).to eq :all
|
|
153
|
+
end
|
|
154
|
+
end
|
|
85
155
|
end
|
metadata
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: janus-ar
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 8.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lloyd Watkin
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-06-19 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activerecord
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
17
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
- - "<"
|
|
18
20
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
21
|
+
version: '9.0'
|
|
20
22
|
type: :runtime
|
|
21
23
|
prerelease: false
|
|
22
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
25
|
requirements:
|
|
24
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
25
30
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
31
|
+
version: '9.0'
|
|
27
32
|
- !ruby/object:Gem::Dependency
|
|
28
33
|
name: activesupport
|
|
29
34
|
requirement: !ruby/object:Gem::Requirement
|
|
30
35
|
requirements:
|
|
31
36
|
- - ">="
|
|
32
37
|
- !ruby/object:Gem::Version
|
|
33
|
-
version:
|
|
38
|
+
version: '8.0'
|
|
34
39
|
type: :development
|
|
35
40
|
prerelease: false
|
|
36
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
42
|
requirements:
|
|
38
43
|
- - ">="
|
|
39
44
|
- !ruby/object:Gem::Version
|
|
40
|
-
version:
|
|
45
|
+
version: '8.0'
|
|
41
46
|
- !ruby/object:Gem::Dependency
|
|
42
47
|
name: mysql2
|
|
43
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -114,28 +119,28 @@ dependencies:
|
|
|
114
119
|
requirements:
|
|
115
120
|
- - "~>"
|
|
116
121
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: 1.
|
|
122
|
+
version: 1.88.0
|
|
118
123
|
type: :development
|
|
119
124
|
prerelease: false
|
|
120
125
|
version_requirements: !ruby/object:Gem::Requirement
|
|
121
126
|
requirements:
|
|
122
127
|
- - "~>"
|
|
123
128
|
- !ruby/object:Gem::Version
|
|
124
|
-
version: 1.
|
|
129
|
+
version: 1.88.0
|
|
125
130
|
- !ruby/object:Gem::Dependency
|
|
126
131
|
name: rubocop-rails
|
|
127
132
|
requirement: !ruby/object:Gem::Requirement
|
|
128
133
|
requirements:
|
|
129
134
|
- - "~>"
|
|
130
135
|
- !ruby/object:Gem::Version
|
|
131
|
-
version: 2.
|
|
136
|
+
version: 2.35.2
|
|
132
137
|
type: :development
|
|
133
138
|
prerelease: false
|
|
134
139
|
version_requirements: !ruby/object:Gem::Requirement
|
|
135
140
|
requirements:
|
|
136
141
|
- - "~>"
|
|
137
142
|
- !ruby/object:Gem::Version
|
|
138
|
-
version: 2.
|
|
143
|
+
version: 2.35.2
|
|
139
144
|
- !ruby/object:Gem::Dependency
|
|
140
145
|
name: rubocop-rspec
|
|
141
146
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -202,22 +207,28 @@ files:
|
|
|
202
207
|
- assets/.gitkeep
|
|
203
208
|
- assets/janus-logo.png
|
|
204
209
|
- bin/release.sh
|
|
210
|
+
- gemfiles/activerecord_8_0.gemfile
|
|
211
|
+
- gemfiles/activerecord_8_1.gemfile
|
|
205
212
|
- janus-ar.gemspec
|
|
206
213
|
- lib/janus-ar.rb
|
|
207
214
|
- lib/janus-ar/active_record/connection_adapters/janus_mysql2_adapter.rb
|
|
208
215
|
- lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter.rb
|
|
216
|
+
- lib/janus-ar/adapter_extensions.rb
|
|
209
217
|
- lib/janus-ar/client.rb
|
|
210
218
|
- lib/janus-ar/context.rb
|
|
211
219
|
- lib/janus-ar/db_console_config.rb
|
|
212
220
|
- lib/janus-ar/logging/logger.rb
|
|
213
221
|
- lib/janus-ar/logging/subscriber.rb
|
|
214
222
|
- lib/janus-ar/query_director.rb
|
|
223
|
+
- lib/janus-ar/railtie.rb
|
|
215
224
|
- lib/janus-ar/version.rb
|
|
216
225
|
- spec/lib/janus-ar/active_record/connection_adapters/janus_mysql_adapter_spec.rb
|
|
217
226
|
- spec/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter_spec.rb
|
|
218
227
|
- spec/lib/janus-ar/client_spec.rb
|
|
219
228
|
- spec/lib/janus-ar/context_spec.rb
|
|
229
|
+
- spec/lib/janus-ar/db_console_config_spec.rb
|
|
220
230
|
- spec/lib/janus-ar/logging/logger_spec.rb
|
|
231
|
+
- spec/lib/janus-ar/logging/subscriber_spec.rb
|
|
221
232
|
- spec/lib/janus-ar/query_director_spec.rb
|
|
222
233
|
- spec/shared_examples/a_mysql_like_server.rb
|
|
223
234
|
- spec/spec_helper.rb
|
|
@@ -226,7 +237,6 @@ licenses:
|
|
|
226
237
|
- MIT
|
|
227
238
|
metadata:
|
|
228
239
|
source_code_uri: https://github.com/olioex/janus-ar
|
|
229
|
-
post_install_message:
|
|
230
240
|
rdoc_options: []
|
|
231
241
|
require_paths:
|
|
232
242
|
- lib
|
|
@@ -241,8 +251,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
241
251
|
- !ruby/object:Gem::Version
|
|
242
252
|
version: '0'
|
|
243
253
|
requirements: []
|
|
244
|
-
rubygems_version: 3.
|
|
245
|
-
signing_key:
|
|
254
|
+
rubygems_version: 3.6.2
|
|
246
255
|
specification_version: 4
|
|
247
256
|
summary: Read/Write proxy for ActiveRecord using primary/replica databases
|
|
248
257
|
test_files: []
|