pgoutput-client 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -2
- data/LICENSE.txt +6 -6
- data/README.md +312 -19
- data/lib/pgoutput/client/commands.rb +62 -0
- data/lib/pgoutput/client/configuration.rb +194 -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/stream.rb +102 -0
- data/lib/pgoutput/client/version.rb +4 -1
- data/lib/pgoutput/client/xlog_data.rb +71 -0
- data/lib/pgoutput/client.rb +117 -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 +504 -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/stream.rbs +68 -0
- data/sig/pgoutput/client/version.rbs +8 -0
- data/sig/pgoutput/client/xlog_data.rbs +63 -0
- data/sig/pgoutput/client.rbs +93 -2
- metadata +46 -10
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# Thin wrapper around `PG::Connection` for logical replication operations.
|
|
6
|
+
#
|
|
7
|
+
# `Connection` hides the small amount of PostgreSQL driver plumbing needed by
|
|
8
|
+
# the rest of the transport layer. It opens the connection in replication
|
|
9
|
+
# mode, renders replication commands through {Commands}, and translates
|
|
10
|
+
# `PG::Error` exceptions into {ConnectionError}.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Connection
|
|
14
|
+
# Configuration associated with this connection.
|
|
15
|
+
#
|
|
16
|
+
# @return [Configuration]
|
|
17
|
+
attr_reader :configuration
|
|
18
|
+
|
|
19
|
+
# Open a PostgreSQL connection in database replication mode.
|
|
20
|
+
#
|
|
21
|
+
# @param configuration [Configuration] replication configuration
|
|
22
|
+
# @return [Connection] wrapper around an open `PG::Connection`
|
|
23
|
+
# @raise [ConnectionError] if the `pg` gem cannot connect
|
|
24
|
+
def self.open(configuration)
|
|
25
|
+
require "pg"
|
|
26
|
+
connection = PG.connect(configuration.database_url, replication: "database")
|
|
27
|
+
new(configuration:, pg_connection: connection)
|
|
28
|
+
rescue PG::Error => e
|
|
29
|
+
raise ConnectionError, e.message
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build a connection wrapper.
|
|
33
|
+
#
|
|
34
|
+
# This constructor is public primarily for tests and alternative connection
|
|
35
|
+
# factories. Normal callers should use {.open}.
|
|
36
|
+
#
|
|
37
|
+
# @param configuration [Configuration] replication configuration
|
|
38
|
+
# @param pg_connection [PG::Connection] connected PostgreSQL driver object
|
|
39
|
+
# @return [void]
|
|
40
|
+
def initialize(configuration:, pg_connection:)
|
|
41
|
+
@configuration = configuration
|
|
42
|
+
@pg_connection = pg_connection
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Execute PostgreSQL's `IDENTIFY_SYSTEM` replication command.
|
|
46
|
+
#
|
|
47
|
+
# @return [PG::Result] server identity result
|
|
48
|
+
# @raise [ConnectionError] if PostgreSQL rejects the command
|
|
49
|
+
def identify_system
|
|
50
|
+
exec("IDENTIFY_SYSTEM")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create the configured logical replication slot.
|
|
54
|
+
#
|
|
55
|
+
# @return [PG::Result] command result
|
|
56
|
+
# @raise [ConnectionError] if PostgreSQL rejects the command
|
|
57
|
+
def create_replication_slot
|
|
58
|
+
exec(Commands.create_replication_slot(configuration))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Drop the configured logical replication slot.
|
|
62
|
+
#
|
|
63
|
+
# @return [PG::Result] command result
|
|
64
|
+
# @raise [ConnectionError] if PostgreSQL rejects the command
|
|
65
|
+
def drop_replication_slot
|
|
66
|
+
exec(Commands.drop_replication_slot(configuration))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Start streaming logical replication from the configured slot and LSN.
|
|
70
|
+
#
|
|
71
|
+
# @return [PG::Result] command result
|
|
72
|
+
# @raise [ConnectionError] if PostgreSQL rejects the command
|
|
73
|
+
def start_replication
|
|
74
|
+
exec(Commands.start_replication(configuration))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Receive one CopyData payload from the server.
|
|
78
|
+
#
|
|
79
|
+
# The call is non-blocking because the underlying `pg` call receives
|
|
80
|
+
# `false` for its blocking argument. `nil` means no complete CopyData
|
|
81
|
+
# payload is currently available.
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil] raw CopyData payload or `nil`
|
|
84
|
+
# @raise [ConnectionError] if receiving fails
|
|
85
|
+
def get_copy_data # rubocop:disable Naming/AccessorMethodName
|
|
86
|
+
@pg_connection.get_copy_data(false)
|
|
87
|
+
rescue PG::Error => e
|
|
88
|
+
raise ConnectionError, e.message
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Send one CopyData payload to the server.
|
|
92
|
+
#
|
|
93
|
+
# Used for standby status feedback messages.
|
|
94
|
+
#
|
|
95
|
+
# @param payload [String] raw CopyData payload
|
|
96
|
+
# @return [void]
|
|
97
|
+
# @raise [ConnectionError] if sending fails
|
|
98
|
+
def put_copy_data(payload)
|
|
99
|
+
@pg_connection.put_copy_data(payload)
|
|
100
|
+
rescue PG::Error => e
|
|
101
|
+
raise ConnectionError, e.message
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Close the PostgreSQL connection if it is still open.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def close
|
|
108
|
+
@pg_connection.close unless @pg_connection.finished?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def exec(sql)
|
|
114
|
+
@pg_connection.exec(sql)
|
|
115
|
+
rescue PG::Error => e
|
|
116
|
+
raise ConnectionError, e.message
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# Base error class for all pgoutput-client failures.
|
|
6
|
+
#
|
|
7
|
+
# Rescue this class when callers want to handle any error raised by the
|
|
8
|
+
# transport layer without also rescuing unrelated Ruby or PostgreSQL driver
|
|
9
|
+
# exceptions.
|
|
10
|
+
#
|
|
11
|
+
# @api public
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised when stream configuration is invalid.
|
|
15
|
+
#
|
|
16
|
+
# Examples include an empty publication list, invalid replication slot name,
|
|
17
|
+
# invalid publication name, non-positive protocol version, or non-positive
|
|
18
|
+
# feedback interval.
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
class ConfigurationError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when a replication protocol envelope cannot be parsed.
|
|
24
|
+
#
|
|
25
|
+
# This error represents malformed or unexpected CopyData payloads at the
|
|
26
|
+
# transport-envelope level. It does not describe pgoutput plugin payload
|
|
27
|
+
# parsing errors; those belong to the parser layer.
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
class ProtocolError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when a PostgreSQL replication connection operation fails.
|
|
33
|
+
#
|
|
34
|
+
# `Connection` converts `PG::Error` instances into this error so public
|
|
35
|
+
# callers do not need to depend on the PostgreSQL driver's exception classes
|
|
36
|
+
# for transport-level handling.
|
|
37
|
+
#
|
|
38
|
+
# @api public
|
|
39
|
+
class ConnectionError < Error; end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
FeedbackData = Data.define(:received_lsn, :flushed_lsn, :applied_lsn, :client_clock, :reply_requested)
|
|
6
|
+
|
|
7
|
+
# Standby status feedback message builder.
|
|
8
|
+
#
|
|
9
|
+
# Logical replication clients periodically send standby status updates to
|
|
10
|
+
# tell PostgreSQL which WAL location has been received, flushed, and applied.
|
|
11
|
+
# `Feedback` models that update and can serialize itself into the CopyData
|
|
12
|
+
# payload expected by the replication protocol.
|
|
13
|
+
#
|
|
14
|
+
# @attr_reader received_lsn [Integer] latest WAL location received by the client
|
|
15
|
+
# @attr_reader flushed_lsn [Integer] latest WAL location flushed by the client
|
|
16
|
+
# @attr_reader applied_lsn [Integer] latest WAL location applied by the client
|
|
17
|
+
# @attr_reader client_clock [Integer] PostgreSQL timestamp in microseconds since 2000-01-01 UTC
|
|
18
|
+
# @attr_reader reply_requested [Boolean] whether this feedback is responding to an immediate-reply request
|
|
19
|
+
class Feedback < FeedbackData
|
|
20
|
+
# Build feedback using the current wall-clock time.
|
|
21
|
+
#
|
|
22
|
+
# By default, flushed and applied LSNs follow the received LSN. Callers can
|
|
23
|
+
# pass lower values if they need to distinguish receipt from durable flush
|
|
24
|
+
# or application progress.
|
|
25
|
+
#
|
|
26
|
+
# @param received_lsn [Integer] latest WAL location received by the client
|
|
27
|
+
# @param flushed_lsn [Integer] latest WAL location flushed by the client
|
|
28
|
+
# @param applied_lsn [Integer] latest WAL location applied by the client
|
|
29
|
+
# @param reply_requested [Boolean] whether this is an immediate reply
|
|
30
|
+
# @return [Feedback] immutable feedback value
|
|
31
|
+
def self.now(received_lsn:, flushed_lsn: received_lsn, applied_lsn: flushed_lsn, reply_requested: false)
|
|
32
|
+
new(received_lsn, flushed_lsn, applied_lsn, current_pg_time, reply_requested)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build a protocol CopyData payload for standby status update.
|
|
36
|
+
#
|
|
37
|
+
# The payload begins with the standby status update tag `r`, followed by
|
|
38
|
+
# three unsigned 64-bit LSN fields, the PostgreSQL timestamp, and a
|
|
39
|
+
# one-byte reply-requested flag.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] frozen binary CopyData payload
|
|
42
|
+
def to_copy_data
|
|
43
|
+
(
|
|
44
|
+
"r".b +
|
|
45
|
+
[received_lsn, flushed_lsn, applied_lsn, client_clock].pack("Q>Q>Q>Q>") +
|
|
46
|
+
[reply_requested ? 1 : 0].pack("C")
|
|
47
|
+
).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Current PostgreSQL protocol timestamp.
|
|
51
|
+
#
|
|
52
|
+
# PostgreSQL timestamps in replication messages are expressed as
|
|
53
|
+
# microseconds since 2000-01-01 00:00:00 UTC, not Unix epoch
|
|
54
|
+
# microseconds.
|
|
55
|
+
#
|
|
56
|
+
# @return [Integer] microseconds since 2000-01-01 UTC
|
|
57
|
+
def self.current_pg_time
|
|
58
|
+
((Time.now.utc - Time.utc(2000, 1, 1)) * 1_000_000).to_i
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
KeepaliveData = Data.define(:wal_end, :server_clock, :reply_requested)
|
|
6
|
+
|
|
7
|
+
# Immutable primary keepalive replication message.
|
|
8
|
+
#
|
|
9
|
+
# PostgreSQL sends keepalive CopyData payloads while a replication stream is
|
|
10
|
+
# active. The payload layout is:
|
|
11
|
+
#
|
|
12
|
+
# ```text
|
|
13
|
+
# Byte 0 : message tag `k`
|
|
14
|
+
# Bytes 1-8 : current server WAL end, unsigned 64-bit big-endian
|
|
15
|
+
# Bytes 9-16 : server clock, PostgreSQL timestamp format
|
|
16
|
+
# Byte 17 : reply-requested flag, 1 for immediate feedback
|
|
17
|
+
# ```
|
|
18
|
+
#
|
|
19
|
+
# The stream layer uses this message to advance its known WAL position and to
|
|
20
|
+
# decide whether to send standby status feedback immediately.
|
|
21
|
+
#
|
|
22
|
+
# @attr_reader wal_end [Integer] latest server WAL position
|
|
23
|
+
# @attr_reader server_clock [Integer] PostgreSQL server timestamp in
|
|
24
|
+
# microseconds since 2000-01-01 UTC
|
|
25
|
+
# @attr_reader reply_requested [Boolean] whether PostgreSQL requested
|
|
26
|
+
# immediate feedback
|
|
27
|
+
class Keepalive < KeepaliveData
|
|
28
|
+
# Parse a keepalive CopyData payload.
|
|
29
|
+
#
|
|
30
|
+
# @param bytes [String] raw CopyData payload beginning with `k`
|
|
31
|
+
# @return [Keepalive] immutable parsed keepalive message
|
|
32
|
+
# @raise [ProtocolError] if the payload is empty, has the wrong message
|
|
33
|
+
# tag, or is too short to contain the required fields
|
|
34
|
+
def self.parse(bytes)
|
|
35
|
+
binary = bytes.b
|
|
36
|
+
raise ProtocolError, "empty CopyData payload" if binary.empty?
|
|
37
|
+
raise ProtocolError, "expected keepalive message" unless binary.getbyte(0) == "k".ord
|
|
38
|
+
raise ProtocolError, "truncated keepalive message" if binary.bytesize < 18
|
|
39
|
+
|
|
40
|
+
wal_end = unpack_u64(binary, 1)
|
|
41
|
+
server_clock = unpack_u64(binary, 9)
|
|
42
|
+
reply_requested = binary.getbyte(17) == 1
|
|
43
|
+
|
|
44
|
+
Ractor.make_shareable(new(wal_end, server_clock, reply_requested))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Latest server WAL position formatted as a PostgreSQL LSN string.
|
|
48
|
+
#
|
|
49
|
+
# @return [String]
|
|
50
|
+
def wal_end_lsn = LSN.format(wal_end)
|
|
51
|
+
|
|
52
|
+
# @param binary [String]
|
|
53
|
+
# @param offset [Integer]
|
|
54
|
+
# @return [Integer]
|
|
55
|
+
def self.unpack_u64(binary, offset)
|
|
56
|
+
value = binary.byteslice(offset, 8)&.unpack1("Q>")
|
|
57
|
+
raise ProtocolError, "failed to unpack uint64" unless value.is_a?(Integer)
|
|
58
|
+
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
private_class_method :unpack_u64
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# PostgreSQL Log Sequence Number conversion helpers.
|
|
6
|
+
#
|
|
7
|
+
# PostgreSQL represents LSNs as two hexadecimal 32-bit halves separated by a
|
|
8
|
+
# slash, such as `0/16B6C50`. The replication protocol transmits the same WAL
|
|
9
|
+
# position as an unsigned 64-bit integer. This module converts between those
|
|
10
|
+
# two representations.
|
|
11
|
+
#
|
|
12
|
+
# @example Parse a textual LSN
|
|
13
|
+
# Pgoutput::Client::LSN.parse("0/10")
|
|
14
|
+
# # => 16
|
|
15
|
+
#
|
|
16
|
+
# @example Format an integer WAL position
|
|
17
|
+
# Pgoutput::Client::LSN.format(16)
|
|
18
|
+
# # => "0/10"
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
module LSN
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Parse a PostgreSQL LSN string into an integer WAL position.
|
|
25
|
+
#
|
|
26
|
+
# @param value [#to_s] LSN string in `HEX/HEX` form
|
|
27
|
+
# @return [Integer] unsigned 64-bit WAL position
|
|
28
|
+
# @raise [ArgumentError] if the value is not a valid LSN string
|
|
29
|
+
def parse(value)
|
|
30
|
+
high, low = String(value).split("/", 2)
|
|
31
|
+
raise ArgumentError, "invalid LSN: #{value.inspect}" if high.nil? || low.nil?
|
|
32
|
+
|
|
33
|
+
(Integer(high, 16) << 32) + Integer(low, 16)
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
raise ArgumentError, "invalid LSN: #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Format an integer WAL position as a PostgreSQL LSN string.
|
|
39
|
+
#
|
|
40
|
+
# @param value [#to_int, #to_s] non-negative integer WAL position
|
|
41
|
+
# @return [String] LSN string in uppercase hexadecimal `HEX/HEX` form
|
|
42
|
+
# @raise [ArgumentError] if the value is negative or cannot be coerced to
|
|
43
|
+
# an integer
|
|
44
|
+
def format(value)
|
|
45
|
+
integer = Integer(value)
|
|
46
|
+
raise ArgumentError, "LSN must be non-negative" if integer.negative?
|
|
47
|
+
|
|
48
|
+
high = integer >> 32
|
|
49
|
+
low = integer & 0xFFFF_FFFF
|
|
50
|
+
"#{high.to_s(16).upcase}/#{low.to_s(16).upcase}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
# @api private
|
|
19
|
+
class Stream
|
|
20
|
+
# Build a stream loop.
|
|
21
|
+
#
|
|
22
|
+
# @param connection [Connection] replication connection
|
|
23
|
+
# @param configuration [Configuration] stream configuration
|
|
24
|
+
# @return [void]
|
|
25
|
+
def initialize(connection:, configuration:)
|
|
26
|
+
@connection = connection
|
|
27
|
+
@configuration = configuration
|
|
28
|
+
@latest_lsn = LSN.parse(configuration.start_lsn_string)
|
|
29
|
+
@last_feedback_at = monotonic_time
|
|
30
|
+
@running = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Start the stream loop.
|
|
34
|
+
#
|
|
35
|
+
# The method blocks while the stream is running. For every XLogData
|
|
36
|
+
# envelope, it yields the raw pgoutput payload and the parsed envelope
|
|
37
|
+
# metadata. When no CopyData payload is currently available, the loop
|
|
38
|
+
# continues and checks again.
|
|
39
|
+
#
|
|
40
|
+
# @yield [payload, metadata] called for each XLogData payload
|
|
41
|
+
# @yieldparam payload [String] frozen raw pgoutput payload bytes
|
|
42
|
+
# @yieldparam metadata [XLogData] parsed WAL envelope metadata
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [ArgumentError] if no block is provided
|
|
45
|
+
# @raise [ProtocolError] if an unknown or malformed replication message is
|
|
46
|
+
# received
|
|
47
|
+
# @raise [ConnectionError] if standby feedback cannot be sent
|
|
48
|
+
def start(&block)
|
|
49
|
+
raise ArgumentError, "block required" unless block_given?
|
|
50
|
+
|
|
51
|
+
@running = true
|
|
52
|
+
while @running
|
|
53
|
+
copy_data = @connection.get_copy_data
|
|
54
|
+
next unless copy_data
|
|
55
|
+
|
|
56
|
+
process_copy_data(copy_data, &block)
|
|
57
|
+
send_periodic_feedback
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stop the stream loop after the current iteration.
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def stop
|
|
65
|
+
@running = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def process_copy_data(copy_data)
|
|
71
|
+
case copy_data.getbyte(0)
|
|
72
|
+
when "w".ord
|
|
73
|
+
xlog = XLogData.parse(copy_data)
|
|
74
|
+
@latest_lsn = xlog.wal_end
|
|
75
|
+
yield xlog.payload, xlog
|
|
76
|
+
when "k".ord
|
|
77
|
+
keepalive = Keepalive.parse(copy_data)
|
|
78
|
+
@latest_lsn = [@latest_lsn, keepalive.wal_end].max
|
|
79
|
+
send_feedback(reply_requested: true) if keepalive.reply_requested
|
|
80
|
+
else
|
|
81
|
+
raise ProtocolError, "unknown CopyData replication message: #{copy_data.getbyte(0).inspect}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def send_periodic_feedback
|
|
86
|
+
return if monotonic_time - @last_feedback_at < @configuration.feedback_interval
|
|
87
|
+
|
|
88
|
+
send_feedback(reply_requested: false)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send_feedback(reply_requested:)
|
|
92
|
+
feedback = Feedback.now(received_lsn: @latest_lsn, reply_requested:)
|
|
93
|
+
@connection.put_copy_data(feedback.to_copy_data)
|
|
94
|
+
@last_feedback_at = monotonic_time
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def monotonic_time
|
|
98
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
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,125 @@
|
|
|
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/commands"
|
|
11
|
+
require_relative "client/connection"
|
|
12
|
+
require_relative "client/stream"
|
|
4
13
|
|
|
5
14
|
module Pgoutput
|
|
15
|
+
# Namespace for PostgreSQL logical replication transport support.
|
|
16
|
+
#
|
|
17
|
+
# `Pgoutput::Client` is the replication transport layer of the CDC Ecosystem.
|
|
18
|
+
# It is responsible for connecting to PostgreSQL in replication mode, creating
|
|
19
|
+
# or consuming replication slots, issuing `START_REPLICATION`, reading CopyData
|
|
20
|
+
# messages, and sending standby feedback.
|
|
21
|
+
#
|
|
22
|
+
# This namespace intentionally does not parse pgoutput plugin payloads into
|
|
23
|
+
# table-level changes. Raw plugin bytes are yielded to downstream protocol and
|
|
24
|
+
# type layers such as `pgoutput-parser` and `pgoutput-decoder`.
|
|
25
|
+
#
|
|
26
|
+
# @api public
|
|
6
27
|
module Client
|
|
7
|
-
|
|
8
|
-
#
|
|
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
|
|
9
124
|
end
|
|
10
125
|
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