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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +87 -7
- data/config/mammoth.example.yml +19 -9
- data/config/mammoth.schema.json +2 -2
- data/lib/mammoth/application.rb +34 -3
- data/lib/mammoth/delivery_processor.rb +5 -2
- data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
- data/lib/mammoth/delivery_worker.rb +9 -25
- data/lib/mammoth/event_serializer.rb +39 -7
- data/lib/mammoth/observability_metrics.rb +3 -1
- data/lib/mammoth/observability_server.rb +5 -3
- data/lib/mammoth/observability_snapshot.rb +63 -10
- data/lib/mammoth/postgres_observability_metrics.rb +80 -0
- data/lib/mammoth/replication_consumer.rb +28 -7
- data/lib/mammoth/sources/postgres.rb +252 -4
- data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
- data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
- data/lib/mammoth/transaction_envelope_serializer.rb +12 -2
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth.rb +5 -1
- metadata +33 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
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::
|
|
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:
|
|
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 "
|
|
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"] ||
|
|
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")
|
data/lib/mammoth/version.rb
CHANGED