pgoutput-client 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -2
- data/LICENSE.txt +6 -6
- data/README.md +346 -19
- data/lib/pgoutput/client/commands.rb +62 -0
- data/lib/pgoutput/client/configuration.rb +187 -0
- data/lib/pgoutput/client/connection.rb +120 -0
- data/lib/pgoutput/client/errors.rb +41 -0
- data/lib/pgoutput/client/feedback.rb +62 -0
- data/lib/pgoutput/client/keepalive.rb +64 -0
- data/lib/pgoutput/client/lsn.rb +54 -0
- data/lib/pgoutput/client/runner.rb +258 -0
- data/lib/pgoutput/client/state.rb +41 -0
- data/lib/pgoutput/client/stream.rb +163 -0
- data/lib/pgoutput/client/version.rb +4 -1
- data/lib/pgoutput/client/xlog_data.rb +71 -0
- data/lib/pgoutput/client.rb +23 -2
- data/lib/pgoutput_client.rb +12 -0
- data/sig/pg.rbs +12 -0
- data/sig/pgoutput/client/commands.rbs +42 -0
- data/sig/pgoutput/client/configuration.rbs +436 -0
- data/sig/pgoutput/client/connection.rbs +91 -0
- data/sig/pgoutput/client/errors.rbs +43 -0
- data/sig/pgoutput/client/feedback.rbs +71 -0
- data/sig/pgoutput/client/keepalive.rbs +55 -0
- data/sig/pgoutput/client/lsn.rbs +36 -0
- data/sig/pgoutput/client/runner.rbs +99 -0
- data/sig/pgoutput/client/state.rbs +29 -0
- data/sig/pgoutput/client/stream.rbs +89 -0
- data/sig/pgoutput/client/version.rbs +8 -0
- data/sig/pgoutput/client/xlog_data.rbs +63 -0
- metadata +50 -11
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
- data/sig/pgoutput/client.rbs +0 -6
|
@@ -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
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# Logical replication stream loop.
|
|
6
|
+
#
|
|
7
|
+
# `Stream` consumes PostgreSQL CopyData payloads from a {Connection}. It
|
|
8
|
+
# understands the two replication envelope message types used by PostgreSQL's
|
|
9
|
+
# streaming replication protocol:
|
|
10
|
+
#
|
|
11
|
+
# * `w` — XLogData, containing logical decoding plugin payload bytes.
|
|
12
|
+
# * `k` — primary keepalive, optionally requesting immediate feedback.
|
|
13
|
+
#
|
|
14
|
+
# The stream yields only XLogData plugin payloads. Keepalive messages are
|
|
15
|
+
# handled internally by updating the latest known WAL position and sending
|
|
16
|
+
# standby feedback when requested.
|
|
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
|
+
#
|
|
23
|
+
# @api private
|
|
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
|
+
|
|
41
|
+
# Build a stream loop.
|
|
42
|
+
#
|
|
43
|
+
# @param connection [Connection] replication connection
|
|
44
|
+
# @param configuration [Configuration] stream configuration
|
|
45
|
+
# @param acked_lsn [String, Integer, nil] initial downstream-acknowledged
|
|
46
|
+
# WAL position
|
|
47
|
+
# @return [void]
|
|
48
|
+
def initialize(connection:, configuration:, acked_lsn: nil)
|
|
49
|
+
@connection = connection
|
|
50
|
+
@configuration = configuration
|
|
51
|
+
@latest_lsn = LSN.parse(configuration.start_lsn_string)
|
|
52
|
+
@acked_lsn = acked_lsn ? normalize_lsn_value(acked_lsn) : @latest_lsn
|
|
53
|
+
@last_feedback_at = monotonic_time
|
|
54
|
+
@last_keepalive_at = nil
|
|
55
|
+
@running = false
|
|
56
|
+
@stop_requested = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start the stream loop.
|
|
60
|
+
#
|
|
61
|
+
# The method blocks while the stream is running. For every XLogData
|
|
62
|
+
# envelope, it yields the raw pgoutput payload and the parsed envelope
|
|
63
|
+
# metadata. When no CopyData payload is currently available, the loop
|
|
64
|
+
# pauses briefly before checking again.
|
|
65
|
+
#
|
|
66
|
+
# @yield [payload, metadata] called for each XLogData payload
|
|
67
|
+
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
68
|
+
# @yieldparam metadata [XLogData] parsed WAL envelope metadata
|
|
69
|
+
# @return [void]
|
|
70
|
+
# @raise [ArgumentError] if no block is provided
|
|
71
|
+
# @raise [ProtocolError] if an unknown or malformed replication message is
|
|
72
|
+
# received
|
|
73
|
+
# @raise [ConnectionError] if standby feedback cannot be sent
|
|
74
|
+
def start(&block)
|
|
75
|
+
raise ArgumentError, "block required" unless block_given?
|
|
76
|
+
|
|
77
|
+
@running = true
|
|
78
|
+
while @running
|
|
79
|
+
copy_data = @connection.get_copy_data
|
|
80
|
+
if copy_data.nil?
|
|
81
|
+
sleep 0.01
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
process_copy_data(copy_data, &block)
|
|
86
|
+
send_periodic_feedback
|
|
87
|
+
end
|
|
88
|
+
ensure
|
|
89
|
+
send_feedback(reply_requested: false) if @stop_requested
|
|
90
|
+
@running = false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stop the stream loop after the current iteration.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def stop
|
|
97
|
+
@stop_requested = true
|
|
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
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def process_copy_data(copy_data)
|
|
126
|
+
case copy_data.getbyte(0)
|
|
127
|
+
when "w".ord
|
|
128
|
+
xlog = XLogData.parse(copy_data)
|
|
129
|
+
@latest_lsn = xlog.wal_end
|
|
130
|
+
yield xlog.payload, xlog
|
|
131
|
+
when "k".ord
|
|
132
|
+
keepalive = Keepalive.parse(copy_data)
|
|
133
|
+
@latest_lsn = [@latest_lsn, keepalive.wal_end].max
|
|
134
|
+
@last_keepalive_at = Time.now.utc
|
|
135
|
+
send_feedback(reply_requested: true) if keepalive.reply_requested
|
|
136
|
+
else
|
|
137
|
+
raise ProtocolError, "unknown CopyData replication message: #{copy_data.getbyte(0).inspect}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def send_periodic_feedback
|
|
142
|
+
return if monotonic_time - @last_feedback_at < @configuration.feedback_interval
|
|
143
|
+
|
|
144
|
+
send_feedback(reply_requested: false)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def send_feedback(reply_requested:)
|
|
148
|
+
feedback = Feedback.now(received_lsn: @latest_lsn, flushed_lsn: @acked_lsn, applied_lsn: @acked_lsn,
|
|
149
|
+
reply_requested:)
|
|
150
|
+
@connection.put_copy_data(feedback.to_copy_data)
|
|
151
|
+
@last_feedback_at = monotonic_time
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_lsn_value(value)
|
|
155
|
+
value.is_a?(String) ? LSN.parse(value) : LSN.parse(LSN.format(value))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def monotonic_time
|
|
159
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
ReplicationXLogData = Data.define(:wal_start, :wal_end, :server_clock, :payload)
|
|
6
|
+
|
|
7
|
+
# Immutable XLogData replication envelope.
|
|
8
|
+
#
|
|
9
|
+
# PostgreSQL wraps logical decoding plugin output in an XLogData CopyData
|
|
10
|
+
# payload while streaming replication is active. The payload layout is:
|
|
11
|
+
#
|
|
12
|
+
# ```text
|
|
13
|
+
# Byte 0 : message tag `w`
|
|
14
|
+
# Bytes 1-8 : WAL start position, unsigned 64-bit big-endian
|
|
15
|
+
# Bytes 9-16 : WAL end position, unsigned 64-bit big-endian
|
|
16
|
+
# Bytes 17-24 : server clock, PostgreSQL timestamp format
|
|
17
|
+
# Bytes 25.. : logical decoding plugin payload
|
|
18
|
+
# ```
|
|
19
|
+
#
|
|
20
|
+
# Instances are created through {.parse} and made shareable so they can cross
|
|
21
|
+
# Ractor boundaries with their frozen payload bytes.
|
|
22
|
+
#
|
|
23
|
+
# @attr_reader wal_start [Integer] WAL position where this message begins
|
|
24
|
+
# @attr_reader wal_end [Integer] WAL position after this message
|
|
25
|
+
# @attr_reader server_clock [Integer] PostgreSQL server timestamp in
|
|
26
|
+
# microseconds since 2000-01-01 UTC
|
|
27
|
+
# @attr_reader payload [String] frozen raw logical decoding plugin payload
|
|
28
|
+
class XLogData < ReplicationXLogData
|
|
29
|
+
# Parse an XLogData CopyData payload.
|
|
30
|
+
#
|
|
31
|
+
# @param bytes [String] raw CopyData payload beginning with `w`
|
|
32
|
+
# @return [XLogData] immutable parsed envelope
|
|
33
|
+
# @raise [ProtocolError] if the payload is empty, has the wrong message
|
|
34
|
+
# tag, or is too short to contain the required fields
|
|
35
|
+
def self.parse(bytes)
|
|
36
|
+
binary = bytes.b
|
|
37
|
+
raise ProtocolError, "empty CopyData payload" if binary.empty?
|
|
38
|
+
raise ProtocolError, "expected XLogData message" unless binary.getbyte(0) == "w".ord
|
|
39
|
+
raise ProtocolError, "truncated XLogData message" if binary.bytesize < 25
|
|
40
|
+
|
|
41
|
+
wal_start = unpack_u64(binary, 1)
|
|
42
|
+
wal_end = unpack_u64(binary, 9)
|
|
43
|
+
server_clock = unpack_u64(binary, 17)
|
|
44
|
+
payload = binary.byteslice(25..)&.freeze || "".b.freeze
|
|
45
|
+
|
|
46
|
+
Ractor.make_shareable(new(wal_start, wal_end, server_clock, payload))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Starting WAL position formatted as a PostgreSQL LSN string.
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def wal_start_lsn = LSN.format(wal_start)
|
|
53
|
+
|
|
54
|
+
# Ending WAL position formatted as a PostgreSQL LSN string.
|
|
55
|
+
#
|
|
56
|
+
# @return [String]
|
|
57
|
+
def wal_end_lsn = LSN.format(wal_end)
|
|
58
|
+
|
|
59
|
+
# @param binary [String]
|
|
60
|
+
# @param offset [Integer]
|
|
61
|
+
# @return [Integer]
|
|
62
|
+
def self.unpack_u64(binary, offset)
|
|
63
|
+
value = binary.byteslice(offset, 8)&.unpack1("Q>")
|
|
64
|
+
raise ProtocolError, "failed to unpack uint64" unless value.is_a?(Integer)
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
private_class_method :unpack_u64
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/pgoutput/client.rb
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "client/version"
|
|
4
|
+
require_relative "client/errors"
|
|
5
|
+
require_relative "client/configuration"
|
|
6
|
+
require_relative "client/lsn"
|
|
7
|
+
require_relative "client/xlog_data"
|
|
8
|
+
require_relative "client/keepalive"
|
|
9
|
+
require_relative "client/feedback"
|
|
10
|
+
require_relative "client/state"
|
|
11
|
+
require_relative "client/commands"
|
|
12
|
+
require_relative "client/connection"
|
|
13
|
+
require_relative "client/stream"
|
|
14
|
+
require_relative "client/runner"
|
|
4
15
|
|
|
5
16
|
module Pgoutput
|
|
17
|
+
# Namespace for PostgreSQL logical replication transport support.
|
|
18
|
+
#
|
|
19
|
+
# `Pgoutput::Client` is the replication transport layer of the CDC Ecosystem.
|
|
20
|
+
# It is responsible for connecting to PostgreSQL in replication mode, creating
|
|
21
|
+
# or consuming replication slots, issuing `START_REPLICATION`, reading CopyData
|
|
22
|
+
# messages, and sending standby feedback.
|
|
23
|
+
#
|
|
24
|
+
# This namespace intentionally does not parse pgoutput plugin payloads into
|
|
25
|
+
# table-level changes. Raw plugin bytes are yielded to downstream protocol and
|
|
26
|
+
# type layers such as `pgoutput-parser` and `pgoutput-decoder`.
|
|
27
|
+
#
|
|
28
|
+
# @api public
|
|
6
29
|
module Client
|
|
7
|
-
class Error < StandardError; end
|
|
8
|
-
# Your code goes here...
|
|
9
30
|
end
|
|
10
31
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Main convenience require for pgoutput-client.
|
|
4
|
+
#
|
|
5
|
+
# Requiring this file loads the public `Pgoutput::Client` namespace and its
|
|
6
|
+
# transport-layer classes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# require "pgoutput_client"
|
|
10
|
+
#
|
|
11
|
+
# @see Pgoutput::Client
|
|
12
|
+
require_relative "pgoutput/client"
|
data/sig/pg.rbs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Pgoutput
|
|
2
|
+
module Client
|
|
3
|
+
# SQL command builders for PostgreSQL replication-mode commands.
|
|
4
|
+
#
|
|
5
|
+
# PostgreSQL replication commands are issued on a connection opened with the
|
|
6
|
+
# replication parameter enabled. The methods in this module render the small
|
|
7
|
+
# command subset needed by `pgoutput-client` and rely on {Configuration} to
|
|
8
|
+
# validate identifier-like values before interpolation.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Commands
|
|
12
|
+
# Render a `CREATE_REPLICATION_SLOT` command.
|
|
13
|
+
#
|
|
14
|
+
# Temporary slots are requested only when
|
|
15
|
+
# {Configuration#temporary_slot} is true.
|
|
16
|
+
#
|
|
17
|
+
# @example Permanent slot
|
|
18
|
+
# Commands.create_replication_slot(config)
|
|
19
|
+
# # => "CREATE_REPLICATION_SLOT cdc_slot LOGICAL pgoutput"
|
|
20
|
+
#
|
|
21
|
+
# @param configuration [Configuration] replication configuration
|
|
22
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
23
|
+
def self?.create_replication_slot: (untyped configuration) -> ::String
|
|
24
|
+
|
|
25
|
+
# Render a `DROP_REPLICATION_SLOT` command.
|
|
26
|
+
#
|
|
27
|
+
# @param configuration [Configuration] replication configuration
|
|
28
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
29
|
+
def self?.drop_replication_slot: (untyped configuration) -> ::String
|
|
30
|
+
|
|
31
|
+
# Render a `START_REPLICATION SLOT ... LOGICAL ...` command.
|
|
32
|
+
#
|
|
33
|
+
# The command includes the pgoutput options required by PostgreSQL:
|
|
34
|
+
# `proto_version` and `publication_names`. Optional pgoutput switches such
|
|
35
|
+
# as `binary` and `messages` are emitted only when enabled.
|
|
36
|
+
#
|
|
37
|
+
# @param configuration [Configuration] replication configuration
|
|
38
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
39
|
+
def self?.start_replication: (untyped configuration) -> ::String
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|