pgoutput-client 0.2.4 → 0.3.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: 9a0c63a95d0df00f5a4b8b5a0e85b01bbec1ed71688a41f853c065e2f2b01a03
4
+ data.tar.gz: 3e86b0d6e62c54c03edd73a86be9dce33997a60f2d5bd097459b3a2a0d75795a
5
5
  SHA512:
6
- metadata.gz: 57d9bdda60c19cfc8c09e403d998af9f26bfcdf9ddb6dd81874f22142c00b8fd1e20cea7120fcbc7484886490870f1c3efe4fcab20a0e0a52283a2d456332977
7
- data.tar.gz: 2840604c443d755c6850154284dfe412d60821adac80428b76fa3569f8db986109ee0457a695938b6e051f224e1b5bb69923cea13934a95184836595bfad6b4f
6
+ metadata.gz: 0577c07284f05bb0daa59ae055a39cdd456e2104f936c33b8a921473fb5554cf65dfae2f9d9d69ce82900e27dfb1a0f632c960654bae7526b73b62ab664f6007
7
+ data.tar.gz: e2ea5d853aee2079d280139f58f287063fc411e8ef846782c14b8f41c286bd1975027022ecfa395619f5494ce5622d640fc0a9b6bd1a0ca9a192ebbab5701d20
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 - 2026-07-17
6
+
7
+ ### Added
8
+
9
+ - Added `Runner#slot_status` and `SlotInspector` for version-tolerant
10
+ `pg_replication_slots` inspection.
11
+ - Added immutable `SlotStatus` snapshots exposing transport and slot-health
12
+ fields without imposing downstream recovery policy.
13
+
5
14
  ## 0.2.4 - 2026-06-17
6
15
 
7
16
  ### 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,45 @@ 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.wal_status
258
+ puts status.invalidation_reason
259
+ end
260
+ ```
261
+
262
+ `SlotStatus` reflects the fields available on the connected PostgreSQL version.
263
+ Optional fields are `nil` on versions that do not expose them. The client
264
+ reports catalog state only; deciding whether a durable checkpoint is safe to
265
+ resume remains the downstream runtime's responsibility.
266
+
267
+ ### Pgoutput::Client::SlotInspector
268
+
269
+ Queries `pg_replication_slots` through a short-lived ordinary PostgreSQL
270
+ connection. `#fetch(slot_name)` returns `nil` when the slot is missing and a
271
+ `SlotStatus` snapshot otherwise. `Runner#slot_status` is the convenient entry
272
+ point for inspecting the runner's configured slot.
273
+
274
+ ### Pgoutput::Client::SlotStatus
275
+
276
+ Immutable replication-slot catalog snapshot. It exposes identity, activity,
277
+ WAL position, retention, and invalidation fields including:
278
+
279
+ - `slot_name`, `plugin`, `slot_type`, and `database`
280
+ - `active`, `active_pid`, and `inactive_since`
281
+ - `restart_lsn` and `confirmed_flush_lsn`
282
+ - `wal_status`, `safe_wal_size`, and `catalog_xmin`
283
+ - `conflicting` and `invalidation_reason`
284
+
244
285
  ### Pgoutput::Client::Configuration
245
286
 
246
287
  Immutable configuration object.
@@ -404,6 +445,11 @@ runner = Pgoutput::Client::Runner.new(
404
445
  )
405
446
  ```
406
447
 
448
+ Existence alone does not prove that an existing or newly created slot can serve
449
+ a downstream durable checkpoint. Use `Runner#slot_status` to obtain catalog
450
+ state and apply continuity or recovery policy in the downstream runtime before
451
+ streaming.
452
+
407
453
  Publication creation remains outside this gem. Create publications through
408
454
  application migrations, database bootstrap SQL, or infrastructure tooling.
409
455
 
@@ -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
  #
@@ -144,9 +146,9 @@ module Pgoutput
144
146
  #
145
147
  # @yield [payload, metadata] called once for each XLogData payload
146
148
  # @return [void]
147
- def restart(&block)
149
+ def restart(&)
148
150
  stop
149
- start(&block)
151
+ start(&)
150
152
  end
151
153
 
152
154
  # Whether the runner is currently inside its streaming loop.
@@ -201,6 +203,17 @@ module Pgoutput
201
203
  )
202
204
  end
203
205
 
206
+ # Inspect the configured replication slot through PostgreSQL's catalog.
207
+ #
208
+ # This reports transport state without applying downstream checkpoint or
209
+ # recovery policy.
210
+ #
211
+ # @return [SlotStatus, nil] current slot state, or `nil` when missing
212
+ # @raise [ConnectionError] when PostgreSQL cannot be queried
213
+ def slot_status
214
+ SlotInspector.new(database_url: configuration.database_url).fetch(configuration.slot_name)
215
+ end
216
+
204
217
  private
205
218
 
206
219
  def setup_connection(connection)
@@ -222,12 +235,12 @@ module Pgoutput
222
235
  error.message.match?(/replication slot .* already exists/i)
223
236
  end
224
237
 
225
- def run_stream_cycle(configuration, &block)
238
+ def run_stream_cycle(configuration, &)
226
239
  connection = Connection.open(configuration)
227
240
  setup_connection(connection)
228
241
  @connected_once = true
229
242
  @stream = Stream.new(connection:, configuration:, acked_lsn: @acked_lsn)
230
- @stream.start(&block)
243
+ @stream.start(&)
231
244
  :done
232
245
  rescue ConnectionError => e
233
246
  @last_error = e
@@ -0,0 +1,74 @@
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 to_jsonb(slot) AS slot
22
+ FROM pg_catalog.pg_replication_slots AS slot
23
+ WHERE slot.slot_name = $1
24
+ SQL
25
+
26
+ # @return [String] PostgreSQL connection URL
27
+ attr_reader :database_url
28
+
29
+ # @param database_url [String] PostgreSQL connection URL
30
+ # @param connection_factory [#call, nil] optional connection factory for tests
31
+ def initialize(database_url:, connection_factory: nil)
32
+ @database_url = database_url
33
+ @connection_factory = connection_factory
34
+ end
35
+
36
+ # Fetch the configured slot's current catalog state.
37
+ #
38
+ # @param slot_name [String] replication slot name
39
+ # @return [SlotStatus, nil] snapshot, or `nil` when the slot is missing
40
+ # @raise [ConnectionError] when PostgreSQL cannot be queried
41
+ def fetch(slot_name)
42
+ connection = open_connection
43
+ row = connection.exec_params(QUERY, [String(slot_name)]).first
44
+ return nil unless row
45
+
46
+ SlotStatus.from_catalog(parse_catalog(row.fetch("slot")))
47
+ rescue pg_error_class => e
48
+ raise ConnectionError, e.message
49
+ ensure
50
+ connection&.close unless connection&.finished?
51
+ end
52
+
53
+ private
54
+
55
+ def open_connection
56
+ return @connection_factory.call(database_url) if @connection_factory
57
+
58
+ require "pg"
59
+ PG.connect(database_url)
60
+ end
61
+
62
+ def parse_catalog(value)
63
+ return value if value.is_a?(Hash)
64
+
65
+ JSON.parse(value)
66
+ end
67
+
68
+ def pg_error_class
69
+ require "pg"
70
+ PG::Error
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,64 @@
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
+ :wal_status,
24
+ :safe_wal_size,
25
+ :inactive_since,
26
+ :conflicting,
27
+ :invalidation_reason
28
+ )
29
+
30
+ # Typed replication-slot catalog snapshot.
31
+ #
32
+ # @api public
33
+ class SlotStatus < SlotStatusData
34
+ # Build a snapshot from a `pg_replication_slots` JSON object.
35
+ #
36
+ # @param attributes [Hash{String, Symbol=>Object}] catalog attributes
37
+ # @return [SlotStatus]
38
+ def self.from_catalog(attributes)
39
+ fetch = lambda do |key|
40
+ next attributes[key] if attributes.key?(key)
41
+
42
+ attributes[key.to_sym]
43
+ end
44
+
45
+ new(
46
+ slot_name: fetch.call("slot_name"),
47
+ plugin: fetch.call("plugin"),
48
+ slot_type: fetch.call("slot_type"),
49
+ database: fetch.call("database"),
50
+ active: fetch.call("active") == true,
51
+ active_pid: fetch.call("active_pid"),
52
+ catalog_xmin: fetch.call("catalog_xmin"),
53
+ restart_lsn: fetch.call("restart_lsn"),
54
+ confirmed_flush_lsn: fetch.call("confirmed_flush_lsn"),
55
+ wal_status: fetch.call("wal_status"),
56
+ safe_wal_size: fetch.call("safe_wal_size"),
57
+ inactive_since: fetch.call("inactive_since"),
58
+ conflicting: fetch.call("conflicting"),
59
+ invalidation_reason: fetch.call("invalidation_reason")
60
+ )
61
+ end
62
+ end
63
+ end
64
+ 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.3.0"
9
9
  end
10
10
  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,41 @@
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 wal_status: String?
14
+ attr_reader safe_wal_size: Integer?
15
+ attr_reader inactive_since: String?
16
+ attr_reader conflicting: bool?
17
+ attr_reader invalidation_reason: String?
18
+
19
+ def self.new: (
20
+ slot_name: String,
21
+ plugin: String?,
22
+ slot_type: String,
23
+ database: String?,
24
+ active: bool,
25
+ active_pid: Integer?,
26
+ catalog_xmin: String?,
27
+ restart_lsn: String?,
28
+ confirmed_flush_lsn: String?,
29
+ wal_status: String?,
30
+ safe_wal_size: Integer?,
31
+ inactive_since: String?,
32
+ conflicting: bool?,
33
+ invalidation_reason: String?
34
+ ) -> instance
35
+ end
36
+
37
+ class SlotStatus < SlotStatusData
38
+ def self.from_catalog: (Hash[(String | Symbol), untyped] attributes) -> SlotStatus
39
+ end
40
+ end
41
+ 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.3.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