pgoutput-client 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: 3dd08a56a5b98573babde8d5aa72e02c44668877803a5272006879c28bd69ebd
4
- data.tar.gz: c385343185a60a6304ecc276b5f6fac207cb5791fb8035823d863620b238ebda
3
+ metadata.gz: 1413514b4a4dbe4f3c0f4c59744931ed865ca6faea5662f33844c0786a46e287
4
+ data.tar.gz: ebb972e2f0dd5a40f46161d6cd5be70df2f42db42e05862edf16f11e0f9a15ef
5
5
  SHA512:
6
- metadata.gz: 687bd8396cdf3b7a9e019c6cf2c7d584cfa2dcd273dcb5a4c662079d059c03aa2c55b487f9b1d8f58cc82ebfc36421d9d65c774f1fbb6ec6e65ba811418cb82e
7
- data.tar.gz: 8d5e7c7a2b172084071006ca893e85fcff841539919bab78065a5a70d61fed89929dcd469b0837e959a164a7c3c882734bce24405c2660400c1f06b159e56401
6
+ metadata.gz: 7cb26f20d9a5a86c231956cb801c98b269aeebcf969dc2408b8ded3dbcae9a1b7995f88179480cbdd59c6f99b9d5df9d7910db78f0d84175d74fcc7e1ac6a0f5
7
+ data.tar.gz: 93a66bfe718e050fc636c77e78dcc1dd8c8bce064d64794f982944e9dc7a83ed261e3831a88d91ce1a6acb4c2498a5506135d815a8f6d278f5ff1fda168dc868
data/CHANGELOG.md CHANGED
@@ -1,13 +1,19 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## Unreleased
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
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
- ### Added
7
+ ### Fixed
9
8
 
10
- - Placeholder for future development.
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
  [![Gem Version](https://badge.fury.io/rb/pgoutput-client.svg)](https://badge.fury.io/rb/pgoutput-client)
4
4
  [![CI](https://github.com/kanutocd/pgoutput-client/workflows/CI/badge.svg)](https://github.com/kanutocd/pgoutput-client/actions)
5
- [![Coverage Status](https://codecov.io/gh/kanutocd/pgoutput-client/branch/main/graph/badge.svg)](https://codecov.io/gh/kanutocd/pgoutput-client)
6
5
  [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.4-ruby.svg)](https://www.ruby-lang.org/en/)
7
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,6 +335,34 @@ 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
@@ -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 #{configuration.plugin}"
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
- # Default logical decoding output plugin.
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
- # continues and checks again.
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
- next unless copy_data
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, reply_requested:)
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
@@ -5,6 +5,6 @@ module Pgoutput
5
5
  # Current pgoutput-client gem version.
6
6
  #
7
7
  # @return [String]
8
- VERSION = "0.1.0"
8
+ VERSION = "0.2.0"
9
9
  end
10
10
  end
@@ -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
- # Default logical decoding output plugin.
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?, ?plugin: untyped, ?proto_version: untyped, ?binary: bool, ?messages: bool, ?auto_create_slot: bool, ?temporary_slot: bool, ?feedback_interval: untyped) -> void
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
- # continues and checks again.
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: () -> untyped
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.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -42,12 +42,13 @@ files:
42
42
  - lib/pgoutput/client/feedback.rb
43
43
  - lib/pgoutput/client/keepalive.rb
44
44
  - lib/pgoutput/client/lsn.rb
45
+ - lib/pgoutput/client/runner.rb
46
+ - lib/pgoutput/client/state.rb
45
47
  - lib/pgoutput/client/stream.rb
46
48
  - lib/pgoutput/client/version.rb
47
49
  - lib/pgoutput/client/xlog_data.rb
48
50
  - lib/pgoutput_client.rb
49
51
  - sig/pg.rbs
50
- - sig/pgoutput/client.rbs
51
52
  - sig/pgoutput/client/commands.rbs
52
53
  - sig/pgoutput/client/configuration.rbs
53
54
  - sig/pgoutput/client/connection.rbs
@@ -55,6 +56,8 @@ files:
55
56
  - sig/pgoutput/client/feedback.rbs
56
57
  - sig/pgoutput/client/keepalive.rbs
57
58
  - sig/pgoutput/client/lsn.rbs
59
+ - sig/pgoutput/client/runner.rbs
60
+ - sig/pgoutput/client/state.rbs
58
61
  - sig/pgoutput/client/stream.rbs
59
62
  - sig/pgoutput/client/version.rbs
60
63
  - sig/pgoutput/client/xlog_data.rbs
@@ -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