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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/README.md +93 -10
- data/config/mammoth.example.yml +19 -9
- data/config/mammoth.schema.json +3 -3
- data/lib/mammoth/application.rb +54 -43
- data/lib/mammoth/cli.rb +1 -13
- data/lib/mammoth/commands/bootstrap_command.rb +11 -7
- data/lib/mammoth/commands/dead_letters_command.rb +5 -3
- data/lib/mammoth/commands/deliver_sample_command.rb +1 -1
- data/lib/mammoth/commands/status_command.rb +5 -5
- data/lib/mammoth/concurrent_delivery_runtime.rb +15 -3
- data/lib/mammoth/dead_letter_commands.rb +16 -25
- data/lib/mammoth/dead_letter_store.rb +1 -1
- data/lib/mammoth/delivery_processor.rb +51 -9
- data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
- data/lib/mammoth/delivery_worker.rb +19 -33
- data/lib/mammoth/dispatch_metrics.rb +55 -0
- data/lib/mammoth/event_serializer.rb +23 -12
- data/lib/mammoth/fanout_delivery_worker.rb +4 -4
- data/lib/mammoth/metrics_observer.rb +52 -0
- data/lib/mammoth/observability_metrics.rb +71 -0
- data/lib/mammoth/observability_server.rb +8 -6
- data/lib/mammoth/observability_snapshot.rb +118 -66
- data/lib/mammoth/operational_state/adapter.rb +16 -0
- data/lib/mammoth/operational_state/registry.rb +8 -0
- data/lib/mammoth/operational_state/sqlite_adapter.rb +18 -2
- data/lib/mammoth/persisted_payload_deserializer.rb +85 -0
- data/lib/mammoth/postgres_observability_metrics.rb +80 -0
- data/lib/mammoth/replication_consumer.rb +41 -52
- data/lib/mammoth/runtimes/batching_runtime.rb +59 -0
- data/lib/mammoth/runtimes/concurrent_adapter.rb +4 -2
- data/lib/mammoth/runtimes/inline_adapter.rb +19 -5
- data/lib/mammoth/runtimes/registry.rb +3 -2
- data/lib/mammoth/sources/postgres.rb +261 -4
- data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
- data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
- data/lib/mammoth/status.rb +22 -18
- data/lib/mammoth/transaction_envelope_serializer.rb +13 -15
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +2 -2
- data/lib/mammoth.rb +12 -1
- 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 [
|
|
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
|
-
|
|
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
|
|
56
|
+
def each_grouped_event(&block)
|
|
41
57
|
effective_source.each do |work|
|
|
42
|
-
|
|
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
|
|
66
|
+
def grouped_cdc_work(work)
|
|
51
67
|
return [] if work.nil?
|
|
52
|
-
return
|
|
53
|
-
return
|
|
54
|
-
return work.
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
[envelope]
|
|
84
|
+
def transaction_work(envelope)
|
|
85
|
+
transaction_delivery? ? [envelope] : envelope.events
|
|
67
86
|
end
|
|
68
87
|
|
|
69
|
-
def
|
|
70
|
-
|
|
88
|
+
def event_work(event)
|
|
89
|
+
transaction_delivery? ? [transaction_envelope(event)] : [event]
|
|
71
90
|
end
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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::
|
|
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:
|
|
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
|
|