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.
@@ -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
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Pgoutput
4
4
  module Client
5
- VERSION = "0.0.0"
5
+ # Current pgoutput-client gem version.
6
+ #
7
+ # @return [String]
8
+ VERSION = "0.1.0"
6
9
  end
7
10
  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
@@ -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
- class Error < StandardError; end
8
- # Your code goes here...
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
@@ -0,0 +1,12 @@
1
+ module PG
2
+ def self.connect: (String conninfo, ?replication: String) -> Connection
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class Connection
8
+ def exec: (String sql) -> untyped
9
+ def close: () -> void
10
+ def finished?: () -> bool
11
+ end
12
+ end