activerecord-wait_for_lsn 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +152 -0
- data/Rakefile +12 -0
- data/lib/active_record/wait_for_lsn/read_query_patch.rb +15 -0
- data/lib/active_record/wait_for_lsn/resolver.rb +106 -0
- data/lib/active_record/wait_for_lsn/session.rb +15 -0
- data/lib/active_record/wait_for_lsn/version.rb +7 -0
- data/lib/active_record/wait_for_lsn/yabeda.rb +70 -0
- data/lib/active_record/wait_for_lsn.rb +28 -0
- data/lib/activerecord-wait_for_lsn.rb +3 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cbd7e6e0f5eb7df644152b7ca91788239c5fbbc650ccd1fc6e17666cf70c0f85
|
|
4
|
+
data.tar.gz: f7d5f9438d176acf4b937a1786b570cd1253092ee8de6aacb7e392dabdf4e77f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 706c03870f25a5f9ea1642d2825b5eacc1a83cc617460e201f47f4b200db63aa8ef218cf4e2611d57048f235cd4c67e7786bb4082fa63aea37e090c51f09fdf6
|
|
7
|
+
data.tar.gz: 56f9d278efbd92800270b8bc35e1c2034e56567276fe3033f4a763d2a2faa0e7bd265b3e500fbe2106fcd865d70213ab812a2201dbb4c07aa24f9e985a985617
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-09-04
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Keep the replica connection leased for the whole request. `WAIT FOR LSN`
|
|
8
|
+
and every read that follows now run on the same connection, so the guarantee
|
|
9
|
+
holds behind a TCP load balancer or a libpq multi-host `host=a,b` where the
|
|
10
|
+
pool's connections point at different standbys. Before, the read could pick
|
|
11
|
+
another pooled connection and land on a standby that had not replayed the
|
|
12
|
+
LSN yet.
|
|
13
|
+
- Add `database` (the `database.yml` entry name) to the `read_from_replica`,
|
|
14
|
+
`read_from_primary` and `wait_for_lsn` event payloads.
|
|
15
|
+
- Add an opt-in Yabeda integration: `require "active_record/wait_for_lsn/yabeda"`
|
|
16
|
+
and `ActiveRecord::WaitForLSN::Yabeda.collect_replication_lag!` fill
|
|
17
|
+
`wait_for_lsn_replication_lag_seconds` and `wait_for_lsn_replication_lag_bytes`
|
|
18
|
+
per standby from `pg_stat_replication` on every export.
|
|
19
|
+
- Drop the `mode` option. The wait always uses `standby_replay`, the only mode
|
|
20
|
+
that guarantees a following `SELECT` on the replica sees the write.
|
|
21
|
+
`standby_write` and `standby_flush` only confirm WAL arrival, so a read could
|
|
22
|
+
still miss a fresh row or return a stale one. `primary_flush` is primary-only
|
|
23
|
+
and never applied to the replica wait.
|
|
24
|
+
- Rename `timeout` to `wait_timeout` and lower the default from `500ms` to
|
|
25
|
+
`20ms`.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 izhanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# activerecord-wait_for_lsn
|
|
2
|
+
|
|
3
|
+
Read-your-writes on Rails read replicas via PostgreSQL 19
|
|
4
|
+
[`WAIT FOR LSN`](https://www.postgresql.org/docs/19/sql-wait-for.html).
|
|
5
|
+
|
|
6
|
+
After a write the resolver stores the primary's `pg_current_wal_lsn()` in the
|
|
7
|
+
session. Before a read it runs `WAIT FOR LSN` on the replica. If the replica has
|
|
8
|
+
caught up, the read goes to the replica, otherwise to the primary.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Gemfile
|
|
14
|
+
gem "activerecord-wait_for_lsn"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# config/initializers/multi_db.rb
|
|
19
|
+
Rails.application.configure do
|
|
20
|
+
config.active_record.database_selector = { wait_timeout: "50ms" } # default 20ms
|
|
21
|
+
config.active_record.database_resolver = ActiveRecord::WaitForLSN::Resolver
|
|
22
|
+
config.active_record.database_resolver_context = ActiveRecord::WaitForLSN::Session
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires PostgreSQL 19, a streaming standby configured as `replica: true`, and
|
|
27
|
+
Rails 7.2+.
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
Passed through the `database_selector` hash.
|
|
32
|
+
|
|
33
|
+
| Option | Default | Description |
|
|
34
|
+
|------------|--------------------|-------------|
|
|
35
|
+
| `wait_timeout` | `"20ms"` | How long to wait for the replica before reading from the primary. Any PostgreSQL interval literal: `"200ms"`, `"1s"`. |
|
|
36
|
+
| `no_throw` | `true` | Return a status (`success`, `timeout`, `not in recovery`) instead of raising on timeout. |
|
|
37
|
+
|
|
38
|
+
## Several replicas
|
|
39
|
+
|
|
40
|
+
Rails has one reading role per class, so several standbys sit behind one
|
|
41
|
+
`replica` entry: a TCP load balancer (HAProxy, PgBouncer in session mode) or
|
|
42
|
+
libpq's own multi-host connection string. With PostgreSQL 16+ libpq the entry
|
|
43
|
+
can spread connections itself:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
replica:
|
|
47
|
+
<<: *default
|
|
48
|
+
host: standby1,standby2,standby3
|
|
49
|
+
port: "5432,5432,5432" # quoted, or YAML reads it as one number
|
|
50
|
+
load_balance_hosts: random
|
|
51
|
+
replica: true
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every request leases one replica connection: the `WAIT FOR LSN` and all reads
|
|
55
|
+
of the request run over that connection, hence on the same standby, whichever
|
|
56
|
+
one the pool handed out. Transaction-pooling proxies that spread the statements
|
|
57
|
+
of one client connection over several servers break the guarantee.
|
|
58
|
+
|
|
59
|
+
Note that `load_balance_hosts` balances connections, not requests: libpq picks
|
|
60
|
+
a host when a connection is opened, and the pool then reuses its most recently
|
|
61
|
+
returned connections first. Under light load a couple of pooled connections
|
|
62
|
+
serve most requests, so the standbys they landed on see most of the reads.
|
|
63
|
+
For an even spread per request put a round-robin TCP balancer in front of the
|
|
64
|
+
standbys.
|
|
65
|
+
|
|
66
|
+
Which standby is behind and by how much is visible per standby through the
|
|
67
|
+
[lag metrics](#metrics), which come from `pg_stat_replication` on the primary
|
|
68
|
+
and cover every streaming standby, whether or not it takes reads.
|
|
69
|
+
|
|
70
|
+
## Wait mode
|
|
71
|
+
|
|
72
|
+
The wait always uses `MODE 'standby_replay'`: the LSN must be applied on the
|
|
73
|
+
standby, so a following `SELECT` sees the write. The other modes
|
|
74
|
+
(`standby_write`, `standby_flush`) only confirm the WAL has arrived on the
|
|
75
|
+
standby, not that it is visible, so they are not exposed.
|
|
76
|
+
|
|
77
|
+
## Instrumentation
|
|
78
|
+
|
|
79
|
+
Each wait emits `database_selector.active_record.wait_for_lsn` with `lsn`,
|
|
80
|
+
`lsn_status` and `database` (the `database.yml` entry of the standby) in the
|
|
81
|
+
payload. On `success` the event duration is how long the replica took to
|
|
82
|
+
replay your last write. `database_selector.active_record.read_from_replica`
|
|
83
|
+
and `database_selector.active_record.read_from_primary` fire around every read,
|
|
84
|
+
also with `database` in the payload, so per-replica counters are one
|
|
85
|
+
subscriber away:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
ActiveSupport::Notifications.subscribe("database_selector.active_record.wait_for_lsn") do |event|
|
|
89
|
+
Rails.logger.info "WAIT FOR LSN on #{event.payload[:database]}: #{event.payload[:lsn_status]} in #{event.duration.round}ms"
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Metrics
|
|
94
|
+
|
|
95
|
+
An opt-in [Yabeda](https://github.com/yabeda-rb/yabeda) integration exports
|
|
96
|
+
replication lag per standby. Add Yabeda and an exporter to the Gemfile:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
gem "yabeda"
|
|
100
|
+
gem "yabeda-prometheus" # or yabeda-datadog, yabeda-statsd, ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then require the integration from an initializer and opt in:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# config/initializers/multi_db.rb
|
|
107
|
+
require "active_record/wait_for_lsn/yabeda"
|
|
108
|
+
ActiveRecord::WaitForLSN::Yabeda.collect_replication_lag!
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Before every export (each Prometheus scrape) it runs one query against the
|
|
112
|
+
primary and sets two gauges per streaming standby, replay stage only, since
|
|
113
|
+
that is the point at which a `SELECT` on the standby sees the write:
|
|
114
|
+
|
|
115
|
+
| Metric | Tags | Description |
|
|
116
|
+
|--------|------|-------------|
|
|
117
|
+
| `wait_for_lsn_replication_lag_seconds` | `standby` | `replay_lag` from `pg_stat_replication`. |
|
|
118
|
+
| `wait_for_lsn_replication_lag_bytes` | `standby` | `pg_current_wal_lsn() - replay_lsn`. |
|
|
119
|
+
|
|
120
|
+
`standby` is the `application_name` the standby sets in `primary_conninfo`,
|
|
121
|
+
which falls back to its `cluster_name`.
|
|
122
|
+
|
|
123
|
+
Why `pg_stat_replication` and not the wait itself: the duration of the
|
|
124
|
+
`wait_for_lsn` event is how long a request waited for its own write, capped by
|
|
125
|
+
`wait_timeout`. A replica that caught up before the next request arrives shows
|
|
126
|
+
a wait of zero. The real lag is measured by PostgreSQL from standby feedback.
|
|
127
|
+
Per-request counters (reads by role, wait statuses) are yours to build from the
|
|
128
|
+
events above if you need them.
|
|
129
|
+
|
|
130
|
+
Requirements and caveats:
|
|
131
|
+
|
|
132
|
+
- The application's database role needs `pg_read_all_stats` (or superuser),
|
|
133
|
+
otherwise the lag columns are `NULL` for every row:
|
|
134
|
+
```sql
|
|
135
|
+
GRANT pg_read_all_stats TO app;
|
|
136
|
+
```
|
|
137
|
+
- PostgreSQL reports `NULL` once a standby is fully caught up and the primary
|
|
138
|
+
is idle. Both gauges record it as `0`.
|
|
139
|
+
- The query runs on the writing connection with `prevent_writes: true` and
|
|
140
|
+
costs one round trip per scrape, not per request.
|
|
141
|
+
|
|
142
|
+
```promql
|
|
143
|
+
# Replay lag per standby, the number DBAs see in pg_stat_replication
|
|
144
|
+
wait_for_lsn_replication_lag_seconds
|
|
145
|
+
|
|
146
|
+
# Alert when a standby falls more than a second behind
|
|
147
|
+
max(wait_for_lsn_replication_lag_seconds) > 1
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module WaitForLSN
|
|
5
|
+
class Resolver < ::ActiveRecord::Middleware::DatabaseSelector::Resolver
|
|
6
|
+
DEFAULT_LSN_TIMEOUT = "20ms"
|
|
7
|
+
DEFAULT_LSN_NO_THROW = true
|
|
8
|
+
LSN_MODE = "standby_replay"
|
|
9
|
+
LSN_OUTPUT_SUCCESS = "success"
|
|
10
|
+
|
|
11
|
+
attr_reader :wait_timeout, :no_throw
|
|
12
|
+
|
|
13
|
+
def initialize(context, options = {})
|
|
14
|
+
super
|
|
15
|
+
@wait_timeout = options.fetch(:wait_timeout, DEFAULT_LSN_TIMEOUT)
|
|
16
|
+
@no_throw = options.fetch(:no_throw, DEFAULT_LSN_NO_THROW)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read(&blk)
|
|
20
|
+
super
|
|
21
|
+
ensure
|
|
22
|
+
release_replica_connection
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def write_to_primary
|
|
28
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, prevent_writes: false) do
|
|
29
|
+
instrumenter.instrument("database_selector.active_record.write_to_primary") do
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
context.update_last_write_lsn(current_write_lsn)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read_from_primary(&blk)
|
|
38
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, prevent_writes: true) do
|
|
39
|
+
instrumenter.instrument("database_selector.active_record.read_from_primary", database: database_name, &blk)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read_from_replica(&blk)
|
|
44
|
+
connected_to_replica(prevent_writes: true) do
|
|
45
|
+
instrumenter.instrument("database_selector.active_record.read_from_replica", database: database_name, &blk)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
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
|
|
53
|
+
|
|
54
|
+
release_replica_connection
|
|
55
|
+
true
|
|
56
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
|
|
57
|
+
release_replica_connection
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wait_for_lsn(lsn)
|
|
62
|
+
connected_to_replica do
|
|
63
|
+
conn = ActiveRecord::Base.connection_pool.lease_connection
|
|
64
|
+
query = "WAIT FOR LSN #{conn.quote(lsn)} WITH (#{wait_for_lsn_options})"
|
|
65
|
+
payload = { lsn: lsn, database: database_name }
|
|
66
|
+
instrumenter.instrument("database_selector.active_record.wait_for_lsn", payload) do |p|
|
|
67
|
+
p[:lsn_status] = conn.select_value(query)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def release_replica_connection
|
|
73
|
+
connected_to_replica do
|
|
74
|
+
ActiveRecord::Base.connection_pool.release_connection
|
|
75
|
+
end
|
|
76
|
+
rescue ActiveRecord::ConnectionNotDefined
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def connected_to_replica(prevent_writes: false, &blk)
|
|
81
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.reading_role, prevent_writes: prevent_writes, &blk)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def database_name
|
|
85
|
+
ActiveRecord::Base.connection_pool.db_config.name
|
|
86
|
+
rescue ActiveRecord::ConnectionNotDefined
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_write_lsn
|
|
91
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
92
|
+
conn.select_value("SELECT pg_current_wal_lsn();")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def wait_for_lsn_options
|
|
97
|
+
options = { timeout: wait_timeout, mode: LSN_MODE, no_throw: no_throw }
|
|
98
|
+
options.filter_map do |(opt, val)|
|
|
99
|
+
next unless val
|
|
100
|
+
|
|
101
|
+
opt == :no_throw ? opt.to_s.upcase : "#{opt.upcase} '#{val}'"
|
|
102
|
+
end.join(", ")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module WaitForLSN
|
|
5
|
+
class Session < ::ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
|
|
6
|
+
def last_write_lsn
|
|
7
|
+
session[:last_write_lsn]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def update_last_write_lsn(new_lsn)
|
|
11
|
+
session[:last_write_lsn] = new_lsn
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yabeda"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module WaitForLSN
|
|
7
|
+
# Opt-in Yabeda metrics. Not loaded automatically; require it from an initializer:
|
|
8
|
+
#
|
|
9
|
+
# require "active_record/wait_for_lsn/yabeda"
|
|
10
|
+
# ActiveRecord::WaitForLSN::Yabeda.collect_replication_lag!
|
|
11
|
+
#
|
|
12
|
+
# Registers the +wait_for_lsn+ group with replication lag gauges per standby.
|
|
13
|
+
# They are filled before every export by querying +pg_stat_replication+ on the
|
|
14
|
+
# primary, which needs +pg_read_all_stats+. Metrics are recorded once
|
|
15
|
+
# +Yabeda.configure!+ has run, which Yabeda's own Railtie does after the app
|
|
16
|
+
# is initialized.
|
|
17
|
+
module Yabeda
|
|
18
|
+
# Replay stage only: the point at which a SELECT on the standby sees the write.
|
|
19
|
+
REPLICATION_LAG_SQL = <<~SQL
|
|
20
|
+
SELECT application_name,
|
|
21
|
+
pg_current_wal_lsn() - replay_lsn AS replay_lag_bytes,
|
|
22
|
+
EXTRACT(EPOCH FROM replay_lag) AS replay_lag_seconds
|
|
23
|
+
FROM pg_stat_replication
|
|
24
|
+
WHERE state = 'streaming'
|
|
25
|
+
SQL
|
|
26
|
+
|
|
27
|
+
::Yabeda.configure do
|
|
28
|
+
group :wait_for_lsn do
|
|
29
|
+
gauge :replication_lag,
|
|
30
|
+
comment: "Replay lag reported by pg_stat_replication on the primary",
|
|
31
|
+
unit: :seconds,
|
|
32
|
+
tags: [:standby]
|
|
33
|
+
|
|
34
|
+
gauge :replication_lag_bytes,
|
|
35
|
+
comment: "WAL bytes the standby has yet to replay, from pg_stat_replication",
|
|
36
|
+
tags: [:standby]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Queries pg_stat_replication on the primary before every export (each
|
|
42
|
+
# Prometheus scrape) and sets the replication lag gauges per standby.
|
|
43
|
+
def collect_replication_lag!
|
|
44
|
+
::Yabeda.collect { record_replication_lag }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_replication_lag
|
|
48
|
+
return unless ::Yabeda.already_configured?
|
|
49
|
+
|
|
50
|
+
replication_lag_rows.each do |row|
|
|
51
|
+
tags = { standby: row["application_name"] }
|
|
52
|
+
# NULL means the standby is idle and fully caught up.
|
|
53
|
+
::Yabeda.wait_for_lsn.replication_lag.set(tags, row["replay_lag_seconds"].to_f)
|
|
54
|
+
::Yabeda.wait_for_lsn.replication_lag_bytes.set(tags, row["replay_lag_bytes"].to_i)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def replication_lag_rows
|
|
61
|
+
ActiveRecord::Base.connected_to(role: ActiveRecord.writing_role, prevent_writes: true) do
|
|
62
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
63
|
+
conn.select_all(REPLICATION_LAG_SQL).to_a
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "zeitwerk"
|
|
5
|
+
|
|
6
|
+
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
|
|
13
|
+
|
|
14
|
+
module ActiveRecord
|
|
15
|
+
# Read-your-writes for Rails replicas via PostgreSQL 19 +WAIT FOR LSN+.
|
|
16
|
+
#
|
|
17
|
+
# config.active_record.database_selector = { wait_timeout: "50ms" }
|
|
18
|
+
# config.active_record.database_resolver = ActiveRecord::WaitForLSN::Resolver
|
|
19
|
+
# config.active_record.database_resolver_context = ActiveRecord::WaitForLSN::Session
|
|
20
|
+
module WaitForLSN
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Rails classifies SQL on replica connections by its first keyword and treats
|
|
25
|
+
# anything unknown as a write. Teach the PostgreSQL adapter that WAIT FOR is read-only.
|
|
26
|
+
ActiveSupport.on_load(:active_record_postgresqladapter) do
|
|
27
|
+
prepend ActiveRecord::WaitForLSN::ReadQueryPatch
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-wait_for_lsn
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aibek Izhanov
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
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
|
+
description: |-
|
|
41
|
+
A DatabaseSelector resolver that stores the primary WAL LSN after each write and runs
|
|
42
|
+
WAIT FOR LSN on the replica before reading, falling back to the primary on timeout.
|
|
43
|
+
email:
|
|
44
|
+
- aibek.izhanov@evilmartians.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE.txt
|
|
51
|
+
- README.md
|
|
52
|
+
- Rakefile
|
|
53
|
+
- lib/active_record/wait_for_lsn.rb
|
|
54
|
+
- lib/active_record/wait_for_lsn/read_query_patch.rb
|
|
55
|
+
- lib/active_record/wait_for_lsn/resolver.rb
|
|
56
|
+
- lib/active_record/wait_for_lsn/session.rb
|
|
57
|
+
- lib/active_record/wait_for_lsn/version.rb
|
|
58
|
+
- lib/active_record/wait_for_lsn/yabeda.rb
|
|
59
|
+
- lib/activerecord-wait_for_lsn.rb
|
|
60
|
+
homepage: https://github.com/izhanov/activerecord-wait_for_lsn
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
homepage_uri: https://github.com/izhanov/activerecord-wait_for_lsn
|
|
65
|
+
source_code_uri: https://github.com/izhanov/activerecord-wait_for_lsn
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 3.2.0
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 3.6.9
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: Read-your-writes on Rails replicas using PostgreSQL 19 WAIT FOR LSN
|
|
83
|
+
test_files: []
|