mammoth 0.9.0 → 1.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.
@@ -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,13 +64,15 @@ 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
74
  normalizer.each_normalized(decoded_stream) do |work|
68
- block.call(validate_core_work!(work))
75
+ yield_with_progress_position(validate_core_work!(work), &block)
69
76
  end
70
77
  nil
71
78
  rescue StandardError => e
@@ -74,11 +81,65 @@ module Mammoth
74
81
  raise ReplicationError, "PostgreSQL CDC source failed: #{e.message}"
75
82
  end
76
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
+
77
137
  private
78
138
 
79
139
  def decoded_stream
80
140
  Enumerator.new do |stream|
81
141
  effective_runner.start do |payload, metadata = nil|
142
+ @latest_progress_position = acknowledgement_position(metadata)
82
143
  process_payload(payload, metadata) { |decoded| stream << stream_event(decoded, metadata) }
83
144
  end
84
145
  end
@@ -108,6 +169,15 @@ module Mammoth
108
169
  normalizer.stream_event(decoded, source_position: source_position(metadata))
109
170
  end
110
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
+
111
181
  def validate_core_work!(work)
112
182
  return work if work.is_a?(CDC::Core::ChangeEvent)
113
183
  return work if work.is_a?(CDC::Core::TransactionEnvelope)
@@ -156,6 +226,22 @@ module Mammoth
156
226
  value_from(metadata, :source_position, :commit_lsn, :lsn, :wal_end_lsn, :end_lsn, :final_lsn)
157
227
  end
158
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
+
159
245
  def effective_runner
160
246
  runner || @effective_runner || begin
161
247
  @effective_runner = build_runner
@@ -180,6 +266,12 @@ module Mammoth
180
266
  end
181
267
  end
182
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
+
183
275
  def build_runner
184
276
  require_optional!("pgoutput/client", "pgoutput-client")
185
277
 
@@ -201,7 +293,12 @@ module Mammoth
201
293
  def build_adapter
202
294
  require_optional!("pgoutput/source_adapter", "pgoutput-source-adapter")
203
295
 
204
- 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:)
205
302
  end
206
303
 
207
304
  def runner_options
@@ -210,7 +307,7 @@ module Mammoth
210
307
  slot_name: required_config("replication", "slot"),
211
308
  publication_names: required_publications,
212
309
  start_lsn: replication_start_lsn,
213
- auto_create_slot: config.dig("replication", "auto_create_slot") == true,
310
+ auto_create_slot: safe_auto_create_slot?,
214
311
  temporary_slot: config.dig("replication", "temporary_slot") == true
215
312
  }.tap do |options|
216
313
  feedback_interval = config.dig("replication", "feedback_interval")
@@ -225,6 +322,157 @@ module Mammoth
225
322
  checkpoint_lsn
226
323
  end
227
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
+
228
476
  def checkpoint_lsn
229
477
  return nil unless checkpoint_store
230
478
 
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mammoth
6
+ module Sources
7
+ # Immutable replica-identity facts for one table in configured publications.
8
+ class PostgresPublicationTable
9
+ attr_reader :relation_id, :schema_name, :table_name, :publishes_updates, :publishes_deletes, :replica_identity,
10
+ :replica_identity_columns, :primary_key_usable, :replica_identity_index_usable
11
+
12
+ # @return [void]
13
+ def initialize(relation_id:, schema_name:, table_name:, publishes_updates:, publishes_deletes:,
14
+ replica_identity:, replica_identity_columns:, primary_key_usable:,
15
+ replica_identity_index_usable:)
16
+ @relation_id = relation_id
17
+ @schema_name = schema_name
18
+ @table_name = table_name
19
+ @publishes_updates = publishes_updates
20
+ @publishes_deletes = publishes_deletes
21
+ @replica_identity = replica_identity
22
+ @replica_identity_columns = replica_identity_columns.freeze
23
+ @primary_key_usable = primary_key_usable
24
+ @replica_identity_index_usable = replica_identity_index_usable
25
+ freeze
26
+ end
27
+
28
+ # Schema-qualified table name for diagnostics.
29
+ #
30
+ # @return [String]
31
+ def qualified_name
32
+ "#{schema_name}.#{table_name}"
33
+ end
34
+
35
+ # Publication actions that require old-row identity.
36
+ #
37
+ # @return [Array<String>]
38
+ def identity_actions
39
+ actions = [] # : Array[String]
40
+ actions.tap do
41
+ actions << "UPDATE" if publishes_updates
42
+ actions << "DELETE" if publishes_deletes
43
+ end
44
+ end
45
+
46
+ # Whether any configured publication requires replica identity.
47
+ #
48
+ # @return [Boolean]
49
+ def identity_required?
50
+ publishes_updates || publishes_deletes
51
+ end
52
+
53
+ # Whether PostgreSQL can identify old rows for the published actions.
54
+ #
55
+ # `d` uses a primary key, `i` uses a selected replica-identity index,
56
+ # and `f` logs the full old row. `n` provides no old-row identity.
57
+ #
58
+ # @return [Boolean]
59
+ def identity_usable?
60
+ return true unless identity_required?
61
+
62
+ case replica_identity
63
+ when "d" then primary_key_usable
64
+ when "i" then replica_identity_index_usable
65
+ when "f" then true
66
+ else false
67
+ end
68
+ end
69
+ end
70
+
71
+ # Read-only PostgreSQL publication and replica-identity catalog inspector.
72
+ class PostgresPublicationInspector
73
+ QUERY = <<~SQL
74
+ SELECT
75
+ relation.oid AS relation_id,
76
+ namespace.nspname AS schema_name,
77
+ relation.relname AS table_name,
78
+ bool_or(publication.pubupdate) AS publishes_updates,
79
+ bool_or(publication.pubdelete) AS publishes_deletes,
80
+ relation.relreplident AS replica_identity,
81
+ CASE relation.relreplident
82
+ WHEN 'd' THEN (
83
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY key.ordinality), '[]'::jsonb)
84
+ FROM pg_index AS identity_columns_index
85
+ JOIN LATERAL unnest(identity_columns_index.indkey)
86
+ WITH ORDINALITY AS key(attribute_number, ordinality) ON true
87
+ JOIN pg_attribute AS attribute
88
+ ON attribute.attrelid = relation.oid
89
+ AND attribute.attnum = key.attribute_number
90
+ WHERE identity_columns_index.indrelid = relation.oid
91
+ AND identity_columns_index.indisprimary
92
+ AND identity_columns_index.indisvalid
93
+ AND identity_columns_index.indisready
94
+ AND identity_columns_index.indislive
95
+ )
96
+ WHEN 'i' THEN (
97
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY key.ordinality), '[]'::jsonb)
98
+ FROM pg_index AS identity_columns_index
99
+ JOIN LATERAL unnest(identity_columns_index.indkey)
100
+ WITH ORDINALITY AS key(attribute_number, ordinality) ON true
101
+ JOIN pg_attribute AS attribute
102
+ ON attribute.attrelid = relation.oid
103
+ AND attribute.attnum = key.attribute_number
104
+ WHERE identity_columns_index.indrelid = relation.oid
105
+ AND identity_columns_index.indisreplident
106
+ AND identity_columns_index.indisvalid
107
+ AND identity_columns_index.indisready
108
+ AND identity_columns_index.indislive
109
+ )
110
+ WHEN 'f' THEN (
111
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY attribute.attnum), '[]'::jsonb)
112
+ FROM pg_attribute AS attribute
113
+ WHERE attribute.attrelid = relation.oid
114
+ AND attribute.attnum > 0
115
+ AND NOT attribute.attisdropped
116
+ )
117
+ ELSE '[]'::jsonb
118
+ END AS replica_identity_columns,
119
+ EXISTS (
120
+ SELECT 1
121
+ FROM pg_index AS primary_index
122
+ WHERE primary_index.indrelid = relation.oid
123
+ AND primary_index.indisprimary
124
+ AND primary_index.indisvalid
125
+ AND primary_index.indisready
126
+ AND primary_index.indislive
127
+ ) AS primary_key_usable,
128
+ EXISTS (
129
+ SELECT 1
130
+ FROM pg_index AS identity_index
131
+ WHERE identity_index.indrelid = relation.oid
132
+ AND identity_index.indisreplident
133
+ AND identity_index.indisvalid
134
+ AND identity_index.indisready
135
+ AND identity_index.indislive
136
+ ) AS replica_identity_index_usable
137
+ FROM pg_publication_tables AS publication_table
138
+ JOIN pg_publication AS publication
139
+ ON publication.pubname = publication_table.pubname
140
+ JOIN pg_namespace AS namespace
141
+ ON namespace.nspname = publication_table.schemaname
142
+ JOIN pg_class AS relation
143
+ ON relation.relnamespace = namespace.oid
144
+ AND relation.relname = publication_table.tablename
145
+ WHERE publication.pubname IN (
146
+ SELECT jsonb_array_elements_text($1::jsonb)
147
+ )
148
+ GROUP BY namespace.nspname, relation.relname, relation.oid, relation.relreplident
149
+ ORDER BY namespace.nspname, relation.relname
150
+ SQL
151
+
152
+ attr_reader :database_url
153
+
154
+ # @param database_url [String] PostgreSQL connection URL
155
+ # @param connection_factory [#call, nil] optional connection factory for tests
156
+ def initialize(database_url:, connection_factory: nil)
157
+ @database_url = database_url
158
+ @connection_factory = connection_factory
159
+ end
160
+
161
+ # Inspect tables included by the configured publications.
162
+ #
163
+ # @param publication_names [Array<String>] configured publication names
164
+ # @return [Array<PostgresPublicationTable>]
165
+ def inspect(publication_names)
166
+ connection = open_connection
167
+ rows = connection.exec_params(QUERY, [JSON.generate(publication_names)])
168
+ rows.map { |row| build_table(row) }
169
+ rescue pg_error_class => e
170
+ raise ReplicationError, "PostgreSQL publication inspection failed: #{e.message}"
171
+ ensure
172
+ connection&.close unless connection&.finished?
173
+ end
174
+
175
+ private
176
+
177
+ def open_connection
178
+ return @connection_factory.call(database_url) if @connection_factory
179
+
180
+ require "pg"
181
+ PG.connect(database_url)
182
+ end
183
+
184
+ def build_table(row)
185
+ PostgresPublicationTable.new(
186
+ relation_id: Integer(row.fetch("relation_id")),
187
+ schema_name: row.fetch("schema_name"),
188
+ table_name: row.fetch("table_name"),
189
+ publishes_updates: postgres_boolean(row.fetch("publishes_updates")),
190
+ publishes_deletes: postgres_boolean(row.fetch("publishes_deletes")),
191
+ replica_identity: row.fetch("replica_identity"),
192
+ replica_identity_columns: parse_columns(row.fetch("replica_identity_columns")),
193
+ primary_key_usable: postgres_boolean(row.fetch("primary_key_usable")),
194
+ replica_identity_index_usable: postgres_boolean(row.fetch("replica_identity_index_usable"))
195
+ )
196
+ end
197
+
198
+ def parse_columns(value)
199
+ columns = value.is_a?(String) ? JSON.parse(value) : value
200
+ column_list = Array(columns) # : Array[untyped]
201
+ column_list.map(&:to_s)
202
+ end
203
+
204
+ def postgres_boolean(value)
205
+ [true, "t"].include?(value)
206
+ end
207
+
208
+ def pg_error_class
209
+ require "pg"
210
+ PG::Error
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ module Sources
5
+ # Immutable operator-facing health snapshot for one PostgreSQL replication slot.
6
+ #
7
+ # This is a Mammoth-owned policy view. The transport layer supplies catalog
8
+ # facts; Mammoth decides whether they make the configured source ready for
9
+ # continuous delivery.
10
+ # Typed PostgreSQL slot health and readiness policy.
11
+ class PostgresSlotHealth
12
+ attr_reader :slot_name, :present, :active, :retained_wal_bytes, :wal_status, :safe_wal_size, :inactive_since,
13
+ :invalidation_reason, :restart_lsn, :restart_lsn_bytes, :confirmed_flush_lsn,
14
+ :confirmed_flush_lsn_bytes, :conflicting
15
+
16
+ # Build a PostgreSQL slot health snapshot from normalized catalog facts.
17
+ #
18
+ # @return [void]
19
+ def initialize(slot_name:, present:, active:, retained_wal_bytes:, wal_status:, safe_wal_size:, inactive_since:,
20
+ invalidation_reason:, restart_lsn:, restart_lsn_bytes:, confirmed_flush_lsn:,
21
+ confirmed_flush_lsn_bytes:, conflicting:)
22
+ @slot_name = slot_name
23
+ @present = present
24
+ @active = active
25
+ @retained_wal_bytes = retained_wal_bytes
26
+ @wal_status = wal_status
27
+ @safe_wal_size = safe_wal_size
28
+ @inactive_since = inactive_since
29
+ @invalidation_reason = invalidation_reason
30
+ @restart_lsn = restart_lsn
31
+ @restart_lsn_bytes = restart_lsn_bytes
32
+ @confirmed_flush_lsn = confirmed_flush_lsn
33
+ @confirmed_flush_lsn_bytes = confirmed_flush_lsn_bytes
34
+ @conflicting = conflicting
35
+ freeze
36
+ end
37
+
38
+ # Build a snapshot for a missing configured slot.
39
+ #
40
+ # @param slot_name [String] configured replication slot
41
+ # @return [PostgresSlotHealth]
42
+ def self.missing(slot_name)
43
+ new(
44
+ slot_name:, present: false, active: false, retained_wal_bytes: nil,
45
+ wal_status: nil, safe_wal_size: nil, inactive_since: nil,
46
+ invalidation_reason: nil, restart_lsn: nil, restart_lsn_bytes: nil,
47
+ confirmed_flush_lsn: nil, confirmed_flush_lsn_bytes: nil, conflicting: false
48
+ )
49
+ end
50
+
51
+ # Whether the slot is present, active, and retaining usable WAL.
52
+ #
53
+ # @return [Boolean]
54
+ def ready?
55
+ reason.nil?
56
+ end
57
+
58
+ # Operator-facing reason the slot is not ready.
59
+ #
60
+ # @return [String, nil]
61
+ def reason
62
+ return "slot is missing" unless present
63
+ return "slot is inactive" unless active
64
+ return "wal_status=#{wal_status}" if %w[lost unreserved].include?(wal_status)
65
+ return "slot is invalidated: #{invalidation_reason}" unless blank?(invalidation_reason)
66
+ return "slot is conflicting" if conflicting
67
+ return "slot has no restart LSN" if blank?(restart_lsn)
68
+
69
+ nil
70
+ end
71
+
72
+ # Stable readiness payload without transport-library types.
73
+ #
74
+ # @return [Hash]
75
+ def summary
76
+ {
77
+ slot_name:, present:, active:, retained_wal_bytes:, wal_status:, safe_wal_size:, inactive_since:,
78
+ invalidation_reason:, restart_lsn:, restart_lsn_bytes:, confirmed_flush_lsn:,
79
+ confirmed_flush_lsn_bytes:, conflicting:, ready: ready?, reason:
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def blank?(value)
86
+ value.nil? || value == ""
87
+ end
88
+ end
89
+ end
90
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
4
+ require "digest"
5
5
  require "time"
6
6
 
7
7
  module Mammoth
@@ -38,7 +38,7 @@ module Mammoth
38
38
  def call
39
39
  event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
40
40
  {
41
- "event_id" => envelope_metadata["event_id"] || SecureRandom.uuid,
41
+ "event_id" => envelope_metadata["event_id"] || deterministic_event_id(event_payloads),
42
42
  "type" => PAYLOAD_TYPE,
43
43
  "source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
44
44
  "transaction_id" => envelope.transaction_id,
@@ -70,6 +70,16 @@ module Mammoth
70
70
  @envelope_metadata ||= stringify_keys(envelope.metadata)
71
71
  end
72
72
 
73
+ def deterministic_event_id(event_payloads)
74
+ identity = {
75
+ source: first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
76
+ transaction_id: envelope.transaction_id,
77
+ source_position: source_position(event_payloads),
78
+ event_ids: event_payloads.map { |payload| payload.fetch("event_id") }
79
+ }
80
+ "txn_#{Digest::SHA256.hexdigest(JSON.generate(identity))}"
81
+ end
82
+
73
83
  def source_position(event_payloads)
74
84
  envelope.commit_lsn ||
75
85
  first_event_value(event_payloads.reverse, "source_position")
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.9.0"
5
+ VERSION = "1.1.0"
6
6
  end