pgoutput-client 0.2.4 → 0.4.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: e4f6889c573693726868891513ab942b96ea305cef6c70407216a0fdf9046a32
4
- data.tar.gz: 6ae8423d421ba51472c3f777451bc970f986c2b2b52c9cf6a234f97491c0ea70
3
+ metadata.gz: a79d1b00595418c8536658073164d8c4d29ad90fe79f5ba58b04e4ee80dd90a1
4
+ data.tar.gz: 436b7bd8d0aedae584d4b5f702635cca25f96d78857fd736c3fde5d41d1be52d
5
5
  SHA512:
6
- metadata.gz: 57d9bdda60c19cfc8c09e403d998af9f26bfcdf9ddb6dd81874f22142c00b8fd1e20cea7120fcbc7484886490870f1c3efe4fcab20a0e0a52283a2d456332977
7
- data.tar.gz: 2840604c443d755c6850154284dfe412d60821adac80428b76fa3569f8db986109ee0457a695938b6e051f224e1b5bb69923cea13934a95184836595bfad6b4f
6
+ metadata.gz: ea61369e9d3ce153ce50b1f24e7335914feb898ca3e52cea20cdb0aa9875ad88a20342b2badc35c81a6fb95a941e8f36ebfdb3e3762c4f3ff421cd9cc4bfed3a
7
+ data.tar.gz: fb24fa3565d7d626282c5d8bdd3b5502b9cd22d168359522b741990a0d1dd10e616e921a65c3dc92beefcbcae5470484872a21a6caba6420b47d3079af62e930
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 - 2026-07-17
6
+
7
+ ### Added
8
+
9
+ - Added `SlotStatus#retained_wal_bytes`, computed by PostgreSQL from the current
10
+ WAL position and the slot's `restart_lsn`.
11
+
12
+ ## 0.3.0 - 2026-07-17
13
+
14
+ ### Added
15
+
16
+ - Added `Runner#slot_status` and `SlotInspector` for version-tolerant
17
+ `pg_replication_slots` inspection.
18
+ - Added immutable `SlotStatus` snapshots exposing transport and slot-health
19
+ fields without imposing downstream recovery policy.
20
+
5
21
  ## 0.2.4 - 2026-06-17
6
22
 
7
23
  ### Added
data/README.md CHANGED
@@ -58,6 +58,7 @@ Decoded row events
58
58
  - Supports `CREATE_REPLICATION_SLOT`
59
59
  - Supports `DROP_REPLICATION_SLOT`
60
60
  - Supports `START_REPLICATION SLOT ... LOGICAL ...`
61
+ - Inspects version-dependent `pg_replication_slots` state
61
62
  - Parses XLogData envelopes
62
63
  - Parses primary keepalive messages
63
64
  - Builds standby feedback messages
@@ -175,6 +176,7 @@ It owns:
175
176
  - Keepalive handling
176
177
  - Standby status feedback
177
178
  - LSN conversion
179
+ - Replication-slot catalog inspection
178
180
 
179
181
  ---
180
182
 
@@ -241,6 +243,46 @@ client = Pgoutput::Client::Runner.new(...)
241
243
  client.start { |payload, metadata| ... }
242
244
  ```
243
245
 
246
+ Inspect the configured replication slot before applying a downstream recovery
247
+ policy:
248
+
249
+ ```ruby
250
+ status = client.slot_status
251
+
252
+ if status.nil?
253
+ warn "replication slot is missing"
254
+ else
255
+ puts status.restart_lsn
256
+ puts status.confirmed_flush_lsn
257
+ puts status.retained_wal_bytes
258
+ puts status.wal_status
259
+ puts status.invalidation_reason
260
+ end
261
+ ```
262
+
263
+ `SlotStatus` reflects the fields available on the connected PostgreSQL version.
264
+ Optional fields are `nil` on versions that do not expose them. The client
265
+ reports catalog state only; deciding whether a durable checkpoint is safe to
266
+ resume remains the downstream runtime's responsibility.
267
+
268
+ ### Pgoutput::Client::SlotInspector
269
+
270
+ Queries `pg_replication_slots` through a short-lived ordinary PostgreSQL
271
+ connection. `#fetch(slot_name)` returns `nil` when the slot is missing and a
272
+ `SlotStatus` snapshot otherwise. `Runner#slot_status` is the convenient entry
273
+ point for inspecting the runner's configured slot.
274
+
275
+ ### Pgoutput::Client::SlotStatus
276
+
277
+ Immutable replication-slot catalog snapshot. It exposes identity, activity,
278
+ WAL position, retention, and invalidation fields including:
279
+
280
+ - `slot_name`, `plugin`, `slot_type`, and `database`
281
+ - `active`, `active_pid`, and `inactive_since`
282
+ - `restart_lsn`, `confirmed_flush_lsn`, and `retained_wal_bytes`
283
+ - `wal_status`, `safe_wal_size`, and `catalog_xmin`
284
+ - `conflicting` and `invalidation_reason`
285
+
244
286
  ### Pgoutput::Client::Configuration
245
287
 
246
288
  Immutable configuration object.
@@ -404,6 +446,11 @@ runner = Pgoutput::Client::Runner.new(
404
446
  )
405
447
  ```
406
448
 
449
+ Existence alone does not prove that an existing or newly created slot can serve
450
+ a downstream durable checkpoint. Use `Runner#slot_status` to obtain catalog
451
+ state and apply continuity or recovery policy in the downstream runtime before
452
+ streaming.
453
+
407
454
  Publication creation remains outside this gem. Create publications through
408
455
  application migrations, database bootstrap SQL, or infrastructure tooling.
409
456
 
@@ -121,7 +121,7 @@ module Pgoutput
121
121
  socket = @pg_connection.socket_io
122
122
  return true unless socket
123
123
 
124
- !!IO.select([socket], nil, nil, 0.1)
124
+ !!socket.wait_readable(0.1)
125
125
  end
126
126
 
127
127
  def exec(sql)
@@ -70,6 +70,7 @@ module Pgoutput
70
70
  # {Configuration#initialize}
71
71
  # @return [void]
72
72
  # @raise [ConfigurationError] if the supplied configuration is invalid
73
+ # rubocop:disable Style/ArgumentsForwarding -- YARD needs a named parameter
73
74
  def initialize(**options)
74
75
  @configuration = Configuration.new(
75
76
  **options # : untyped
@@ -84,6 +85,7 @@ module Pgoutput
84
85
  @last_error = nil
85
86
  @reconnect_attempts = 0
86
87
  end
88
+ # rubocop:enable Style/ArgumentsForwarding
87
89
 
88
90
  # Start streaming raw pgoutput payloads.
89
91
  #
@@ -109,15 +111,13 @@ module Pgoutput
109
111
 
110
112
  loop do
111
113
  current_configuration = configuration_for_resume
112
- case run_stream_cycle(current_configuration, &block)
113
- when :done
114
- break
115
- when :retry
116
- @reconnect_attempts += 1
117
- raise @last_error if @reconnect_attempts > DEFAULT_RECONNECT_ATTEMPTS
118
-
119
- sleep(reconnect_backoff_for(@reconnect_attempts))
120
- end
114
+ result = run_stream_cycle(current_configuration, &block)
115
+ break if result == :done
116
+
117
+ @reconnect_attempts += 1
118
+ raise @last_error if @reconnect_attempts > DEFAULT_RECONNECT_ATTEMPTS
119
+
120
+ sleep(reconnect_backoff_for(@reconnect_attempts))
121
121
  end
122
122
  ensure
123
123
  @running = false
@@ -144,9 +144,9 @@ module Pgoutput
144
144
  #
145
145
  # @yield [payload, metadata] called once for each XLogData payload
146
146
  # @return [void]
147
- def restart(&block)
147
+ def restart(&)
148
148
  stop
149
- start(&block)
149
+ start(&)
150
150
  end
151
151
 
152
152
  # Whether the runner is currently inside its streaming loop.
@@ -201,6 +201,17 @@ module Pgoutput
201
201
  )
202
202
  end
203
203
 
204
+ # Inspect the configured replication slot through PostgreSQL's catalog.
205
+ #
206
+ # This reports transport state without applying downstream checkpoint or
207
+ # recovery policy.
208
+ #
209
+ # @return [SlotStatus, nil] current slot state, or `nil` when missing
210
+ # @raise [ConnectionError] when PostgreSQL cannot be queried
211
+ def slot_status
212
+ SlotInspector.new(database_url: configuration.database_url).fetch(configuration.slot_name)
213
+ end
214
+
204
215
  private
205
216
 
206
217
  def setup_connection(connection)
@@ -222,12 +233,12 @@ module Pgoutput
222
233
  error.message.match?(/replication slot .* already exists/i)
223
234
  end
224
235
 
225
- def run_stream_cycle(configuration, &block)
236
+ def run_stream_cycle(configuration, &)
226
237
  connection = Connection.open(configuration)
227
238
  setup_connection(connection)
228
239
  @connected_once = true
229
240
  @stream = Stream.new(connection:, configuration:, acked_lsn: @acked_lsn)
230
- @stream.start(&block)
241
+ @stream.start(&)
231
242
  :done
232
243
  rescue ConnectionError => e
233
244
  @last_error = e
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgoutput
6
+ module Client
7
+ # Reads PostgreSQL replication-slot catalog state.
8
+ #
9
+ # Inspection uses a short-lived ordinary database connection because
10
+ # catalog queries are separate from the long-lived replication protocol
11
+ # connection. Querying the whole catalog row through `to_jsonb` keeps the
12
+ # result compatible with PostgreSQL versions that expose different optional
13
+ # slot-health columns.
14
+ #
15
+ # @api public
16
+ class SlotInspector
17
+ # Version-tolerant catalog query for one replication slot.
18
+ #
19
+ # @return [String]
20
+ QUERY = <<~SQL
21
+ SELECT
22
+ to_jsonb(slot) || jsonb_build_object(
23
+ 'retained_wal_bytes',
24
+ CASE
25
+ WHEN slot.restart_lsn IS NULL THEN NULL
26
+ ELSE pg_wal_lsn_diff(pg_current_wal_lsn(), slot.restart_lsn)::bigint
27
+ END
28
+ ) AS slot
29
+ FROM pg_catalog.pg_replication_slots AS slot
30
+ WHERE slot.slot_name = $1
31
+ SQL
32
+
33
+ # @return [String] PostgreSQL connection URL
34
+ attr_reader :database_url
35
+
36
+ # @param database_url [String] PostgreSQL connection URL
37
+ # @param connection_factory [#call, nil] optional connection factory for tests
38
+ def initialize(database_url:, connection_factory: nil)
39
+ @database_url = database_url
40
+ @connection_factory = connection_factory
41
+ end
42
+
43
+ # Fetch the configured slot's current catalog state.
44
+ #
45
+ # @param slot_name [String] replication slot name
46
+ # @return [SlotStatus, nil] snapshot, or `nil` when the slot is missing
47
+ # @raise [ConnectionError] when PostgreSQL cannot be queried
48
+ def fetch(slot_name)
49
+ connection = open_connection
50
+ row = connection.exec_params(QUERY, [String(slot_name)]).first
51
+ return nil unless row
52
+
53
+ SlotStatus.from_catalog(parse_catalog(row.fetch("slot")))
54
+ rescue pg_error_class => e
55
+ raise ConnectionError, e.message
56
+ ensure
57
+ connection&.close unless connection&.finished?
58
+ end
59
+
60
+ private
61
+
62
+ def open_connection
63
+ return @connection_factory.call(database_url) if @connection_factory
64
+
65
+ require "pg"
66
+ PG.connect(database_url)
67
+ end
68
+
69
+ def parse_catalog(value)
70
+ return value if value.is_a?(Hash)
71
+
72
+ JSON.parse(value)
73
+ end
74
+
75
+ def pg_error_class
76
+ require "pg"
77
+ PG::Error
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgoutput
4
+ module Client
5
+ # Immutable snapshot of one PostgreSQL logical replication slot.
6
+ #
7
+ # Fields introduced by newer PostgreSQL versions are `nil` when the server
8
+ # does not expose them. The client reports transport/catalog state only;
9
+ # downstream runtimes remain responsible for deciding whether a checkpoint
10
+ # can safely resume from this slot.
11
+ #
12
+ # @api public
13
+ SlotStatusData = Data.define(
14
+ :slot_name,
15
+ :plugin,
16
+ :slot_type,
17
+ :database,
18
+ :active,
19
+ :active_pid,
20
+ :catalog_xmin,
21
+ :restart_lsn,
22
+ :confirmed_flush_lsn,
23
+ :retained_wal_bytes,
24
+ :wal_status,
25
+ :safe_wal_size,
26
+ :inactive_since,
27
+ :conflicting,
28
+ :invalidation_reason
29
+ )
30
+
31
+ # Typed replication-slot catalog snapshot.
32
+ #
33
+ # @api public
34
+ class SlotStatus < SlotStatusData
35
+ # Build a snapshot from a `pg_replication_slots` JSON object.
36
+ #
37
+ # @param attributes [Hash{String, Symbol=>Object}] catalog attributes
38
+ # @return [SlotStatus]
39
+ def self.from_catalog(attributes)
40
+ value = ->(key) { attributes.fetch(key) { attributes[key.to_sym] } }
41
+
42
+ new(
43
+ slot_name: value.call("slot_name"), plugin: value.call("plugin"),
44
+ slot_type: value.call("slot_type"), database: value.call("database"),
45
+ active: value.call("active") == true, active_pid: value.call("active_pid"),
46
+ catalog_xmin: value.call("catalog_xmin"), restart_lsn: value.call("restart_lsn"),
47
+ confirmed_flush_lsn: value.call("confirmed_flush_lsn"),
48
+ retained_wal_bytes: value.call("retained_wal_bytes"),
49
+ wal_status: value.call("wal_status"), safe_wal_size: value.call("safe_wal_size"),
50
+ inactive_since: value.call("inactive_since"), conflicting: value.call("conflicting"),
51
+ invalidation_reason: value.call("invalidation_reason")
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -71,7 +71,7 @@ module Pgoutput
71
71
  # @raise [ProtocolError] if an unknown or malformed replication message is
72
72
  # received
73
73
  # @raise [ConnectionError] if standby feedback cannot be sent
74
- def start(&block)
74
+ def start(&)
75
75
  raise ArgumentError, "block required" unless block_given?
76
76
 
77
77
  @running = true
@@ -83,7 +83,7 @@ module Pgoutput
83
83
  next
84
84
  end
85
85
 
86
- process_copy_data(copy_data, &block)
86
+ process_copy_data(copy_data, &)
87
87
  send_periodic_feedback
88
88
  end
89
89
  ensure
@@ -5,6 +5,6 @@ module Pgoutput
5
5
  # Current pgoutput-client gem version.
6
6
  #
7
7
  # @return [String]
8
- VERSION = "0.2.4"
8
+ VERSION = "0.4.0"
9
9
  end
10
10
  end
@@ -44,7 +44,7 @@ module Pgoutput
44
44
  wal_start = unpack_u64(binary, 1)
45
45
  wal_end = unpack_u64(binary, 9)
46
46
  server_clock = unpack_u64(binary, 17)
47
- payload = binary.byteslice(25..)&.freeze || "".b.freeze
47
+ payload = binary.unpack1("@25a*").freeze
48
48
 
49
49
  Ractor.make_shareable(new(wal_start, wal_end, server_clock, payload))
50
50
  end
@@ -8,6 +8,8 @@ require_relative "client/xlog_data"
8
8
  require_relative "client/keepalive"
9
9
  require_relative "client/feedback"
10
10
  require_relative "client/state"
11
+ require_relative "client/slot_status"
12
+ require_relative "client/slot_inspector"
11
13
  require_relative "client/commands"
12
14
  require_relative "client/connection"
13
15
  require_relative "client/stream"
data/sig/json.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module JSON
2
+ def self.parse: (String source) -> Hash[(String | Symbol), untyped]
3
+ end
@@ -79,6 +79,8 @@ module Pgoutput
79
79
 
80
80
  def monitor: () -> Pgoutput::Client::RunnerState
81
81
 
82
+ def slot_status: () -> (Pgoutput::Client::SlotStatus | nil)
83
+
82
84
  private
83
85
 
84
86
  def setup_connection: (untyped connection) -> untyped
@@ -0,0 +1,18 @@
1
+ module Pgoutput
2
+ module Client
3
+ class SlotInspector
4
+ QUERY: String
5
+
6
+ attr_reader database_url: String
7
+
8
+ def initialize: (database_url: String, ?connection_factory: untyped) -> void
9
+ def fetch: (String slot_name) -> (SlotStatus | nil)
10
+
11
+ private
12
+
13
+ def open_connection: () -> untyped
14
+ def parse_catalog: (untyped value) -> Hash[(String | Symbol), untyped]
15
+ def pg_error_class: () -> singleton(::PG::Error)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ module Pgoutput
2
+ module Client
3
+ class SlotStatusData < Data
4
+ attr_reader slot_name: String
5
+ attr_reader plugin: String?
6
+ attr_reader slot_type: String
7
+ attr_reader database: String?
8
+ attr_reader active: bool
9
+ attr_reader active_pid: Integer?
10
+ attr_reader catalog_xmin: String?
11
+ attr_reader restart_lsn: String?
12
+ attr_reader confirmed_flush_lsn: String?
13
+ attr_reader retained_wal_bytes: Integer?
14
+ attr_reader wal_status: String?
15
+ attr_reader safe_wal_size: Integer?
16
+ attr_reader inactive_since: String?
17
+ attr_reader conflicting: bool?
18
+ attr_reader invalidation_reason: String?
19
+
20
+ def self.new: (
21
+ slot_name: String,
22
+ plugin: String?,
23
+ slot_type: String,
24
+ database: String?,
25
+ active: bool,
26
+ active_pid: Integer?,
27
+ catalog_xmin: String?,
28
+ restart_lsn: String?,
29
+ confirmed_flush_lsn: String?,
30
+ retained_wal_bytes: Integer?,
31
+ wal_status: String?,
32
+ safe_wal_size: Integer?,
33
+ inactive_since: String?,
34
+ conflicting: bool?,
35
+ invalidation_reason: String?
36
+ ) -> instance
37
+ end
38
+
39
+ class SlotStatus < SlotStatusData
40
+ def self.from_catalog: (Hash[(String | Symbol), untyped] attributes) -> SlotStatus
41
+ end
42
+ end
43
+ 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.2.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -53,11 +53,14 @@ files:
53
53
  - lib/pgoutput/client/keepalive.rb
54
54
  - lib/pgoutput/client/lsn.rb
55
55
  - lib/pgoutput/client/runner.rb
56
+ - lib/pgoutput/client/slot_inspector.rb
57
+ - lib/pgoutput/client/slot_status.rb
56
58
  - lib/pgoutput/client/state.rb
57
59
  - lib/pgoutput/client/stream.rb
58
60
  - lib/pgoutput/client/version.rb
59
61
  - lib/pgoutput/client/xlog_data.rb
60
62
  - lib/pgoutput_client.rb
63
+ - sig/json.rbs
61
64
  - sig/pg.rbs
62
65
  - sig/pgoutput/client/commands.rbs
63
66
  - sig/pgoutput/client/configuration.rbs
@@ -67,6 +70,8 @@ files:
67
70
  - sig/pgoutput/client/keepalive.rbs
68
71
  - sig/pgoutput/client/lsn.rbs
69
72
  - sig/pgoutput/client/runner.rbs
73
+ - sig/pgoutput/client/slot_inspector.rbs
74
+ - sig/pgoutput/client/slot_status.rbs
70
75
  - sig/pgoutput/client/state.rbs
71
76
  - sig/pgoutput/client/stream.rbs
72
77
  - sig/pgoutput/client/version.rbs