mammoth 0.8.0 → 1.0.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +93 -10
  4. data/config/mammoth.example.yml +19 -9
  5. data/config/mammoth.schema.json +3 -3
  6. data/lib/mammoth/application.rb +54 -43
  7. data/lib/mammoth/cli.rb +1 -13
  8. data/lib/mammoth/commands/bootstrap_command.rb +11 -7
  9. data/lib/mammoth/commands/dead_letters_command.rb +5 -3
  10. data/lib/mammoth/commands/deliver_sample_command.rb +1 -1
  11. data/lib/mammoth/commands/status_command.rb +5 -5
  12. data/lib/mammoth/concurrent_delivery_runtime.rb +15 -3
  13. data/lib/mammoth/dead_letter_commands.rb +16 -25
  14. data/lib/mammoth/dead_letter_store.rb +1 -1
  15. data/lib/mammoth/delivery_processor.rb +51 -9
  16. data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
  17. data/lib/mammoth/delivery_worker.rb +19 -33
  18. data/lib/mammoth/dispatch_metrics.rb +55 -0
  19. data/lib/mammoth/event_serializer.rb +23 -12
  20. data/lib/mammoth/fanout_delivery_worker.rb +4 -4
  21. data/lib/mammoth/metrics_observer.rb +52 -0
  22. data/lib/mammoth/observability_metrics.rb +71 -0
  23. data/lib/mammoth/observability_server.rb +8 -6
  24. data/lib/mammoth/observability_snapshot.rb +118 -66
  25. data/lib/mammoth/operational_state/adapter.rb +16 -0
  26. data/lib/mammoth/operational_state/registry.rb +8 -0
  27. data/lib/mammoth/operational_state/sqlite_adapter.rb +18 -2
  28. data/lib/mammoth/persisted_payload_deserializer.rb +85 -0
  29. data/lib/mammoth/postgres_observability_metrics.rb +80 -0
  30. data/lib/mammoth/replication_consumer.rb +41 -52
  31. data/lib/mammoth/runtimes/batching_runtime.rb +59 -0
  32. data/lib/mammoth/runtimes/concurrent_adapter.rb +4 -2
  33. data/lib/mammoth/runtimes/inline_adapter.rb +19 -5
  34. data/lib/mammoth/runtimes/registry.rb +3 -2
  35. data/lib/mammoth/sources/postgres.rb +261 -4
  36. data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
  37. data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
  38. data/lib/mammoth/status.rb +22 -18
  39. data/lib/mammoth/transaction_envelope_serializer.rb +13 -15
  40. data/lib/mammoth/version.rb +1 -1
  41. data/lib/mammoth/webhook_sink.rb +2 -2
  42. data/lib/mammoth.rb +12 -1
  43. metadata +38 -9
@@ -20,14 +20,14 @@ module Mammoth
20
20
 
21
21
  # Consume normalized CDC work from the configured source.
22
22
  #
23
- # @yieldparam event [Object] CDC::Core::ChangeEvent-compatible event
23
+ # @yieldparam event [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
24
24
  # @return [Integer] number of consumed events
25
25
  def start
26
26
  return enum_for(:start) unless block_given?
27
27
 
28
28
  count = 0
29
29
 
30
- each_event do |event|
30
+ each_grouped_event do |event, _group_end|
31
31
  yield event
32
32
  count += 1
33
33
  end
@@ -35,11 +35,27 @@ module Mammoth
35
35
  count
36
36
  end
37
37
 
38
+ # Consume work with an explicit source-group boundary for safe progress.
39
+ #
40
+ # @yieldparam event [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
41
+ # @yieldparam group_end [Boolean] true for the final delivery in a source transaction
42
+ # @return [Integer] number of consumed work items
43
+ def start_with_boundaries
44
+ return enum_for(:start_with_boundaries) unless block_given?
45
+
46
+ count = 0
47
+ each_grouped_event do |event, group_end|
48
+ yield event, group_end
49
+ count += 1
50
+ end
51
+ count
52
+ end
53
+
38
54
  private
39
55
 
40
- def each_event(&block)
56
+ def each_grouped_event(&block)
41
57
  effective_source.each do |work|
42
- flatten_cdc_work(work).each(&block)
58
+ grouped_cdc_work(work).each { |event, group_end| block.call(event, group_end) }
43
59
  end
44
60
  end
45
61
 
@@ -47,67 +63,40 @@ module Mammoth
47
63
  source || raise(ReplicationError, "replication source is not configured")
48
64
  end
49
65
 
50
- def flatten_cdc_work(work)
66
+ def grouped_cdc_work(work)
51
67
  return [] if work.nil?
52
- return validate_transaction_envelope(work) if transaction_envelope?(work) && transaction_delivery?
53
- return validate_events(work.events) if transaction_envelope?(work)
54
- return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
55
- return [synthetic_transaction_envelope(work)] if transaction_delivery?
68
+ return work.flat_map { |item| grouped_cdc_work(item) } if work.is_a?(Array)
69
+ return grouped_transaction_work(work) if work.is_a?(CDC::Core::TransactionEnvelope)
70
+ return event_work(work).map { |item| [item, true] } if work.is_a?(CDC::Core::ChangeEvent)
71
+
72
+ raise ReplicationError, "CDC source yielded non-core work: #{work.class}"
73
+ end
56
74
 
57
- validate_events([work])
75
+ def grouped_transaction_work(envelope)
76
+ items = transaction_work(envelope)
77
+ items.each_with_index.map { |item, index| [item, index == items.length - 1] }
58
78
  end
59
79
 
60
80
  def transaction_delivery?
61
81
  delivery_unit == :transaction
62
82
  end
63
83
 
64
- def validate_transaction_envelope(envelope)
65
- validate_events(envelope.events)
66
- [envelope]
84
+ def transaction_work(envelope)
85
+ transaction_delivery? ? [envelope] : envelope.events
67
86
  end
68
87
 
69
- def validate_events(events)
70
- events.each { |event| validate_cdc_event!(event) }
88
+ def event_work(event)
89
+ transaction_delivery? ? [transaction_envelope(event)] : [event]
71
90
  end
72
91
 
73
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
74
- def synthetic_transaction_envelope(event)
75
- validate_cdc_event!(event)
76
-
77
- event_hash = event.to_h
78
- SyntheticTransactionEnvelope.new(
79
- [event],
80
- event_hash["transaction_id"] || event_hash[:transaction_id] ||
81
- event_hash["xid"] || event_hash[:xid] || event_hash["event_id"] || event_hash[:event_id],
82
- event_hash["commit_lsn"] || event_hash[:commit_lsn] ||
83
- event_hash["source_position"] || event_hash[:source_position],
84
- event_hash["committed_at"] || event_hash[:committed_at] ||
85
- event_hash["occurred_at"] || event_hash[:occurred_at],
86
- event_hash["metadata"] || event_hash[:metadata] || {}
92
+ def transaction_envelope(event)
93
+ CDC::Core::TransactionEnvelope.new(
94
+ transaction_id: event.transaction_id || event.metadata[:event_id] || event.commit_lsn,
95
+ events: [event],
96
+ commit_lsn: event.commit_lsn,
97
+ committed_at: event.occurred_at,
98
+ metadata: event.metadata
87
99
  )
88
100
  end
89
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
90
-
91
- def validate_cdc_event!(event)
92
- return event if event.respond_to?(:to_h) && cdc_event_hash?(event.to_h)
93
-
94
- raise ReplicationError, "CDC source yielded non-CDC work: #{event.class}"
95
- end
96
-
97
- def cdc_event_hash?(event_hash)
98
- return false unless event_hash.respond_to?(:key?)
99
-
100
- has_operation = event_hash.key?("operation") || event_hash.key?(:operation)
101
- has_position = event_hash.key?("source_position") || event_hash.key?(:source_position) ||
102
- event_hash.key?("commit_lsn") || event_hash.key?(:commit_lsn)
103
- has_operation && has_position
104
- end
105
-
106
- def transaction_envelope?(work)
107
- work.respond_to?(:events) && work.respond_to?(:transaction_id)
108
- end
109
-
110
- SyntheticTransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
111
- private_constant :SyntheticTransactionEnvelope
112
101
  end
113
102
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ module Runtimes
5
+ # Adds configured batch submission to a selected delivery runtime.
6
+ #
7
+ # Application streams one work item at a time into this runtime boundary.
8
+ # BatchingRuntime owns accumulation and submits complete or final partial
9
+ # batches through the selected adapter runtime's #process_many contract.
10
+ class BatchingRuntime
11
+ attr_reader :runtime, :batch_size, :buffer
12
+
13
+ # @param runtime [#process_many] selected delivery runtime
14
+ # @param batch_size [Integer] maximum work items submitted together
15
+ def initialize(runtime:, batch_size:)
16
+ @runtime = runtime
17
+ @batch_size = batch_size
18
+ @buffer = []
19
+ end
20
+
21
+ # Buffer one work item and submit a full batch when ready.
22
+ #
23
+ # @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
24
+ # @return [Array] processor results when a batch is submitted, otherwise an empty array
25
+ def process(work)
26
+ buffer << work
27
+ return [] if buffer.size < batch_size
28
+
29
+ flush
30
+ end
31
+
32
+ # Submit work immediately without changing the buffered stream.
33
+ #
34
+ # @param items [Array] work items
35
+ # @return [Array] processor results
36
+ def process_many(items)
37
+ runtime.process_many(items)
38
+ end
39
+
40
+ # Submit the final partial batch.
41
+ #
42
+ # @return [Array] processor results, or an empty array when no work is buffered
43
+ def flush
44
+ return [] if buffer.empty?
45
+
46
+ runtime.process_many(buffer.shift(buffer.size))
47
+ end
48
+
49
+ # Flush pending work and shut down the selected runtime when supported.
50
+ #
51
+ # @return [nil]
52
+ def shutdown
53
+ flush
54
+ runtime.shutdown if runtime.respond_to?(:shutdown)
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -16,13 +16,15 @@ module Mammoth
16
16
  # @param concurrency [Integer] worker count
17
17
  # @param timeout [Numeric, nil] optional timeout
18
18
  # @param preserve_order [Boolean] preserve ordering when supported
19
+ # @param observer [CDC::Core::Observer] dispatch lifecycle observer
19
20
  # @return [Mammoth::ConcurrentDeliveryRuntime]
20
- def build(processor:, concurrency:, timeout:, preserve_order:)
21
+ def build(processor:, concurrency:, timeout:, preserve_order:, observer: CDC::Core::Observer.new)
21
22
  ConcurrentDeliveryRuntime.new(
22
23
  processor: processor,
23
24
  concurrency: concurrency,
24
25
  timeout: timeout,
25
- preserve_order: preserve_order
26
+ preserve_order: preserve_order,
27
+ observer: observer
26
28
  )
27
29
  end
28
30
  end
@@ -4,20 +4,22 @@ module Mammoth
4
4
  module Runtimes
5
5
  # Inline runtime adapter that processes work in the caller thread.
6
6
  class InlineAdapter < Adapter
7
- attr_reader :processor
7
+ attr_reader :processor, :observer
8
8
 
9
9
  # @param processor [#process] delivery processor
10
- def initialize(processor:)
10
+ # @param observer [CDC::Core::Observer] dispatch lifecycle observer
11
+ def initialize(processor:, observer: CDC::Core::Observer.new)
11
12
  super()
12
13
  @processor = processor
14
+ @observer = observer
13
15
  end
14
16
 
15
17
  # Build an inline runtime.
16
18
  #
17
19
  # @param processor [#process] delivery processor
18
20
  # @return [Mammoth::Runtimes::InlineAdapter]
19
- def self.build(processor:, **_options)
20
- new(processor: processor)
21
+ def self.build(processor:, observer: CDC::Core::Observer.new, **_options)
22
+ new(processor: processor, observer: observer)
21
23
  end
22
24
 
23
25
  # @return [String] adapter type name
@@ -28,13 +30,25 @@ module Mammoth
28
30
  # @param items [Array<Object>] work units
29
31
  # @return [Array<Object>] processor results
30
32
  def process_many(items)
31
- items.map { |item| processor.process(item) }
33
+ items.map do |item|
34
+ observer.dispatch_started(item)
35
+ processor.process(item).tap { |result| observe_result(result) }
36
+ end
32
37
  end
33
38
 
34
39
  # @return [nil]
35
40
  def shutdown
36
41
  nil
37
42
  end
43
+
44
+ private
45
+
46
+ def observe_result(result)
47
+ return observer.dispatch_succeeded(result) if result.success?
48
+ return observer.dispatch_failed(result) if result.failure?
49
+
50
+ observer.dispatch_skipped(result)
51
+ end
38
52
  end
39
53
  end
40
54
  end
@@ -27,8 +27,9 @@ module Mammoth
27
27
  # @param name [String, Symbol] adapter name
28
28
  # @param options [Hash] adapter-specific build options
29
29
  # @return [Object] runtime adapter
30
- def build(name, **options)
31
- fetch(name).build(**options)
30
+ def build(name, batch_size: 1, **options)
31
+ runtime = fetch(name).build(**options)
32
+ BatchingRuntime.new(runtime:, batch_size:)
32
33
  end
33
34
 
34
35
  # @return [Array<String>] registered runtime adapter names
@@ -27,6 +27,8 @@ module Mammoth
27
27
  attr_reader :adapter
28
28
  # @return [Mammoth::CheckpointStore, nil] checkpoint store used for restart resume
29
29
  attr_reader :checkpoint_store
30
+ # @return [#inspect, nil] injected PostgreSQL publication inspector
31
+ attr_reader :publication_inspector
30
32
 
31
33
  # Build a PostgreSQL CDC source.
32
34
  #
@@ -36,13 +38,16 @@ module Mammoth
36
38
  # @param decoder [Object, nil] injected pgoutput decoder
37
39
  # @param adapter [Object, nil] injected source adapter
38
40
  # @param checkpoint_store [Mammoth::CheckpointStore, nil] persisted checkpoints for restart resume
39
- def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil)
41
+ # @param publication_inspector [#inspect, nil] injected publication metadata inspector
42
+ def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil,
43
+ publication_inspector: nil)
40
44
  @config = config
41
45
  @runner = runner
42
46
  @parser = parser
43
47
  @decoder = decoder
44
48
  @adapter = adapter
45
49
  @checkpoint_store = checkpoint_store
50
+ @publication_inspector = publication_inspector
46
51
  end
47
52
 
48
53
  # Stream CDC::Core work from PostgreSQL logical replication.
@@ -59,12 +64,16 @@ module Mammoth
59
64
  def each(&block)
60
65
  return enum_for(:each) unless block_given?
61
66
 
67
+ preflight_slot!
68
+ preflight_replica_identity!
62
69
  normalizer = effective_adapter
63
70
  unless normalizer.respond_to?(:each_normalized)
64
71
  raise ReplicationError, "pgoutput source adapter must respond to #each_normalized"
65
72
  end
66
73
 
67
- normalizer.each_normalized(decoded_stream, &block)
74
+ normalizer.each_normalized(decoded_stream) do |work|
75
+ yield_with_progress_position(validate_core_work!(work), &block)
76
+ end
68
77
  nil
69
78
  rescue StandardError => e
70
79
  raise e if e.is_a?(ReplicationError)
@@ -72,11 +81,65 @@ module Mammoth
72
81
  raise ReplicationError, "PostgreSQL CDC source failed: #{e.message}"
73
82
  end
74
83
 
84
+ # Acknowledge a durably handled PostgreSQL WAL position.
85
+ #
86
+ # @param lsn [String, Integer] pgoutput-client compatible WAL position
87
+ # @return [Integer] normalized acknowledged WAL position
88
+ def acknowledge(lsn)
89
+ effective_runner.ack(lsn)
90
+ rescue StandardError => e
91
+ raise ReplicationError, "PostgreSQL WAL acknowledgement failed: #{e.message}"
92
+ end
93
+
94
+ # Resolve the acknowledgement-compatible transport position for work
95
+ # currently being yielded by this source.
96
+ #
97
+ # @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] yielded work
98
+ # @return [String, Integer, nil] pgoutput-client compatible WAL position
99
+ def progress_position_for(work)
100
+ return nil unless yielded_work_includes?(work)
101
+
102
+ @yielded_progress_position
103
+ end
104
+
105
+ # Inspect the configured slot for readiness and operator metrics.
106
+ #
107
+ # pgoutput-client owns catalog access. This method converts its snapshot
108
+ # into Mammoth's PostgreSQL-specific health policy without leaking
109
+ # transport-library types into the observability layer.
110
+ #
111
+ # @return [PostgresSlotHealth]
112
+ def slot_health
113
+ status = inspected_slot_status
114
+ return PostgresSlotHealth.missing(required_config("replication", "slot")) unless status
115
+
116
+ PostgresSlotHealth.new(
117
+ slot_name: value_from(status, :slot_name),
118
+ present: true,
119
+ active: value_from(status, :active) == true,
120
+ retained_wal_bytes: value_from(status, :retained_wal_bytes),
121
+ wal_status: value_from(status, :wal_status),
122
+ safe_wal_size: value_from(status, :safe_wal_size),
123
+ inactive_since: value_from(status, :inactive_since),
124
+ invalidation_reason: value_from(status, :invalidation_reason),
125
+ restart_lsn: value_from(status, :restart_lsn),
126
+ restart_lsn_bytes: metric_lsn(value_from(status, :restart_lsn)),
127
+ confirmed_flush_lsn: value_from(status, :confirmed_flush_lsn),
128
+ confirmed_flush_lsn_bytes: metric_lsn(value_from(status, :confirmed_flush_lsn)),
129
+ conflicting: value_from(status, :conflicting) == true
130
+ )
131
+ rescue StandardError => e
132
+ raise e if e.is_a?(ReplicationError)
133
+
134
+ raise ReplicationError, "PostgreSQL slot health inspection failed: #{e.message}"
135
+ end
136
+
75
137
  private
76
138
 
77
139
  def decoded_stream
78
140
  Enumerator.new do |stream|
79
141
  effective_runner.start do |payload, metadata = nil|
142
+ @latest_progress_position = acknowledgement_position(metadata)
80
143
  process_payload(payload, metadata) { |decoded| stream << stream_event(decoded, metadata) }
81
144
  end
82
145
  end
@@ -106,6 +169,22 @@ module Mammoth
106
169
  normalizer.stream_event(decoded, source_position: source_position(metadata))
107
170
  end
108
171
 
172
+ def yield_with_progress_position(work)
173
+ @yielded_work = work
174
+ @yielded_progress_position = @latest_progress_position
175
+ yield work
176
+ ensure
177
+ @yielded_work = nil
178
+ @yielded_progress_position = nil
179
+ end
180
+
181
+ def validate_core_work!(work)
182
+ return work if work.is_a?(CDC::Core::ChangeEvent)
183
+ return work if work.is_a?(CDC::Core::TransactionEnvelope)
184
+
185
+ raise ReplicationError, "pgoutput source adapter yielded non-core work: #{work.class}"
186
+ end
187
+
109
188
  def parse_payload(payload)
110
189
  parser = effective_parser
111
190
  return parser.process(payload) if parser.respond_to?(:process)
@@ -147,6 +226,22 @@ module Mammoth
147
226
  value_from(metadata, :source_position, :commit_lsn, :lsn, :wal_end_lsn, :end_lsn, :final_lsn)
148
227
  end
149
228
 
229
+ def acknowledgement_position(metadata)
230
+ value = value_from(metadata, :wal_end_lsn, :wal_end, :lsn)
231
+ return nil if value.nil?
232
+ return value if value.is_a?(Integer) && value >= 0
233
+ return value if value.is_a?(String) && value.match?(%r{\A[0-9A-F]+/[0-9A-F]+\z}i)
234
+
235
+ raise ReplicationError, "invalid PostgreSQL transport LSN for acknowledgement: #{value.inspect}"
236
+ end
237
+
238
+ def yielded_work_includes?(work)
239
+ return true if @yielded_work.equal?(work)
240
+ return false unless @yielded_work.is_a?(CDC::Core::TransactionEnvelope)
241
+
242
+ @yielded_work.events.any? { |event| event.equal?(work) }
243
+ end
244
+
150
245
  def effective_runner
151
246
  runner || @effective_runner || begin
152
247
  @effective_runner = build_runner
@@ -171,6 +266,12 @@ module Mammoth
171
266
  end
172
267
  end
173
268
 
269
+ def effective_publication_inspector
270
+ publication_inspector || @effective_publication_inspector || begin
271
+ @effective_publication_inspector = build_publication_inspector
272
+ end
273
+ end
274
+
174
275
  def build_runner
175
276
  require_optional!("pgoutput/client", "pgoutput-client")
176
277
 
@@ -192,7 +293,12 @@ module Mammoth
192
293
  def build_adapter
193
294
  require_optional!("pgoutput/source_adapter", "pgoutput-source-adapter")
194
295
 
195
- Pgoutput::SourceAdapter::Cdc.new
296
+ resolver = Pgoutput::SourceAdapter::ReplicaIdentityResolver.new(replica_identity_relations)
297
+ Pgoutput::SourceAdapter::Cdc.new(primary_key_resolver: resolver)
298
+ end
299
+
300
+ def build_publication_inspector
301
+ PostgresPublicationInspector.new(database_url:)
196
302
  end
197
303
 
198
304
  def runner_options
@@ -201,7 +307,7 @@ module Mammoth
201
307
  slot_name: required_config("replication", "slot"),
202
308
  publication_names: required_publications,
203
309
  start_lsn: replication_start_lsn,
204
- auto_create_slot: config.dig("replication", "auto_create_slot") == true,
310
+ auto_create_slot: safe_auto_create_slot?,
205
311
  temporary_slot: config.dig("replication", "temporary_slot") == true
206
312
  }.tap do |options|
207
313
  feedback_interval = config.dig("replication", "feedback_interval")
@@ -216,6 +322,157 @@ module Mammoth
216
322
  checkpoint_lsn
217
323
  end
218
324
 
325
+ def preflight_slot!
326
+ resume_lsn = replication_start_lsn
327
+ validate_temporary_slot_resume!(resume_lsn)
328
+ status = inspected_slot_status
329
+
330
+ if status.nil?
331
+ return if safe_auto_create_slot?
332
+
333
+ raise ReplicationError, missing_slot_message(resume_lsn)
334
+ end
335
+
336
+ validate_slot_identity!(status)
337
+ validate_slot_health!(status)
338
+ validate_slot_reachability!(status, resume_lsn) unless blank?(resume_lsn)
339
+ nil
340
+ rescue StandardError => e
341
+ raise e if e.is_a?(ReplicationError)
342
+
343
+ raise ReplicationError, "PostgreSQL slot preflight failed: #{e.message}"
344
+ end
345
+
346
+ def preflight_replica_identity!
347
+ invalid_tables = publication_tables.reject(&:identity_usable?)
348
+ return if invalid_tables.empty?
349
+
350
+ details = invalid_tables.map do |table|
351
+ "#{table.qualified_name} (actions=#{table.identity_actions.join("/")}, " \
352
+ "replica_identity=#{replica_identity_name(table.replica_identity)})"
353
+ end
354
+ raise ReplicationError,
355
+ "PostgreSQL replica identity preflight failed for #{details.join(", ")}. " \
356
+ "Add a primary key, select an eligible unique index with REPLICA IDENTITY USING INDEX, " \
357
+ "use REPLICA IDENTITY FULL, or remove UPDATE/DELETE from the publication."
358
+ rescue StandardError => e
359
+ raise e if e.is_a?(ReplicationError)
360
+
361
+ raise ReplicationError, "PostgreSQL replica identity preflight failed: #{e.message}"
362
+ end
363
+
364
+ def publication_tables
365
+ @publication_tables ||= effective_publication_inspector.inspect(required_publications).freeze
366
+ end
367
+
368
+ def replica_identity_relations
369
+ relations = {} # : Hash[Integer | [String, String], Array[String]]
370
+ publication_tables.each do |table|
371
+ next if table.replica_identity_columns.empty?
372
+
373
+ columns = table.replica_identity_columns
374
+ relations[table.relation_id] = columns
375
+ relations[[table.schema_name, table.table_name]] = columns
376
+ end
377
+ relations
378
+ end
379
+
380
+ def inspected_slot_status
381
+ client = effective_runner
382
+ unless client.respond_to?(:slot_status)
383
+ raise ReplicationError, "pgoutput-client 0.4+ with #slot_status is required for PostgreSQL slot inspection"
384
+ end
385
+
386
+ client.slot_status
387
+ end
388
+
389
+ def validate_temporary_slot_resume!(resume_lsn)
390
+ return if blank?(resume_lsn)
391
+ return unless config.dig("replication", "temporary_slot") == true
392
+
393
+ raise ReplicationError, "cannot resume durable PostgreSQL checkpoint #{resume_lsn} with a temporary slot"
394
+ end
395
+
396
+ def validate_slot_identity!(status)
397
+ slot_name = required_config("replication", "slot")
398
+ unless value_from(status, :slot_name) == slot_name
399
+ raise ReplicationError, "PostgreSQL slot preflight returned the wrong slot for #{slot_name}"
400
+ end
401
+ unless value_from(status, :slot_type) == "logical" && value_from(status, :plugin) == "pgoutput"
402
+ raise ReplicationError, "PostgreSQL slot #{slot_name} must be a logical pgoutput slot"
403
+ end
404
+
405
+ database = required_config("postgres", "database")
406
+ return if value_from(status, :database) == database
407
+
408
+ raise ReplicationError, "PostgreSQL slot #{slot_name} belongs to a different database"
409
+ end
410
+
411
+ def validate_slot_health!(status)
412
+ slot_name = required_config("replication", "slot")
413
+ raise ReplicationError, "PostgreSQL slot #{slot_name} is already active" if value_from(status, :active) == true
414
+
415
+ wal_status = value_from(status, :wal_status)
416
+ if %w[lost unreserved].include?(wal_status)
417
+ raise ReplicationError, "PostgreSQL slot #{slot_name} cannot retain required WAL (wal_status=#{wal_status})"
418
+ end
419
+
420
+ invalidation_reason = value_from(status, :invalidation_reason)
421
+ if value_from(status, :conflicting) == true || !blank?(invalidation_reason)
422
+ raise ReplicationError,
423
+ "PostgreSQL slot #{slot_name} is invalidated#{": #{invalidation_reason}" unless blank?(invalidation_reason)}"
424
+ end
425
+
426
+ return unless blank?(value_from(status, :restart_lsn))
427
+
428
+ raise ReplicationError, "PostgreSQL slot #{slot_name} has no reachable restart LSN"
429
+ end
430
+
431
+ def validate_slot_reachability!(status, resume_lsn)
432
+ requested = parse_transport_lsn(resume_lsn, "resume checkpoint")
433
+ %i[restart_lsn confirmed_flush_lsn].each do |field|
434
+ boundary = value_from(status, field)
435
+ next if blank?(boundary)
436
+ next unless requested < parse_transport_lsn(boundary, field.to_s)
437
+
438
+ raise ReplicationError,
439
+ "PostgreSQL slot cannot serve checkpoint #{resume_lsn}; #{field}=#{boundary} has already advanced past it"
440
+ end
441
+ end
442
+
443
+ def parse_transport_lsn(value, label)
444
+ require_optional!("pgoutput/client", "pgoutput-client")
445
+ Pgoutput::Client::LSN.parse(value)
446
+ rescue StandardError => e
447
+ raise ReplicationError, "invalid PostgreSQL #{label} LSN #{value.inspect}: #{e.message}"
448
+ end
449
+
450
+ def metric_lsn(value)
451
+ return nil if blank?(value)
452
+
453
+ parse_transport_lsn(value, "slot metric")
454
+ end
455
+
456
+ def replica_identity_name(value)
457
+ {
458
+ "d" => "default",
459
+ "n" => "nothing",
460
+ "f" => "full",
461
+ "i" => "index"
462
+ }.fetch(value, value)
463
+ end
464
+
465
+ def safe_auto_create_slot?
466
+ config.dig("replication", "auto_create_slot") == true && blank?(replication_start_lsn)
467
+ end
468
+
469
+ def missing_slot_message(resume_lsn)
470
+ slot_name = required_config("replication", "slot")
471
+ return "PostgreSQL slot #{slot_name} is missing and auto_create_slot is disabled" if blank?(resume_lsn)
472
+
473
+ "PostgreSQL slot #{slot_name} is missing; refusing to recreate it while checkpoint #{resume_lsn} requires continuity"
474
+ end
475
+
219
476
  def checkpoint_lsn
220
477
  return nil unless checkpoint_store
221
478