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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbd7e6e0f5eb7df644152b7ca91788239c5fbbc650ccd1fc6e17666cf70c0f85
4
- data.tar.gz: f7d5f9438d176acf4b937a1786b570cd1253092ee8de6aacb7e392dabdf4e77f
3
+ metadata.gz: 4221e1d4c57f9b504e6fbd6b81e9cdf4e4a2af356d20f7c9d9d8a0135eb13379
4
+ data.tar.gz: 7f11cc5b8040fd5dc01564fc4ea56ffcbb0ecd4cc9cec86fa19803f9983bedd6
5
5
  SHA512:
6
- metadata.gz: 706c03870f25a5f9ea1642d2825b5eacc1a83cc617460e201f47f4b200db63aa8ef218cf4e2611d57048f235cd4c67e7786bb4082fa63aea37e090c51f09fdf6
7
- data.tar.gz: 56f9d278efbd92800270b8bc35e1c2034e56567276fe3033f4a763d2a2faa0e7bd265b3e500fbe2106fcd865d70213ab812a2201dbb4c07aa24f9e985a985617
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
+ [![CI](https://github.com/izhanov/activerecord-wait_for_lsn/actions/workflows/ci.yml/badge.svg)](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
- Minitest::TestTask.create
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
- require "rubocop/rake_task"
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
- RuboCop::RakeTask.new
11
-
12
- task default: %i[test rubocop]
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
- lsn = context.last_write_lsn
51
- return false unless lsn
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::ConnectionNotDefined
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::ConnectionNotDefined
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::ConnectionNotDefined
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
 
@@ -9,6 +9,12 @@ module ActiveRecord
9
9
 
10
10
  def update_last_write_lsn(new_lsn)
11
11
  session[:last_write_lsn] = new_lsn
12
+ update_last_write_timestamp
13
+ end
14
+
15
+ def clear_last_write_lsn
16
+ session.delete(:last_write_lsn)
17
+ session.delete(:last_write)
12
18
  end
13
19
  end
14
20
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module WaitForLSN
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -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
- loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord)
9
- loader.inflector.inflect("wait_for_lsn" => "WaitForLSN")
10
- loader.ignore("#{__dir__}/wait_for_lsn/version.rb")
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.1.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.