activerecord-wait_for_lsn 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +37 -0
- data/Rakefile +16 -5
- data/lib/active_record/wait_for_lsn/resolver.rb +23 -8
- data/lib/active_record/wait_for_lsn/session.rb +6 -0
- data/lib/active_record/wait_for_lsn/version.rb +1 -1
- data/lib/active_record/wait_for_lsn.rb +4 -7
- metadata +1 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4221e1d4c57f9b504e6fbd6b81e9cdf4e4a2af356d20f7c9d9d8a0135eb13379
|
|
4
|
+
data.tar.gz: 7f11cc5b8040fd5dc01564fc4ea56ffcbb0ecd4cc9cec86fa19803f9983bedd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8ba48eed7797370b91bb8bc780e80fc05ddbfa2eb0391168c351a2cb85cbc7ea8d31f659b11b68fd89577dfd3991dfbb8d0a43196eea5930f32f483b52df5fe
|
|
7
|
+
data.tar.gz: efb4f5e3ec5d155f7c4cb7df70252a944bc66cdb1d87cfbb253f8faf43157d5fbec192396b06ee8b069cc77085fe2690dcf305f7c51d73180c7bb00b21810fdc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-09-04
|
|
4
|
+
|
|
5
|
+
- `max_age` option (default 10 seconds): reads stop waiting for the LSN once
|
|
6
|
+
the last write is older than this and the LSN is dropped from the session.
|
|
7
|
+
Before, a session ran `WAIT FOR LSN` on every read for the rest of its life.
|
|
8
|
+
`max_age: nil` restores that behaviour. `Session` now also records Rails'
|
|
9
|
+
`last_write` timestamp and gained `clear_last_write_lsn`.
|
|
10
|
+
- Fix ActiveRecord 7.2: rescue `ConnectionNotEstablished` instead of
|
|
11
|
+
`ConnectionNotDefined`, which only exists since ActiveRecord 8.0. On 7.2 a
|
|
12
|
+
failed `WAIT FOR LSN` raised `NameError` instead of falling back to the primary.
|
|
13
|
+
- Cast `pg_current_wal_lsn()` to text, silencing the adapter's
|
|
14
|
+
`unknown OID 3220` warning on every write.
|
|
15
|
+
- Integration tests against a real PostgreSQL 19 primary and standby
|
|
16
|
+
(`rake test:integration`, cluster in `test/integration/compose.yml`).
|
|
17
|
+
- Drop the `zeitwerk` dependency; the gem requires its four files directly.
|
|
18
|
+
- GitHub Actions CI: rubocop, unit tests on Ruby 3.2–3.4 × ActiveRecord
|
|
19
|
+
7.2/8.0/8.1, integration tests per ActiveRecord version.
|
|
20
|
+
|
|
3
21
|
## 0.1.0 - 2026-09-04
|
|
4
22
|
|
|
5
23
|
First release.
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# activerecord-wait_for_lsn
|
|
2
2
|
|
|
3
|
+
[](https://github.com/izhanov/activerecord-wait_for_lsn/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
Read-your-writes on Rails read replicas via PostgreSQL 19
|
|
4
6
|
[`WAIT FOR LSN`](https://www.postgresql.org/docs/19/sql-wait-for.html).
|
|
5
7
|
|
|
@@ -34,6 +36,13 @@ Passed through the `database_selector` hash.
|
|
|
34
36
|
|------------|--------------------|-------------|
|
|
35
37
|
| `wait_timeout` | `"20ms"` | How long to wait for the replica before reading from the primary. Any PostgreSQL interval literal: `"200ms"`, `"1s"`. |
|
|
36
38
|
| `no_throw` | `true` | Return a status (`success`, `timeout`, `not in recovery`) instead of raising on timeout. |
|
|
39
|
+
| `max_age` | `10` | Seconds after a write during which reads wait for the LSN. Older writes are assumed replicated and the LSN is dropped from the session. `nil` waits forever. |
|
|
40
|
+
|
|
41
|
+
`max_age` is what keeps a session from running `WAIT FOR LSN` on every read for
|
|
42
|
+
the rest of its life: once the last write is older than `max_age` the resolver
|
|
43
|
+
stops checking and reads straight from the replica. Pick a value above the
|
|
44
|
+
replication lag you are prepared to tolerate; the lag gauges below show what
|
|
45
|
+
your standbys actually do. Rails' own `delay` option is not used.
|
|
37
46
|
|
|
38
47
|
## Several replicas
|
|
39
48
|
|
|
@@ -147,6 +156,34 @@ wait_for_lsn_replication_lag_seconds
|
|
|
147
156
|
max(wait_for_lsn_replication_lag_seconds) > 1
|
|
148
157
|
```
|
|
149
158
|
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
Unit tests need no database:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
bundle exec rake test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Integration tests run the resolver and the `DatabaseSelector` middleware
|
|
168
|
+
against a real PostgreSQL 19 primary and streaming standby. Start the cluster
|
|
169
|
+
from the bundled compose file, then point the tests at it:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
docker compose -f test/integration/compose.yml up -d --wait
|
|
173
|
+
PRIMARY_URL=postgres://postgres:postgres@localhost:55432/wait_for_lsn_test \
|
|
174
|
+
REPLICA_URL=postgres://postgres:postgres@localhost:55433/wait_for_lsn_test \
|
|
175
|
+
bundle exec rake test:integration
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Both connections must be superuser: the tests set `recovery_min_apply_delay`
|
|
179
|
+
on the standby to simulate lag. CI runs the suite against ActiveRecord 7.2,
|
|
180
|
+
8.0 and 8.1 via the gemfiles in `gemfiles/`:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
BUNDLE_GEMFILE=gemfiles/activerecord_7.2.gemfile bundle install
|
|
184
|
+
BUNDLE_GEMFILE=gemfiles/activerecord_7.2.gemfile bundle exec rake test
|
|
185
|
+
```
|
|
186
|
+
|
|
150
187
|
## License
|
|
151
188
|
|
|
152
189
|
MIT
|
data/Rakefile
CHANGED
|
@@ -3,10 +3,21 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "minitest/test_task"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Unit tests: no database needed.
|
|
7
|
+
Minitest::TestTask.create(:test) do |t|
|
|
8
|
+
t.test_globs = ["test/active_record/**/*_test.rb"]
|
|
9
|
+
end
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
# Integration tests against a real primary + standby, see test/integration/compose.yml.
|
|
12
|
+
Minitest::TestTask.create(:"test:integration") do |t|
|
|
13
|
+
t.test_globs = ["test/integration/**/*_test.rb"]
|
|
14
|
+
end
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
# The CI gemfiles under gemfiles/ carry no rubocop.
|
|
17
|
+
begin
|
|
18
|
+
require "rubocop/rake_task"
|
|
19
|
+
RuboCop::RakeTask.new
|
|
20
|
+
task default: %i[test rubocop]
|
|
21
|
+
rescue LoadError
|
|
22
|
+
task default: :test
|
|
23
|
+
end
|
|
@@ -5,15 +5,17 @@ module ActiveRecord
|
|
|
5
5
|
class Resolver < ::ActiveRecord::Middleware::DatabaseSelector::Resolver
|
|
6
6
|
DEFAULT_LSN_TIMEOUT = "20ms"
|
|
7
7
|
DEFAULT_LSN_NO_THROW = true
|
|
8
|
+
DEFAULT_LSN_MAX_AGE = 10 # seconds
|
|
8
9
|
LSN_MODE = "standby_replay"
|
|
9
10
|
LSN_OUTPUT_SUCCESS = "success"
|
|
10
11
|
|
|
11
|
-
attr_reader :wait_timeout, :no_throw
|
|
12
|
+
attr_reader :wait_timeout, :no_throw, :max_age
|
|
12
13
|
|
|
13
14
|
def initialize(context, options = {})
|
|
14
15
|
super
|
|
15
16
|
@wait_timeout = options.fetch(:wait_timeout, DEFAULT_LSN_TIMEOUT)
|
|
16
17
|
@no_throw = options.fetch(:no_throw, DEFAULT_LSN_NO_THROW)
|
|
18
|
+
@max_age = options.fetch(:max_age, DEFAULT_LSN_MAX_AGE)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def read(&blk)
|
|
@@ -47,17 +49,30 @@ module ActiveRecord
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def read_from_primary?
|
|
50
|
-
|
|
51
|
-
return false
|
|
52
|
-
return false if wait_for_lsn(lsn) == LSN_OUTPUT_SUCCESS
|
|
52
|
+
return false unless recent_write?
|
|
53
|
+
return false if wait_for_lsn(context.last_write_lsn) == LSN_OUTPUT_SUCCESS
|
|
53
54
|
|
|
54
55
|
release_replica_connection
|
|
55
56
|
true
|
|
56
|
-
rescue ActiveRecord::StatementInvalid, ActiveRecord::
|
|
57
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
57
58
|
release_replica_connection
|
|
58
59
|
true
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
def recent_write?
|
|
63
|
+
return false unless context.last_write_lsn
|
|
64
|
+
return true if within_max_age?
|
|
65
|
+
|
|
66
|
+
context.clear_last_write_lsn
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def within_max_age?
|
|
71
|
+
return true if max_age.nil?
|
|
72
|
+
|
|
73
|
+
Time.now - context.last_write_timestamp <= max_age
|
|
74
|
+
end
|
|
75
|
+
|
|
61
76
|
def wait_for_lsn(lsn)
|
|
62
77
|
connected_to_replica do
|
|
63
78
|
conn = ActiveRecord::Base.connection_pool.lease_connection
|
|
@@ -73,7 +88,7 @@ module ActiveRecord
|
|
|
73
88
|
connected_to_replica do
|
|
74
89
|
ActiveRecord::Base.connection_pool.release_connection
|
|
75
90
|
end
|
|
76
|
-
rescue ActiveRecord::
|
|
91
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
|
77
92
|
nil
|
|
78
93
|
end
|
|
79
94
|
|
|
@@ -83,13 +98,13 @@ module ActiveRecord
|
|
|
83
98
|
|
|
84
99
|
def database_name
|
|
85
100
|
ActiveRecord::Base.connection_pool.db_config.name
|
|
86
|
-
rescue ActiveRecord::
|
|
101
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
|
87
102
|
nil
|
|
88
103
|
end
|
|
89
104
|
|
|
90
105
|
def current_write_lsn
|
|
91
106
|
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
92
|
-
conn.select_value("SELECT pg_current_wal_lsn()
|
|
107
|
+
conn.select_value("SELECT pg_current_wal_lsn()::text")
|
|
93
108
|
end
|
|
94
109
|
end
|
|
95
110
|
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
|
-
require "zeitwerk"
|
|
5
4
|
|
|
6
5
|
require_relative "wait_for_lsn/version"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
loader.ignore("#{__dir__}/wait_for_lsn/yabeda.rb") # opt-in, required explicitly
|
|
12
|
-
loader.setup
|
|
6
|
+
require_relative "wait_for_lsn/session"
|
|
7
|
+
require_relative "wait_for_lsn/resolver"
|
|
8
|
+
require_relative "wait_for_lsn/read_query_patch"
|
|
9
|
+
# wait_for_lsn/yabeda is opt-in and required explicitly.
|
|
13
10
|
|
|
14
11
|
module ActiveRecord
|
|
15
12
|
# Read-your-writes for Rails replicas via PostgreSQL 19 +WAIT FOR LSN+.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-wait_for_lsn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aibek Izhanov
|
|
@@ -23,20 +23,6 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.2'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: zeitwerk
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '2.6'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '2.6'
|
|
40
26
|
description: |-
|
|
41
27
|
A DatabaseSelector resolver that stores the primary WAL LSN after each write and runs
|
|
42
28
|
WAIT FOR LSN on the replica before reading, falling back to the primary on timeout.
|