pgoutput-client 0.1.0 → 0.2.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/CHANGELOG.md +11 -5
- data/README.md +37 -3
- data/lib/pgoutput/client/commands.rb +1 -1
- data/lib/pgoutput/client/configuration.rb +1 -8
- data/lib/pgoutput/client/runner.rb +258 -0
- data/lib/pgoutput/client/state.rb +41 -0
- data/lib/pgoutput/client/stream.rb +65 -4
- data/lib/pgoutput/client/version.rb +1 -1
- data/lib/pgoutput/client.rb +2 -96
- data/sig/pgoutput/client/configuration.rbs +2 -70
- data/sig/pgoutput/client/runner.rbs +99 -0
- data/sig/pgoutput/client/state.rbs +29 -0
- data/sig/pgoutput/client/stream.rbs +24 -3
- metadata +19 -5
- data/sig/pgoutput/client.rbs +0 -97
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 697aed85b1c507b7c0e6707bdff020075f31c35c1be6f74a3dff9a060987d69e
|
|
4
|
+
data.tar.gz: ae49847c1b076c79754149cf3b7db0f32ee14f487a1c1ab03531b4a37fc42ab8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 326686eeecf787c33b5ed9daa8d0491fa441a17ac9936666b7e130fba67a1fdaa6b136a86ed903398fd6d2c48e4037199763e03d8390bd5671184e28311eb5f6
|
|
7
|
+
data.tar.gz: 68290690628bef4393ef04867fdfd63337159e85505943062960173a9e5c0eaffb2b00b18eec03372f8d4cba4f02cb2601166eb2d25d98fd0cf6e0ebac387ff9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Unreleased
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
## [0.2.0] - 2026-06-16
|
|
7
6
|
|
|
8
|
-
###
|
|
7
|
+
### Fixed
|
|
9
8
|
|
|
10
|
-
-
|
|
9
|
+
- Remove the configurable plugin surface; this transport layer is fixed to `pgoutput`.
|
|
10
|
+
- Wire `Pgoutput::Client::Runner#stop` to the active stream for cooperative shutdown.
|
|
11
|
+
- Back off briefly when the replication socket has no `CopyData` ready instead of busy polling.
|
|
12
|
+
- Retry live stream connection loss with backoff and resume from the latest confirmed WAL position.
|
|
13
|
+
- Avoid recreating an existing replication slot during reconnect attempts.
|
|
14
|
+
- Document the Docker-backed E2E workflow in the README and test skip hint.
|
|
15
|
+
- Document that replay, checkpointing, deduplication, and sink ordering belong to downstream layers.
|
|
16
|
+
- Add a live E2E reconnect test that restarts PostgreSQL mid-stream and verifies resume behavior.
|
|
11
17
|
|
|
12
18
|
---
|
|
13
19
|
|
data/README.md
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/pgoutput-client)
|
|
4
4
|
[](https://github.com/kanutocd/pgoutput-client/actions)
|
|
5
|
-
[](https://codecov.io/gh/kanutocd/pgoutput-client)
|
|
6
5
|
[](https://www.ruby-lang.org/en/)
|
|
7
6
|
[](https://opensource.org/licenses/MIT)
|
|
8
7
|
|
|
@@ -190,8 +189,15 @@ It does not:
|
|
|
190
189
|
- Run processor pipelines
|
|
191
190
|
- Manage Ractor worker pools
|
|
192
191
|
- Store audit records
|
|
192
|
+
- Own replay, checkpointing, deduplication, or sink ordering
|
|
193
193
|
|
|
194
|
-
Those responsibilities belong to higher layers.
|
|
194
|
+
Those responsibilities belong to higher layers, especially `cdc-core` and the sink that materializes downstream state.
|
|
195
|
+
|
|
196
|
+
## Failure Semantics
|
|
197
|
+
|
|
198
|
+
If the live replication stream loses its connection, `pgoutput-client` retries a small number of times with a backoff and resumes from the latest confirmed WAL position.
|
|
199
|
+
|
|
200
|
+
It does not decide replay policy, deduplication strategy, checkpoint storage, or exactly-once delivery. Those concerns belong to the downstream CDC runtime and sink layer.
|
|
195
201
|
|
|
196
202
|
---
|
|
197
203
|
|
|
@@ -329,8 +335,36 @@ bundle exec rbs:validate
|
|
|
329
335
|
bundle exec rake yard
|
|
330
336
|
```
|
|
331
337
|
|
|
338
|
+
### End-to-End PostgreSQL
|
|
339
|
+
|
|
340
|
+
Run the full Docker-backed E2E flow and clean up afterward:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
script/test-e2e
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Keep PostgreSQL running after the test for debugging:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
KEEP_E2E_POSTGRES=1 script/test-e2e
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
You can also run the steps manually:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
script/e2e-up
|
|
356
|
+
PGOUTPUT_CLIENT_E2E=1 bundle exec rake test:e2e
|
|
357
|
+
script/e2e-down
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Equivalent Rake task:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
bundle exec rake e2e:run
|
|
364
|
+
```
|
|
365
|
+
|
|
332
366
|
---
|
|
333
367
|
|
|
334
368
|
## License
|
|
335
369
|
|
|
336
|
-
MIT.
|
|
370
|
+
[MIT](LICENSE.txt).
|
|
@@ -26,7 +26,7 @@ module Pgoutput
|
|
|
26
26
|
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
27
27
|
def create_replication_slot(configuration)
|
|
28
28
|
temporary = configuration.temporary_slot ? " TEMPORARY" : ""
|
|
29
|
-
"CREATE_REPLICATION_SLOT #{configuration.slot_name}#{temporary} LOGICAL #{
|
|
29
|
+
"CREATE_REPLICATION_SLOT #{configuration.slot_name}#{temporary} LOGICAL #{Configuration::DEFAULT_PLUGIN}"
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Render a `DROP_REPLICATION_SLOT` command.
|
|
@@ -31,7 +31,7 @@ module Pgoutput
|
|
|
31
31
|
#
|
|
32
32
|
# @api public
|
|
33
33
|
class Configuration
|
|
34
|
-
#
|
|
34
|
+
# Fixed logical decoding output plugin.
|
|
35
35
|
#
|
|
36
36
|
# @return [String]
|
|
37
37
|
DEFAULT_PLUGIN = "pgoutput"
|
|
@@ -58,9 +58,6 @@ module Pgoutput
|
|
|
58
58
|
# @!attribute [r] start_lsn
|
|
59
59
|
# Optional normalized starting LSN.
|
|
60
60
|
# @return [String, nil]
|
|
61
|
-
# @!attribute [r] plugin
|
|
62
|
-
# Logical decoding output plugin name.
|
|
63
|
-
# @return [String]
|
|
64
61
|
# @!attribute [r] proto_version
|
|
65
62
|
# pgoutput protocol version.
|
|
66
63
|
# @return [Integer]
|
|
@@ -83,7 +80,6 @@ module Pgoutput
|
|
|
83
80
|
:slot_name,
|
|
84
81
|
:publication_names,
|
|
85
82
|
:start_lsn,
|
|
86
|
-
:plugin,
|
|
87
83
|
:proto_version,
|
|
88
84
|
:binary,
|
|
89
85
|
:messages,
|
|
@@ -107,7 +103,6 @@ module Pgoutput
|
|
|
107
103
|
# names to pass to pgoutput
|
|
108
104
|
# @param start_lsn [String, Integer, nil] starting LSN as a PostgreSQL LSN
|
|
109
105
|
# string, an integer WAL position, or `nil` for `0/0`
|
|
110
|
-
# @param plugin [#to_s] logical decoding plugin name
|
|
111
106
|
# @param proto_version [#to_int, #to_s] pgoutput protocol version
|
|
112
107
|
# @param binary [Object] truthy to request binary column values
|
|
113
108
|
# @param messages [Object] truthy to request logical decoding messages
|
|
@@ -126,7 +121,6 @@ module Pgoutput
|
|
|
126
121
|
slot_name:,
|
|
127
122
|
publication_names:,
|
|
128
123
|
start_lsn: nil,
|
|
129
|
-
plugin: DEFAULT_PLUGIN,
|
|
130
124
|
proto_version: DEFAULT_PROTO_VERSION,
|
|
131
125
|
binary: false,
|
|
132
126
|
messages: false,
|
|
@@ -139,7 +133,6 @@ module Pgoutput
|
|
|
139
133
|
validate_identifier(name, "publication_name").freeze
|
|
140
134
|
end.freeze
|
|
141
135
|
@start_lsn = normalize_lsn(start_lsn).freeze
|
|
142
|
-
@plugin = String(plugin).freeze
|
|
143
136
|
@proto_version = Integer(proto_version)
|
|
144
137
|
@binary = boolean(binary, "binary")
|
|
145
138
|
@messages = boolean(messages, "messages")
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# High-level logical replication client facade.
|
|
6
|
+
#
|
|
7
|
+
# `Runner` is the simplest public entry point for consumers that want to
|
|
8
|
+
# stream raw pgoutput payloads without manually managing a replication
|
|
9
|
+
# connection. It owns the connection lifecycle for one logical replication
|
|
10
|
+
# stream:
|
|
11
|
+
#
|
|
12
|
+
# 1. Build an immutable {Configuration} from keyword arguments.
|
|
13
|
+
# 2. Open a PostgreSQL replication connection.
|
|
14
|
+
# 3. Optionally create the configured replication slot.
|
|
15
|
+
# 4. Start logical replication.
|
|
16
|
+
# 5. Yield raw pgoutput payload bytes and {XLogData} metadata.
|
|
17
|
+
# 6. Close the connection when streaming exits.
|
|
18
|
+
#
|
|
19
|
+
# If a live stream loses its connection, the runner retries a small number
|
|
20
|
+
# of times with a backoff and resumes from the latest confirmed WAL
|
|
21
|
+
# position. Replay, checkpointing, and deduplication are not owned here;
|
|
22
|
+
# those concerns belong to the downstream CDC runtime and sink layer.
|
|
23
|
+
#
|
|
24
|
+
# @example Stream raw pgoutput messages
|
|
25
|
+
# runner = Pgoutput::Client::Runner.new(
|
|
26
|
+
# database_url: "postgres://localhost/app",
|
|
27
|
+
# slot_name: "cdc_slot",
|
|
28
|
+
# publication_names: ["app_publication"]
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# runner.start do |payload, metadata|
|
|
32
|
+
# puts "received #{payload.bytesize} bytes at #{metadata.wal_end_lsn}"
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @see Configuration
|
|
36
|
+
# @see Connection
|
|
37
|
+
# @see Stream
|
|
38
|
+
# @api public
|
|
39
|
+
class Runner
|
|
40
|
+
DEFAULT_RECONNECT_ATTEMPTS = 3
|
|
41
|
+
DEFAULT_RECONNECT_BACKOFF = 0.5
|
|
42
|
+
|
|
43
|
+
# Configuration used by this runner.
|
|
44
|
+
#
|
|
45
|
+
# @return [Configuration]
|
|
46
|
+
attr_reader :configuration
|
|
47
|
+
|
|
48
|
+
# Last transport error seen by the runner.
|
|
49
|
+
#
|
|
50
|
+
# @return [Exception, nil]
|
|
51
|
+
attr_reader :last_error
|
|
52
|
+
|
|
53
|
+
# Build a runner from configuration keyword arguments.
|
|
54
|
+
#
|
|
55
|
+
# The accepted keywords are the same as {Configuration#initialize}. The
|
|
56
|
+
# resulting configuration object is immutable and reused for the lifetime
|
|
57
|
+
# of this runner.
|
|
58
|
+
#
|
|
59
|
+
# @param options [Hash{Symbol=>Object}] configuration options forwarded to
|
|
60
|
+
# {Configuration#initialize}
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [ConfigurationError] if the supplied configuration is invalid
|
|
63
|
+
def initialize(**options)
|
|
64
|
+
@configuration = Configuration.new(
|
|
65
|
+
**options # : untyped
|
|
66
|
+
)
|
|
67
|
+
@stopped = true
|
|
68
|
+
@running = false
|
|
69
|
+
@stream = nil
|
|
70
|
+
@resume_lsn = configuration.start_lsn
|
|
71
|
+
@acked_lsn = configuration.start_lsn
|
|
72
|
+
@slot_created = false
|
|
73
|
+
@last_error = nil
|
|
74
|
+
@reconnect_attempts = 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Start streaming raw pgoutput payloads.
|
|
78
|
+
#
|
|
79
|
+
# This method blocks until the stream stops or the underlying connection
|
|
80
|
+
# raises an error. The yielded payload is the raw logical decoding plugin
|
|
81
|
+
# payload contained inside PostgreSQL's XLogData envelope; callers normally
|
|
82
|
+
# pass this payload to a pgoutput parser.
|
|
83
|
+
#
|
|
84
|
+
# @yield [payload, metadata] called once for each XLogData payload
|
|
85
|
+
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
86
|
+
# @yieldparam metadata [XLogData] WAL envelope metadata for the payload
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [ArgumentError] if no block is provided
|
|
89
|
+
# @raise [ConnectionError] if a PostgreSQL connection or command fails
|
|
90
|
+
# @raise [ProtocolError] if an invalid replication message is received
|
|
91
|
+
def start(&block)
|
|
92
|
+
raise ArgumentError, "block required" unless block
|
|
93
|
+
|
|
94
|
+
@stopped = false
|
|
95
|
+
@running = true
|
|
96
|
+
@last_error = nil
|
|
97
|
+
@reconnect_attempts = 0
|
|
98
|
+
|
|
99
|
+
loop do
|
|
100
|
+
current_configuration = configuration_for_resume
|
|
101
|
+
case run_stream_cycle(current_configuration, &block)
|
|
102
|
+
when :done
|
|
103
|
+
break
|
|
104
|
+
when :retry
|
|
105
|
+
@reconnect_attempts += 1
|
|
106
|
+
raise @last_error if @reconnect_attempts > DEFAULT_RECONNECT_ATTEMPTS
|
|
107
|
+
|
|
108
|
+
sleep(reconnect_backoff_for(@reconnect_attempts))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
ensure
|
|
112
|
+
@running = false
|
|
113
|
+
@stopped = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Request graceful stop.
|
|
117
|
+
#
|
|
118
|
+
# This method records the caller's intent to stop and asks the active
|
|
119
|
+
# {Stream}, if any, to stop after its current iteration.
|
|
120
|
+
#
|
|
121
|
+
# @return [void]
|
|
122
|
+
def stop
|
|
123
|
+
@stopped = true
|
|
124
|
+
@stream&.stop
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Stop the active stream and start again with the same block.
|
|
129
|
+
#
|
|
130
|
+
# The runner is synchronous, so this helper is primarily useful for
|
|
131
|
+
# supervisors that call it instead of manually calling {#stop} followed by
|
|
132
|
+
# {#start}.
|
|
133
|
+
#
|
|
134
|
+
# @yield [payload, metadata] called once for each XLogData payload
|
|
135
|
+
# @return [void]
|
|
136
|
+
def restart(&block)
|
|
137
|
+
stop
|
|
138
|
+
start(&block)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether the runner is currently inside its streaming loop.
|
|
142
|
+
#
|
|
143
|
+
# @return [Boolean]
|
|
144
|
+
def running?
|
|
145
|
+
@running
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether the runner has stopped.
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean]
|
|
151
|
+
def stopped?
|
|
152
|
+
!running?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Whether an active replication stream exists.
|
|
156
|
+
#
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def connected?
|
|
159
|
+
!@stream.nil?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Mark a WAL position as durably handled by downstream code.
|
|
163
|
+
#
|
|
164
|
+
# This does not checkpoint or persist anything. It only updates transport
|
|
165
|
+
# feedback state so future standby status updates can distinguish received
|
|
166
|
+
# WAL from downstream-acknowledged WAL.
|
|
167
|
+
#
|
|
168
|
+
# @param lsn [String, Integer] WAL position acknowledged by downstream code
|
|
169
|
+
# @return [Integer] normalized acknowledged WAL position
|
|
170
|
+
def ack(lsn)
|
|
171
|
+
parsed = normalize_lsn_value(lsn)
|
|
172
|
+
@acked_lsn = [@acked_lsn ? normalize_lsn_value(@acked_lsn) : 0, parsed].max
|
|
173
|
+
@stream&.ack(@acked_lsn)
|
|
174
|
+
@acked_lsn
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Return an immutable transport status snapshot.
|
|
178
|
+
#
|
|
179
|
+
# @return [RunnerState]
|
|
180
|
+
def monitor
|
|
181
|
+
RunnerState.new(
|
|
182
|
+
running: running?,
|
|
183
|
+
stop_requested: @stopped,
|
|
184
|
+
connected: connected?,
|
|
185
|
+
last_received_lsn: current_lsn_string(@stream&.latest_lsn || @resume_lsn),
|
|
186
|
+
last_feedback_lsn: current_lsn_string(@stream&.acked_lsn || @acked_lsn),
|
|
187
|
+
last_keepalive_at: @stream&.last_keepalive_at,
|
|
188
|
+
last_error: @last_error&.message,
|
|
189
|
+
reconnect_attempts: @reconnect_attempts
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def setup_connection(connection)
|
|
196
|
+
if configuration.auto_create_slot && !@slot_created
|
|
197
|
+
connection.create_replication_slot
|
|
198
|
+
@slot_created = true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
connection.start_replication
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def run_stream_cycle(configuration, &block)
|
|
205
|
+
connection = Connection.open(configuration)
|
|
206
|
+
setup_connection(connection)
|
|
207
|
+
@stream = Stream.new(connection:, configuration:, acked_lsn: @acked_lsn)
|
|
208
|
+
@stream.start(&block)
|
|
209
|
+
:done
|
|
210
|
+
rescue ConnectionError => e
|
|
211
|
+
@last_error = e
|
|
212
|
+
raise if @stopped || @stream.nil?
|
|
213
|
+
|
|
214
|
+
@resume_lsn = @stream.latest_lsn || @resume_lsn
|
|
215
|
+
@acked_lsn = @stream.acked_lsn || @acked_lsn
|
|
216
|
+
:retry
|
|
217
|
+
ensure
|
|
218
|
+
@stream = nil
|
|
219
|
+
connection&.close
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def configuration_for_resume
|
|
223
|
+
return configuration if @resume_lsn.nil?
|
|
224
|
+
|
|
225
|
+
Configuration.new(
|
|
226
|
+
database_url: configuration.database_url,
|
|
227
|
+
slot_name: configuration.slot_name,
|
|
228
|
+
publication_names: configuration.publication_names,
|
|
229
|
+
start_lsn: @resume_lsn,
|
|
230
|
+
proto_version: configuration.proto_version,
|
|
231
|
+
binary: configuration.binary,
|
|
232
|
+
messages: configuration.messages,
|
|
233
|
+
auto_create_slot: configuration.auto_create_slot,
|
|
234
|
+
temporary_slot: configuration.temporary_slot,
|
|
235
|
+
feedback_interval: configuration.feedback_interval
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def normalize_lsn_value(value)
|
|
240
|
+
value.is_a?(String) ? LSN.parse(value) : LSN.parse(LSN.format(value))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def current_lsn_string(value)
|
|
244
|
+
return nil if value.nil?
|
|
245
|
+
|
|
246
|
+
value.is_a?(String) ? value : LSN.format(value)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def reconnect_backoff_for(attempts)
|
|
250
|
+
DEFAULT_RECONNECT_BACKOFF * attempts
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def sleep(duration)
|
|
254
|
+
Kernel.sleep(duration)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
RunnerStateData = Data.define(
|
|
6
|
+
:running,
|
|
7
|
+
:stop_requested,
|
|
8
|
+
:connected,
|
|
9
|
+
:last_received_lsn,
|
|
10
|
+
:last_feedback_lsn,
|
|
11
|
+
:last_keepalive_at,
|
|
12
|
+
:last_error,
|
|
13
|
+
:reconnect_attempts
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Immutable operational snapshot for a {Runner}.
|
|
17
|
+
#
|
|
18
|
+
# `RunnerState` is intentionally small and transport-focused. It exposes
|
|
19
|
+
# connection, feedback, keepalive, and retry state without claiming anything
|
|
20
|
+
# about downstream processing, sink delivery, or business-level consumption.
|
|
21
|
+
#
|
|
22
|
+
# @attr_reader running [Boolean] whether the runner is inside its streaming loop
|
|
23
|
+
# @attr_reader stop_requested [Boolean] whether graceful stop was requested
|
|
24
|
+
# @attr_reader connected [Boolean] whether an active replication stream exists
|
|
25
|
+
# @attr_reader last_received_lsn [String, nil] latest WAL position received from PostgreSQL
|
|
26
|
+
# @attr_reader last_feedback_lsn [String, nil] latest downstream-acknowledged WAL position
|
|
27
|
+
# used for flushed/applied feedback
|
|
28
|
+
# @attr_reader last_keepalive_at [Time, nil] last time a primary keepalive was observed
|
|
29
|
+
# @attr_reader last_error [String, nil] last transport error message
|
|
30
|
+
# @attr_reader reconnect_attempts [Integer] reconnect attempts used by the current/last run
|
|
31
|
+
# @api public
|
|
32
|
+
class RunnerState < RunnerStateData
|
|
33
|
+
# Whether the runner currently has no active stream.
|
|
34
|
+
#
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def stopped?
|
|
37
|
+
!running
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -15,19 +15,45 @@ module Pgoutput
|
|
|
15
15
|
# handled internally by updating the latest known WAL position and sending
|
|
16
16
|
# standby feedback when requested.
|
|
17
17
|
#
|
|
18
|
+
# Feedback separates receipt from downstream acknowledgment. Received LSN
|
|
19
|
+
# follows the latest WAL position seen from PostgreSQL. Flushed/applied LSN
|
|
20
|
+
# follows {#ack}, allowing downstream consumers to decide when a WAL position
|
|
21
|
+
# is safe to report as durable.
|
|
22
|
+
#
|
|
18
23
|
# @api private
|
|
19
24
|
class Stream
|
|
25
|
+
# Latest WAL position observed from XLogData or keepalive messages.
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
attr_reader :latest_lsn
|
|
29
|
+
|
|
30
|
+
# Latest downstream-acknowledged WAL position used as flushed/applied LSN
|
|
31
|
+
# in standby feedback.
|
|
32
|
+
#
|
|
33
|
+
# @return [Integer]
|
|
34
|
+
attr_reader :acked_lsn
|
|
35
|
+
|
|
36
|
+
# Last time a primary keepalive was observed.
|
|
37
|
+
#
|
|
38
|
+
# @return [Time, nil]
|
|
39
|
+
attr_reader :last_keepalive_at
|
|
40
|
+
|
|
20
41
|
# Build a stream loop.
|
|
21
42
|
#
|
|
22
43
|
# @param connection [Connection] replication connection
|
|
23
44
|
# @param configuration [Configuration] stream configuration
|
|
45
|
+
# @param acked_lsn [String, Integer, nil] initial downstream-acknowledged
|
|
46
|
+
# WAL position
|
|
24
47
|
# @return [void]
|
|
25
|
-
def initialize(connection:, configuration:)
|
|
48
|
+
def initialize(connection:, configuration:, acked_lsn: nil)
|
|
26
49
|
@connection = connection
|
|
27
50
|
@configuration = configuration
|
|
28
51
|
@latest_lsn = LSN.parse(configuration.start_lsn_string)
|
|
52
|
+
@acked_lsn = acked_lsn ? normalize_lsn_value(acked_lsn) : @latest_lsn
|
|
29
53
|
@last_feedback_at = monotonic_time
|
|
54
|
+
@last_keepalive_at = nil
|
|
30
55
|
@running = false
|
|
56
|
+
@stop_requested = false
|
|
31
57
|
end
|
|
32
58
|
|
|
33
59
|
# Start the stream loop.
|
|
@@ -35,7 +61,7 @@ module Pgoutput
|
|
|
35
61
|
# The method blocks while the stream is running. For every XLogData
|
|
36
62
|
# envelope, it yields the raw pgoutput payload and the parsed envelope
|
|
37
63
|
# metadata. When no CopyData payload is currently available, the loop
|
|
38
|
-
#
|
|
64
|
+
# pauses briefly before checking again.
|
|
39
65
|
#
|
|
40
66
|
# @yield [payload, metadata] called for each XLogData payload
|
|
41
67
|
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
@@ -51,18 +77,47 @@ module Pgoutput
|
|
|
51
77
|
@running = true
|
|
52
78
|
while @running
|
|
53
79
|
copy_data = @connection.get_copy_data
|
|
54
|
-
|
|
80
|
+
if copy_data.nil?
|
|
81
|
+
sleep 0.01
|
|
82
|
+
next
|
|
83
|
+
end
|
|
55
84
|
|
|
56
85
|
process_copy_data(copy_data, &block)
|
|
57
86
|
send_periodic_feedback
|
|
58
87
|
end
|
|
88
|
+
ensure
|
|
89
|
+
send_feedback(reply_requested: false) if @stop_requested
|
|
90
|
+
@running = false
|
|
59
91
|
end
|
|
60
92
|
|
|
61
93
|
# Stop the stream loop after the current iteration.
|
|
62
94
|
#
|
|
63
95
|
# @return [void]
|
|
64
96
|
def stop
|
|
97
|
+
@stop_requested = true
|
|
65
98
|
@running = false
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether the stream loop is active.
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def running?
|
|
106
|
+
@running
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Mark a WAL position as durably handled by downstream code.
|
|
110
|
+
#
|
|
111
|
+
# The stream never decides sink durability on its own. Downstream code may
|
|
112
|
+
# call this after checkpointing, enqueueing, or otherwise making progress
|
|
113
|
+
# durable. Feedback sent after this call reports the acknowledged LSN as
|
|
114
|
+
# both flushed and applied.
|
|
115
|
+
#
|
|
116
|
+
# @param lsn [String, Integer] WAL position acknowledged by downstream code
|
|
117
|
+
# @return [Integer] normalized acknowledged WAL position
|
|
118
|
+
def ack(lsn)
|
|
119
|
+
parsed = normalize_lsn_value(lsn)
|
|
120
|
+
@acked_lsn = [@acked_lsn, parsed].max
|
|
66
121
|
end
|
|
67
122
|
|
|
68
123
|
private
|
|
@@ -76,6 +131,7 @@ module Pgoutput
|
|
|
76
131
|
when "k".ord
|
|
77
132
|
keepalive = Keepalive.parse(copy_data)
|
|
78
133
|
@latest_lsn = [@latest_lsn, keepalive.wal_end].max
|
|
134
|
+
@last_keepalive_at = Time.now.utc
|
|
79
135
|
send_feedback(reply_requested: true) if keepalive.reply_requested
|
|
80
136
|
else
|
|
81
137
|
raise ProtocolError, "unknown CopyData replication message: #{copy_data.getbyte(0).inspect}"
|
|
@@ -89,11 +145,16 @@ module Pgoutput
|
|
|
89
145
|
end
|
|
90
146
|
|
|
91
147
|
def send_feedback(reply_requested:)
|
|
92
|
-
feedback = Feedback.now(received_lsn: @latest_lsn,
|
|
148
|
+
feedback = Feedback.now(received_lsn: @latest_lsn, flushed_lsn: @acked_lsn, applied_lsn: @acked_lsn,
|
|
149
|
+
reply_requested:)
|
|
93
150
|
@connection.put_copy_data(feedback.to_copy_data)
|
|
94
151
|
@last_feedback_at = monotonic_time
|
|
95
152
|
end
|
|
96
153
|
|
|
154
|
+
def normalize_lsn_value(value)
|
|
155
|
+
value.is_a?(String) ? LSN.parse(value) : LSN.parse(LSN.format(value))
|
|
156
|
+
end
|
|
157
|
+
|
|
97
158
|
def monotonic_time
|
|
98
159
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
160
|
end
|
data/lib/pgoutput/client.rb
CHANGED
|
@@ -7,9 +7,11 @@ require_relative "client/lsn"
|
|
|
7
7
|
require_relative "client/xlog_data"
|
|
8
8
|
require_relative "client/keepalive"
|
|
9
9
|
require_relative "client/feedback"
|
|
10
|
+
require_relative "client/state"
|
|
10
11
|
require_relative "client/commands"
|
|
11
12
|
require_relative "client/connection"
|
|
12
13
|
require_relative "client/stream"
|
|
14
|
+
require_relative "client/runner"
|
|
13
15
|
|
|
14
16
|
module Pgoutput
|
|
15
17
|
# Namespace for PostgreSQL logical replication transport support.
|
|
@@ -25,101 +27,5 @@ module Pgoutput
|
|
|
25
27
|
#
|
|
26
28
|
# @api public
|
|
27
29
|
module Client
|
|
28
|
-
# High-level logical replication client facade.
|
|
29
|
-
#
|
|
30
|
-
# `Runner` is the simplest public entry point for consumers that want to
|
|
31
|
-
# stream raw pgoutput payloads without manually managing a replication
|
|
32
|
-
# connection. It owns the connection lifecycle for one logical replication
|
|
33
|
-
# stream:
|
|
34
|
-
#
|
|
35
|
-
# 1. Build an immutable {Configuration} from keyword arguments.
|
|
36
|
-
# 2. Open a PostgreSQL replication connection.
|
|
37
|
-
# 3. Optionally create the configured replication slot.
|
|
38
|
-
# 4. Start logical replication.
|
|
39
|
-
# 5. Yield raw pgoutput payload bytes and {XLogData} metadata.
|
|
40
|
-
# 6. Close the connection when streaming exits.
|
|
41
|
-
#
|
|
42
|
-
# @example Stream raw pgoutput messages
|
|
43
|
-
# runner = Pgoutput::Client::Runner.new(
|
|
44
|
-
# database_url: "postgres://localhost/app",
|
|
45
|
-
# slot_name: "cdc_slot",
|
|
46
|
-
# publication_names: ["app_publication"]
|
|
47
|
-
# )
|
|
48
|
-
#
|
|
49
|
-
# runner.start do |payload, metadata|
|
|
50
|
-
# puts "received #{payload.bytesize} bytes at #{metadata.wal_end_lsn}"
|
|
51
|
-
# end
|
|
52
|
-
#
|
|
53
|
-
# @see Configuration
|
|
54
|
-
# @see Connection
|
|
55
|
-
# @see Stream
|
|
56
|
-
# @api public
|
|
57
|
-
class Runner
|
|
58
|
-
# Configuration used by this runner.
|
|
59
|
-
#
|
|
60
|
-
# @return [Configuration]
|
|
61
|
-
attr_reader :configuration
|
|
62
|
-
|
|
63
|
-
# Build a runner from configuration keyword arguments.
|
|
64
|
-
#
|
|
65
|
-
# The accepted keywords are the same as {Configuration#initialize}. The
|
|
66
|
-
# resulting configuration object is immutable and reused for the lifetime
|
|
67
|
-
# of this runner.
|
|
68
|
-
#
|
|
69
|
-
# @param options [Hash{Symbol=>Object}] configuration options forwarded to
|
|
70
|
-
# {Configuration#initialize}
|
|
71
|
-
# @return [void]
|
|
72
|
-
# @raise [ConfigurationError] if the supplied configuration is invalid
|
|
73
|
-
def initialize(**options)
|
|
74
|
-
@configuration = Configuration.new(
|
|
75
|
-
**options # : untyped
|
|
76
|
-
)
|
|
77
|
-
@stopped = false
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Start streaming raw pgoutput payloads.
|
|
81
|
-
#
|
|
82
|
-
# This method blocks until the stream stops or the underlying connection
|
|
83
|
-
# raises an error. The yielded payload is the raw logical decoding plugin
|
|
84
|
-
# payload contained inside PostgreSQL's XLogData envelope; callers normally
|
|
85
|
-
# pass this payload to a pgoutput parser.
|
|
86
|
-
#
|
|
87
|
-
# @yield [payload, metadata] called once for each XLogData payload
|
|
88
|
-
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
89
|
-
# @yieldparam metadata [XLogData] WAL envelope metadata for the payload
|
|
90
|
-
# @return [void]
|
|
91
|
-
# @raise [ArgumentError] if no block is provided
|
|
92
|
-
# @raise [ConnectionError] if a PostgreSQL connection or command fails
|
|
93
|
-
# @raise [ProtocolError] if an invalid replication message is received
|
|
94
|
-
def start(&block)
|
|
95
|
-
raise ArgumentError, "block required" unless block
|
|
96
|
-
|
|
97
|
-
connection = Connection.open(configuration)
|
|
98
|
-
setup_connection(connection)
|
|
99
|
-
Stream.new(connection:, configuration:).start { |payload, metadata| block.call(payload, metadata) }
|
|
100
|
-
ensure
|
|
101
|
-
connection&.close
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Request graceful stop.
|
|
105
|
-
#
|
|
106
|
-
# This method records the caller's intent to stop. The current
|
|
107
|
-
# implementation does not yet interrupt an active {Stream}; it exists as
|
|
108
|
-
# part of the public lifecycle API and may be wired into cooperative stream
|
|
109
|
-
# shutdown in a future release.
|
|
110
|
-
#
|
|
111
|
-
# @return [void]
|
|
112
|
-
def stop
|
|
113
|
-
@stopped = true
|
|
114
|
-
nil
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
def setup_connection(connection)
|
|
120
|
-
connection.create_replication_slot if configuration.auto_create_slot
|
|
121
|
-
connection.start_replication
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
30
|
end
|
|
125
31
|
end
|
|
@@ -37,8 +37,6 @@ module Pgoutput
|
|
|
37
37
|
|
|
38
38
|
@start_lsn: untyped
|
|
39
39
|
|
|
40
|
-
@plugin: untyped
|
|
41
|
-
|
|
42
40
|
@proto_version: untyped
|
|
43
41
|
|
|
44
42
|
@binary: untyped
|
|
@@ -51,7 +49,7 @@ module Pgoutput
|
|
|
51
49
|
|
|
52
50
|
@feedback_interval: untyped
|
|
53
51
|
|
|
54
|
-
#
|
|
52
|
+
# Fixed logical decoding output plugin.
|
|
55
53
|
#
|
|
56
54
|
# @return [String]
|
|
57
55
|
DEFAULT_PLUGIN: "pgoutput"
|
|
@@ -78,9 +76,6 @@ module Pgoutput
|
|
|
78
76
|
# @!attribute [r] start_lsn
|
|
79
77
|
# Optional normalized starting LSN.
|
|
80
78
|
# @return [String, nil]
|
|
81
|
-
# @!attribute [r] plugin
|
|
82
|
-
# Logical decoding output plugin name.
|
|
83
|
-
# @return [String]
|
|
84
79
|
# @!attribute [r] proto_version
|
|
85
80
|
# pgoutput protocol version.
|
|
86
81
|
# @return [Integer]
|
|
@@ -113,9 +108,6 @@ module Pgoutput
|
|
|
113
108
|
# @!attribute [r] start_lsn
|
|
114
109
|
# Optional normalized starting LSN.
|
|
115
110
|
# @return [String, nil]
|
|
116
|
-
# @!attribute [r] plugin
|
|
117
|
-
# Logical decoding output plugin name.
|
|
118
|
-
# @return [String]
|
|
119
111
|
# @!attribute [r] proto_version
|
|
120
112
|
# pgoutput protocol version.
|
|
121
113
|
# @return [Integer]
|
|
@@ -148,9 +140,6 @@ module Pgoutput
|
|
|
148
140
|
# @!attribute [r] start_lsn
|
|
149
141
|
# Optional normalized starting LSN.
|
|
150
142
|
# @return [String, nil]
|
|
151
|
-
# @!attribute [r] plugin
|
|
152
|
-
# Logical decoding output plugin name.
|
|
153
|
-
# @return [String]
|
|
154
143
|
# @!attribute [r] proto_version
|
|
155
144
|
# pgoutput protocol version.
|
|
156
145
|
# @return [Integer]
|
|
@@ -183,9 +172,6 @@ module Pgoutput
|
|
|
183
172
|
# @!attribute [r] start_lsn
|
|
184
173
|
# Optional normalized starting LSN.
|
|
185
174
|
# @return [String, nil]
|
|
186
|
-
# @!attribute [r] plugin
|
|
187
|
-
# Logical decoding output plugin name.
|
|
188
|
-
# @return [String]
|
|
189
175
|
# @!attribute [r] proto_version
|
|
190
176
|
# pgoutput protocol version.
|
|
191
177
|
# @return [Integer]
|
|
@@ -218,44 +204,6 @@ module Pgoutput
|
|
|
218
204
|
# @!attribute [r] start_lsn
|
|
219
205
|
# Optional normalized starting LSN.
|
|
220
206
|
# @return [String, nil]
|
|
221
|
-
# @!attribute [r] plugin
|
|
222
|
-
# Logical decoding output plugin name.
|
|
223
|
-
# @return [String]
|
|
224
|
-
# @!attribute [r] proto_version
|
|
225
|
-
# pgoutput protocol version.
|
|
226
|
-
# @return [Integer]
|
|
227
|
-
# @!attribute [r] binary
|
|
228
|
-
# Whether to request binary column values from pgoutput.
|
|
229
|
-
# @return [Boolean]
|
|
230
|
-
# @!attribute [r] messages
|
|
231
|
-
# Whether to request logical decoding messages from pgoutput.
|
|
232
|
-
# @return [Boolean]
|
|
233
|
-
# @!attribute [r] auto_create_slot
|
|
234
|
-
# Whether the client should create the slot before streaming.
|
|
235
|
-
# @return [Boolean]
|
|
236
|
-
# @!attribute [r] temporary_slot
|
|
237
|
-
# Whether a newly created slot should be temporary.
|
|
238
|
-
# @return [Boolean]
|
|
239
|
-
# @!attribute [r] feedback_interval
|
|
240
|
-
# Standby feedback interval in seconds.
|
|
241
|
-
# @return [Float]
|
|
242
|
-
attr_reader plugin: untyped
|
|
243
|
-
|
|
244
|
-
# @!attribute [r] database_url
|
|
245
|
-
# PostgreSQL connection URL.
|
|
246
|
-
# @return [String]
|
|
247
|
-
# @!attribute [r] slot_name
|
|
248
|
-
# Logical replication slot name.
|
|
249
|
-
# @return [String]
|
|
250
|
-
# @!attribute [r] publication_names
|
|
251
|
-
# Publication names requested from pgoutput.
|
|
252
|
-
# @return [Array<String>]
|
|
253
|
-
# @!attribute [r] start_lsn
|
|
254
|
-
# Optional normalized starting LSN.
|
|
255
|
-
# @return [String, nil]
|
|
256
|
-
# @!attribute [r] plugin
|
|
257
|
-
# Logical decoding output plugin name.
|
|
258
|
-
# @return [String]
|
|
259
207
|
# @!attribute [r] proto_version
|
|
260
208
|
# pgoutput protocol version.
|
|
261
209
|
# @return [Integer]
|
|
@@ -288,9 +236,6 @@ module Pgoutput
|
|
|
288
236
|
# @!attribute [r] start_lsn
|
|
289
237
|
# Optional normalized starting LSN.
|
|
290
238
|
# @return [String, nil]
|
|
291
|
-
# @!attribute [r] plugin
|
|
292
|
-
# Logical decoding output plugin name.
|
|
293
|
-
# @return [String]
|
|
294
239
|
# @!attribute [r] proto_version
|
|
295
240
|
# pgoutput protocol version.
|
|
296
241
|
# @return [Integer]
|
|
@@ -323,9 +268,6 @@ module Pgoutput
|
|
|
323
268
|
# @!attribute [r] start_lsn
|
|
324
269
|
# Optional normalized starting LSN.
|
|
325
270
|
# @return [String, nil]
|
|
326
|
-
# @!attribute [r] plugin
|
|
327
|
-
# Logical decoding output plugin name.
|
|
328
|
-
# @return [String]
|
|
329
271
|
# @!attribute [r] proto_version
|
|
330
272
|
# pgoutput protocol version.
|
|
331
273
|
# @return [Integer]
|
|
@@ -358,9 +300,6 @@ module Pgoutput
|
|
|
358
300
|
# @!attribute [r] start_lsn
|
|
359
301
|
# Optional normalized starting LSN.
|
|
360
302
|
# @return [String, nil]
|
|
361
|
-
# @!attribute [r] plugin
|
|
362
|
-
# Logical decoding output plugin name.
|
|
363
|
-
# @return [String]
|
|
364
303
|
# @!attribute [r] proto_version
|
|
365
304
|
# pgoutput protocol version.
|
|
366
305
|
# @return [Integer]
|
|
@@ -393,9 +332,6 @@ module Pgoutput
|
|
|
393
332
|
# @!attribute [r] start_lsn
|
|
394
333
|
# Optional normalized starting LSN.
|
|
395
334
|
# @return [String, nil]
|
|
396
|
-
# @!attribute [r] plugin
|
|
397
|
-
# Logical decoding output plugin name.
|
|
398
|
-
# @return [String]
|
|
399
335
|
# @!attribute [r] proto_version
|
|
400
336
|
# pgoutput protocol version.
|
|
401
337
|
# @return [Integer]
|
|
@@ -428,9 +364,6 @@ module Pgoutput
|
|
|
428
364
|
# @!attribute [r] start_lsn
|
|
429
365
|
# Optional normalized starting LSN.
|
|
430
366
|
# @return [String, nil]
|
|
431
|
-
# @!attribute [r] plugin
|
|
432
|
-
# Logical decoding output plugin name.
|
|
433
|
-
# @return [String]
|
|
434
367
|
# @!attribute [r] proto_version
|
|
435
368
|
# pgoutput protocol version.
|
|
436
369
|
# @return [Integer]
|
|
@@ -467,7 +400,6 @@ module Pgoutput
|
|
|
467
400
|
# names to pass to pgoutput
|
|
468
401
|
# @param start_lsn [String, Integer, nil] starting LSN as a PostgreSQL LSN
|
|
469
402
|
# string, an integer WAL position, or `nil` for `0/0`
|
|
470
|
-
# @param plugin [#to_s] logical decoding plugin name
|
|
471
403
|
# @param proto_version [#to_int, #to_s] pgoutput protocol version
|
|
472
404
|
# @param binary [Object] truthy to request binary column values
|
|
473
405
|
# @param messages [Object] truthy to request logical decoding messages
|
|
@@ -482,7 +414,7 @@ module Pgoutput
|
|
|
482
414
|
# settings are invalid
|
|
483
415
|
# @raise [ArgumentError] if `start_lsn`, `proto_version`, or
|
|
484
416
|
# `feedback_interval` cannot be coerced
|
|
485
|
-
def initialize: (database_url: untyped, slot_name: untyped, publication_names: untyped, ?start_lsn: untyped?, ?
|
|
417
|
+
def initialize: (database_url: untyped, slot_name: untyped, publication_names: untyped, ?start_lsn: untyped?, ?proto_version: untyped, ?binary: bool, ?messages: bool, ?auto_create_slot: bool, ?temporary_slot: bool, ?feedback_interval: untyped) -> void
|
|
486
418
|
|
|
487
419
|
# Starting LSN to render in `START_REPLICATION`.
|
|
488
420
|
#
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Pgoutput
|
|
2
|
+
module Client
|
|
3
|
+
# High-level logical replication client facade.
|
|
4
|
+
#
|
|
5
|
+
# `Runner` is the simplest public entry point for consumers that want to
|
|
6
|
+
# stream raw pgoutput payloads without manually managing a replication
|
|
7
|
+
# connection. It owns the connection lifecycle for one logical replication
|
|
8
|
+
# stream:
|
|
9
|
+
#
|
|
10
|
+
# 1. Build an immutable {Configuration} from keyword arguments.
|
|
11
|
+
# 2. Open a PostgreSQL replication connection.
|
|
12
|
+
# 3. Optionally create the configured replication slot.
|
|
13
|
+
# 4. Start logical replication.
|
|
14
|
+
# 5. Yield raw pgoutput payload bytes and {XLogData} metadata.
|
|
15
|
+
# 6. Close the connection when streaming exits.
|
|
16
|
+
#
|
|
17
|
+
# If a live stream loses its connection, the runner retries a small number
|
|
18
|
+
# of times with a backoff and resumes from the latest confirmed WAL
|
|
19
|
+
# position. Replay, checkpointing, and deduplication are not owned here;
|
|
20
|
+
# those concerns belong to the downstream CDC runtime and sink layer.
|
|
21
|
+
#
|
|
22
|
+
# @example Stream raw pgoutput messages
|
|
23
|
+
# runner = Pgoutput::Client::Runner.new(
|
|
24
|
+
# database_url: "postgres://localhost/app",
|
|
25
|
+
# slot_name: "cdc_slot",
|
|
26
|
+
# publication_names: ["app_publication"]
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
# runner.start do |payload, metadata|
|
|
30
|
+
# puts "received #{payload.bytesize} bytes at #{metadata.wal_end_lsn}"
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @see Configuration
|
|
34
|
+
# @see Connection
|
|
35
|
+
# @see Stream
|
|
36
|
+
# @api public
|
|
37
|
+
class Runner
|
|
38
|
+
@configuration: untyped
|
|
39
|
+
|
|
40
|
+
@stopped: untyped
|
|
41
|
+
|
|
42
|
+
@running: untyped
|
|
43
|
+
|
|
44
|
+
@stream: untyped
|
|
45
|
+
|
|
46
|
+
@resume_lsn: untyped
|
|
47
|
+
|
|
48
|
+
@acked_lsn: untyped
|
|
49
|
+
|
|
50
|
+
@slot_created: untyped
|
|
51
|
+
|
|
52
|
+
@last_error: untyped
|
|
53
|
+
|
|
54
|
+
@reconnect_attempts: untyped
|
|
55
|
+
|
|
56
|
+
DEFAULT_RECONNECT_ATTEMPTS: 3
|
|
57
|
+
|
|
58
|
+
DEFAULT_RECONNECT_BACKOFF: ::Float
|
|
59
|
+
|
|
60
|
+
attr_reader configuration: untyped
|
|
61
|
+
|
|
62
|
+
attr_reader last_error: untyped
|
|
63
|
+
|
|
64
|
+
def initialize: (**untyped options) -> void
|
|
65
|
+
|
|
66
|
+
def start: () ?{ (?) -> untyped } -> untyped
|
|
67
|
+
|
|
68
|
+
def stop: () -> nil
|
|
69
|
+
|
|
70
|
+
def restart: () ?{ (?) -> untyped } -> untyped
|
|
71
|
+
|
|
72
|
+
def running?: () -> bool
|
|
73
|
+
|
|
74
|
+
def stopped?: () -> bool
|
|
75
|
+
|
|
76
|
+
def connected?: () -> bool
|
|
77
|
+
|
|
78
|
+
def ack: (untyped lsn) -> untyped
|
|
79
|
+
|
|
80
|
+
def monitor: () -> Pgoutput::Client::RunnerState
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def setup_connection: (untyped connection) -> untyped
|
|
85
|
+
|
|
86
|
+
def run_stream_cycle: (untyped configuration) { (?) -> untyped } -> untyped
|
|
87
|
+
|
|
88
|
+
def configuration_for_resume: () -> untyped
|
|
89
|
+
|
|
90
|
+
def normalize_lsn_value: (untyped value) -> untyped
|
|
91
|
+
|
|
92
|
+
def current_lsn_string: (untyped value) -> untyped
|
|
93
|
+
|
|
94
|
+
def reconnect_backoff_for: (untyped attempts) -> untyped
|
|
95
|
+
|
|
96
|
+
def sleep: (untyped duration) -> untyped
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Pgoutput
|
|
2
|
+
module Client
|
|
3
|
+
class RunnerStateData < Data
|
|
4
|
+
attr_reader running: bool
|
|
5
|
+
attr_reader stop_requested: bool
|
|
6
|
+
attr_reader connected: bool
|
|
7
|
+
attr_reader last_received_lsn: String?
|
|
8
|
+
attr_reader last_feedback_lsn: String?
|
|
9
|
+
attr_reader last_keepalive_at: Time?
|
|
10
|
+
attr_reader last_error: String?
|
|
11
|
+
attr_reader reconnect_attempts: Integer
|
|
12
|
+
|
|
13
|
+
def self.new: (
|
|
14
|
+
running: bool,
|
|
15
|
+
stop_requested: bool,
|
|
16
|
+
connected: bool,
|
|
17
|
+
last_received_lsn: String?,
|
|
18
|
+
last_feedback_lsn: String?,
|
|
19
|
+
last_keepalive_at: Time?,
|
|
20
|
+
last_error: String?,
|
|
21
|
+
reconnect_attempts: Integer
|
|
22
|
+
) -> instance
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class RunnerState < RunnerStateData
|
|
26
|
+
def stopped?: () -> bool
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -21,23 +21,38 @@ module Pgoutput
|
|
|
21
21
|
|
|
22
22
|
@latest_lsn: untyped
|
|
23
23
|
|
|
24
|
+
@acked_lsn: untyped
|
|
25
|
+
|
|
24
26
|
@last_feedback_at: untyped
|
|
25
27
|
|
|
28
|
+
@last_keepalive_at: untyped
|
|
29
|
+
|
|
26
30
|
@running: untyped
|
|
27
31
|
|
|
32
|
+
@stop_requested: untyped
|
|
33
|
+
|
|
34
|
+
# Latest confirmed WAL position observed by this stream.
|
|
35
|
+
#
|
|
36
|
+
# @return [Integer]
|
|
37
|
+
attr_reader latest_lsn: untyped
|
|
38
|
+
|
|
39
|
+
attr_reader acked_lsn: untyped
|
|
40
|
+
|
|
41
|
+
attr_reader last_keepalive_at: untyped
|
|
42
|
+
|
|
28
43
|
# Build a stream loop.
|
|
29
44
|
#
|
|
30
45
|
# @param connection [Connection] replication connection
|
|
31
46
|
# @param configuration [Configuration] stream configuration
|
|
32
47
|
# @return [void]
|
|
33
|
-
def initialize: (connection: untyped, configuration: untyped) -> void
|
|
48
|
+
def initialize: (connection: untyped, configuration: untyped, ?acked_lsn: untyped) -> void
|
|
34
49
|
|
|
35
50
|
# Start the stream loop.
|
|
36
51
|
#
|
|
37
52
|
# The method blocks while the stream is running. For every XLogData
|
|
38
53
|
# envelope, it yields the raw pgoutput payload and the parsed envelope
|
|
39
54
|
# metadata. When no CopyData payload is currently available, the loop
|
|
40
|
-
#
|
|
55
|
+
# pauses briefly before checking again.
|
|
41
56
|
#
|
|
42
57
|
# @yield [payload, metadata] called for each XLogData payload
|
|
43
58
|
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
@@ -52,7 +67,11 @@ module Pgoutput
|
|
|
52
67
|
# Stop the stream loop after the current iteration.
|
|
53
68
|
#
|
|
54
69
|
# @return [void]
|
|
55
|
-
def stop: () ->
|
|
70
|
+
def stop: () -> nil
|
|
71
|
+
|
|
72
|
+
def running?: () -> bool
|
|
73
|
+
|
|
74
|
+
def ack: (untyped lsn) -> untyped
|
|
56
75
|
|
|
57
76
|
private
|
|
58
77
|
|
|
@@ -62,6 +81,8 @@ module Pgoutput
|
|
|
62
81
|
|
|
63
82
|
def send_feedback: (reply_requested: untyped) -> untyped
|
|
64
83
|
|
|
84
|
+
def normalize_lsn_value: (untyped value) -> untyped
|
|
85
|
+
|
|
65
86
|
def monotonic_time: () -> untyped
|
|
66
87
|
end
|
|
67
88
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgoutput-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -23,8 +23,18 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.6'
|
|
26
|
-
description:
|
|
27
|
-
|
|
26
|
+
description: |
|
|
27
|
+
pgoutput-client provides a PostgreSQL logical replication transport for Ruby.
|
|
28
|
+
|
|
29
|
+
It manages replication connections, lifecycle, monitoring,
|
|
30
|
+
keepalive handling, graceful shutdown, and transport-level
|
|
31
|
+
acknowledgment boundaries while streaming pgoutput messages
|
|
32
|
+
to downstream consumers.
|
|
33
|
+
|
|
34
|
+
pgoutput-client owns transport.
|
|
35
|
+
|
|
36
|
+
Parsers, decoders, source adapters, runtimes, and sinks remain
|
|
37
|
+
separate concerns within the CDC ecosystem.
|
|
28
38
|
email:
|
|
29
39
|
- kenneth.c.demanawa@gmail.com
|
|
30
40
|
executables: []
|
|
@@ -42,12 +52,13 @@ files:
|
|
|
42
52
|
- lib/pgoutput/client/feedback.rb
|
|
43
53
|
- lib/pgoutput/client/keepalive.rb
|
|
44
54
|
- lib/pgoutput/client/lsn.rb
|
|
55
|
+
- lib/pgoutput/client/runner.rb
|
|
56
|
+
- lib/pgoutput/client/state.rb
|
|
45
57
|
- lib/pgoutput/client/stream.rb
|
|
46
58
|
- lib/pgoutput/client/version.rb
|
|
47
59
|
- lib/pgoutput/client/xlog_data.rb
|
|
48
60
|
- lib/pgoutput_client.rb
|
|
49
61
|
- sig/pg.rbs
|
|
50
|
-
- sig/pgoutput/client.rbs
|
|
51
62
|
- sig/pgoutput/client/commands.rbs
|
|
52
63
|
- sig/pgoutput/client/configuration.rbs
|
|
53
64
|
- sig/pgoutput/client/connection.rbs
|
|
@@ -55,6 +66,8 @@ files:
|
|
|
55
66
|
- sig/pgoutput/client/feedback.rbs
|
|
56
67
|
- sig/pgoutput/client/keepalive.rbs
|
|
57
68
|
- sig/pgoutput/client/lsn.rbs
|
|
69
|
+
- sig/pgoutput/client/runner.rbs
|
|
70
|
+
- sig/pgoutput/client/state.rbs
|
|
58
71
|
- sig/pgoutput/client/stream.rbs
|
|
59
72
|
- sig/pgoutput/client/version.rbs
|
|
60
73
|
- sig/pgoutput/client/xlog_data.rbs
|
|
@@ -83,5 +96,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
83
96
|
requirements: []
|
|
84
97
|
rubygems_version: 3.6.9
|
|
85
98
|
specification_version: 4
|
|
86
|
-
summary: PostgreSQL pgoutput logical replication transport
|
|
99
|
+
summary: PostgreSQL pgoutput logical replication transport and lifecycle management
|
|
100
|
+
for Ruby.
|
|
87
101
|
test_files: []
|
data/sig/pgoutput/client.rbs
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
module Pgoutput
|
|
2
|
-
# Namespace for PostgreSQL logical replication transport support.
|
|
3
|
-
#
|
|
4
|
-
# `Pgoutput::Client` is the replication transport layer of the CDC Ecosystem.
|
|
5
|
-
# It is responsible for connecting to PostgreSQL in replication mode, creating
|
|
6
|
-
# or consuming replication slots, issuing `START_REPLICATION`, reading CopyData
|
|
7
|
-
# messages, and sending standby feedback.
|
|
8
|
-
#
|
|
9
|
-
# This namespace intentionally does not parse pgoutput plugin payloads into
|
|
10
|
-
# table-level changes. Raw plugin bytes are yielded to downstream protocol and
|
|
11
|
-
# type layers such as `pgoutput-parser` and `pgoutput-decoder`.
|
|
12
|
-
#
|
|
13
|
-
# @api public
|
|
14
|
-
module Client
|
|
15
|
-
# High-level logical replication client facade.
|
|
16
|
-
#
|
|
17
|
-
# `Runner` is the simplest public entry point for consumers that want to
|
|
18
|
-
# stream raw pgoutput payloads without manually managing a replication
|
|
19
|
-
# connection. It owns the connection lifecycle for one logical replication
|
|
20
|
-
# stream:
|
|
21
|
-
#
|
|
22
|
-
# 1. Build an immutable {Configuration} from keyword arguments.
|
|
23
|
-
# 2. Open a PostgreSQL replication connection.
|
|
24
|
-
# 3. Optionally create the configured replication slot.
|
|
25
|
-
# 4. Start logical replication.
|
|
26
|
-
# 5. Yield raw pgoutput payload bytes and {XLogData} metadata.
|
|
27
|
-
# 6. Close the connection when streaming exits.
|
|
28
|
-
#
|
|
29
|
-
# @example Stream raw pgoutput messages
|
|
30
|
-
# runner = Pgoutput::Client::Runner.new(
|
|
31
|
-
# database_url: "postgres://localhost/app",
|
|
32
|
-
# slot_name: "cdc_slot",
|
|
33
|
-
# publication_names: ["app_publication"]
|
|
34
|
-
# )
|
|
35
|
-
#
|
|
36
|
-
# runner.start do |payload, metadata|
|
|
37
|
-
# puts "received #{payload.bytesize} bytes at #{metadata.wal_end_lsn}"
|
|
38
|
-
# end
|
|
39
|
-
#
|
|
40
|
-
# @see Configuration
|
|
41
|
-
# @see Connection
|
|
42
|
-
# @see Stream
|
|
43
|
-
# @api public
|
|
44
|
-
class Runner
|
|
45
|
-
@configuration: untyped
|
|
46
|
-
|
|
47
|
-
@stopped: untyped
|
|
48
|
-
|
|
49
|
-
# Configuration used by this runner.
|
|
50
|
-
#
|
|
51
|
-
# @return [Configuration]
|
|
52
|
-
attr_reader configuration: untyped
|
|
53
|
-
|
|
54
|
-
# Build a runner from configuration keyword arguments.
|
|
55
|
-
#
|
|
56
|
-
# The accepted keywords are the same as {Configuration#initialize}. The
|
|
57
|
-
# resulting configuration object is immutable and reused for the lifetime
|
|
58
|
-
# of this runner.
|
|
59
|
-
#
|
|
60
|
-
# @param options [Hash{Symbol=>Object}] configuration options forwarded to
|
|
61
|
-
# {Configuration#initialize}
|
|
62
|
-
# @return [void]
|
|
63
|
-
# @raise [ConfigurationError] if the supplied configuration is invalid
|
|
64
|
-
def initialize: (**untyped options) -> void
|
|
65
|
-
|
|
66
|
-
# Start streaming raw pgoutput payloads.
|
|
67
|
-
#
|
|
68
|
-
# This method blocks until the stream stops or the underlying connection
|
|
69
|
-
# raises an error. The yielded payload is the raw logical decoding plugin
|
|
70
|
-
# payload contained inside PostgreSQL's XLogData envelope; callers normally
|
|
71
|
-
# pass this payload to a pgoutput parser.
|
|
72
|
-
#
|
|
73
|
-
# @yield [payload, metadata] called once for each XLogData payload
|
|
74
|
-
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
75
|
-
# @yieldparam metadata [XLogData] WAL envelope metadata for the payload
|
|
76
|
-
# @return [void]
|
|
77
|
-
# @raise [ArgumentError] if no block is provided
|
|
78
|
-
# @raise [ConnectionError] if a PostgreSQL connection or command fails
|
|
79
|
-
# @raise [ProtocolError] if an invalid replication message is received
|
|
80
|
-
def start: () ?{ (?) -> untyped } -> untyped
|
|
81
|
-
|
|
82
|
-
# Request graceful stop.
|
|
83
|
-
#
|
|
84
|
-
# This method records the caller's intent to stop. The current
|
|
85
|
-
# implementation does not yet interrupt an active {Stream}; it exists as
|
|
86
|
-
# part of the public lifecycle API and may be wired into cooperative stream
|
|
87
|
-
# shutdown in a future release.
|
|
88
|
-
#
|
|
89
|
-
# @return [void]
|
|
90
|
-
def stop: () -> nil
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
def setup_connection: (untyped connection) -> untyped
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|