mammoth 0.1.1 → 0.2.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 +54 -0
- data/README.md +45 -5
- data/config/mammoth.example.yml +249 -0
- data/config/mammoth.schema.json +186 -40
- data/lib/mammoth/application.rb +87 -8
- data/lib/mammoth/cli.rb +1 -1
- data/lib/mammoth/concurrent_delivery_runtime.rb +52 -0
- data/lib/mammoth/dead_letter_store.rb +3 -3
- data/lib/mammoth/delivered_envelope_store.rb +98 -0
- data/lib/mammoth/delivery_processor.rb +68 -0
- data/lib/mammoth/delivery_worker.rb +74 -12
- data/lib/mammoth/replication_consumer.rb +39 -4
- data/lib/mammoth/sources/postgres.rb +188 -9
- data/lib/mammoth/sql/__bootstrap__.sql +24 -0
- data/lib/mammoth/transaction_envelope_serializer.rb +91 -0
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +92 -16
- data/lib/mammoth.rb +4 -0
- metadata +19 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Adapter object used by CDC::Concurrent::ProcessorPool.
|
|
5
|
+
#
|
|
6
|
+
# The processor keeps cdc-concurrent integration narrow: cdc-concurrent owns
|
|
7
|
+
# I/O-heavy fan-out mechanics, while DeliveryWorker owns Mammoth relay
|
|
8
|
+
# semantics such as retries, dead letters, and checkpoint writes.
|
|
9
|
+
class DeliveryProcessor
|
|
10
|
+
@concurrent_safe = false
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Mark this processor as safe for CDC::Concurrent::ProcessorPool.
|
|
14
|
+
#
|
|
15
|
+
# DeliveryProcessor itself is intentionally stateless after initialization;
|
|
16
|
+
# per-delivery retry, dead-letter, and checkpoint behavior remains owned by
|
|
17
|
+
# the injected DeliveryWorker.
|
|
18
|
+
#
|
|
19
|
+
# @return [true]
|
|
20
|
+
def concurrent_safe!
|
|
21
|
+
@concurrent_safe = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] true when this processor has explicitly opted in to
|
|
25
|
+
# cdc-concurrent execution.
|
|
26
|
+
def concurrent_safe?
|
|
27
|
+
@concurrent_safe == true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
alias concurrent_safe concurrent_safe?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
concurrent_safe!
|
|
34
|
+
|
|
35
|
+
attr_reader :delivery_worker, :delivery_unit
|
|
36
|
+
|
|
37
|
+
# @param delivery_worker [Mammoth::DeliveryWorker] relay-aware delivery worker
|
|
38
|
+
# @param delivery_unit [String, Symbol] event or transaction
|
|
39
|
+
def initialize(delivery_worker:, delivery_unit: :event)
|
|
40
|
+
@delivery_worker = delivery_worker
|
|
41
|
+
@delivery_unit = delivery_unit.to_sym
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] true when this processor instance is safe for concurrent execution.
|
|
45
|
+
def concurrent_safe?
|
|
46
|
+
self.class.concurrent_safe?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
alias concurrent_safe concurrent_safe?
|
|
50
|
+
|
|
51
|
+
# Process one work item from CDC::Concurrent::ProcessorPool.
|
|
52
|
+
#
|
|
53
|
+
# @param work [Object] event or transaction envelope
|
|
54
|
+
# @return [Hash] delivery summary
|
|
55
|
+
def call(work)
|
|
56
|
+
process(work)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def process(work)
|
|
60
|
+
case delivery_unit
|
|
61
|
+
when :transaction
|
|
62
|
+
delivery_worker.deliver_transaction(work)
|
|
63
|
+
else
|
|
64
|
+
delivery_worker.deliver(work)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -11,12 +11,13 @@ module Mammoth
|
|
|
11
11
|
# Default source name used when an event does not provide one.
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
13
13
|
|
|
14
|
-
attr_reader :sink, :checkpoint_store, :dead_letter_store, :
|
|
15
|
-
:slot_name, :publication_name
|
|
14
|
+
attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
|
|
15
|
+
:sleeper, :source_name, :slot_name, :publication_name
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
18
|
# @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
|
|
19
19
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
20
|
+
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore, nil] downstream delivery ledger
|
|
20
21
|
# @param source_name [String] logical source name
|
|
21
22
|
# @param slot_name [String] replication slot name
|
|
22
23
|
# @param publication_name [String] publication name
|
|
@@ -24,10 +25,11 @@ module Mammoth
|
|
|
24
25
|
# @param retry_schedule [Array<Integer>] retry wait schedule in seconds
|
|
25
26
|
# @param sleeper [#call] sleep strategy, injectable for tests
|
|
26
27
|
def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
|
|
27
|
-
max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep))
|
|
28
|
+
max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep))
|
|
28
29
|
@sink = sink
|
|
29
30
|
@checkpoint_store = checkpoint_store
|
|
30
31
|
@dead_letter_store = dead_letter_store
|
|
32
|
+
@delivered_envelope_store = delivered_envelope_store || DeliveredEnvelopeStore.new(checkpoint_store.sqlite_store)
|
|
31
33
|
@source_name = source_name
|
|
32
34
|
@slot_name = slot_name
|
|
33
35
|
@publication_name = publication_name
|
|
@@ -58,30 +60,70 @@ module Mammoth
|
|
|
58
60
|
)
|
|
59
61
|
end
|
|
60
62
|
|
|
63
|
+
# Deliver a transaction envelope with retry, checkpoint, and DLQ handling.
|
|
64
|
+
#
|
|
65
|
+
# @param envelope [#events, #transaction_id] CDC transaction envelope
|
|
66
|
+
# @return [Hash] delivery summary
|
|
67
|
+
def deliver_transaction(envelope)
|
|
68
|
+
deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
|
|
69
|
+
end
|
|
70
|
+
|
|
61
71
|
# Deliver an event with retry, checkpoint, and DLQ handling.
|
|
62
72
|
#
|
|
63
73
|
# @param event [Hash, #to_h] normalized event
|
|
64
74
|
# @return [Hash] delivery summary
|
|
65
75
|
def deliver(event)
|
|
76
|
+
deliver_work(event, serializer: EventSerializer, delivery_method: :deliver)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# rubocop:disable Metrics/MethodLength
|
|
82
|
+
def deliver_work(work, serializer:, delivery_method:)
|
|
66
83
|
attempts = 0
|
|
84
|
+
payload = serializer.call(work)
|
|
85
|
+
delivery_unit = delivery_unit_for(delivery_method)
|
|
86
|
+
idempotency_key = idempotency_key_for(payload:, delivery_unit:)
|
|
87
|
+
|
|
88
|
+
if delivered_envelope_store.delivered?(idempotency_key)
|
|
89
|
+
checkpoint_payload(payload)
|
|
90
|
+
return {
|
|
91
|
+
status: "skipped",
|
|
92
|
+
duplicate: true,
|
|
93
|
+
idempotency_key: idempotency_key,
|
|
94
|
+
attempts: attempts,
|
|
95
|
+
destination: destination_name
|
|
96
|
+
}
|
|
97
|
+
end
|
|
67
98
|
|
|
68
99
|
begin
|
|
69
100
|
attempts += 1
|
|
70
|
-
result = sink.
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
result = sink.public_send(delivery_method, work)
|
|
102
|
+
delivered_envelope_store.record!(
|
|
103
|
+
idempotency_key: idempotency_key,
|
|
104
|
+
source_name: source_name,
|
|
105
|
+
slot_name: slot_name,
|
|
106
|
+
destination_name: destination_name,
|
|
107
|
+
delivery_unit: delivery_unit.to_s,
|
|
108
|
+
transaction_id: payload["transaction_id"],
|
|
109
|
+
source_position: payload["source_position"]
|
|
110
|
+
)
|
|
111
|
+
checkpoint_payload(payload)
|
|
112
|
+
result.merge(attempts: attempts, idempotency_key: idempotency_key)
|
|
73
113
|
rescue DeliveryError => e
|
|
74
|
-
return dead_letter(
|
|
114
|
+
return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
|
|
75
115
|
|
|
76
116
|
wait_before_retry(attempts)
|
|
77
117
|
retry
|
|
78
118
|
end
|
|
79
119
|
end
|
|
120
|
+
# rubocop:enable Metrics/MethodLength
|
|
80
121
|
|
|
81
|
-
|
|
122
|
+
def checkpoint(work, serializer:)
|
|
123
|
+
checkpoint_payload(serializer.call(work))
|
|
124
|
+
end
|
|
82
125
|
|
|
83
|
-
def
|
|
84
|
-
payload = EventSerializer.call(event)
|
|
126
|
+
def checkpoint_payload(payload)
|
|
85
127
|
checkpoint_store.write(
|
|
86
128
|
source_name: source_name,
|
|
87
129
|
slot_name: slot_name,
|
|
@@ -90,12 +132,32 @@ module Mammoth
|
|
|
90
132
|
)
|
|
91
133
|
end
|
|
92
134
|
|
|
93
|
-
def
|
|
135
|
+
def idempotency_key_for(payload:, delivery_unit:)
|
|
136
|
+
[
|
|
137
|
+
source_name,
|
|
138
|
+
slot_name,
|
|
139
|
+
destination_name,
|
|
140
|
+
delivery_unit,
|
|
141
|
+
payload["transaction_id"] || payload["event_id"],
|
|
142
|
+
payload["source_position"]
|
|
143
|
+
].compact.join(":")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def delivery_unit_for(delivery_method)
|
|
147
|
+
delivery_method == :deliver_transaction ? :transaction : :event
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def destination_name
|
|
151
|
+
sink.respond_to?(:name) ? sink.name : sink.class.name
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def dead_letter(event, error, attempts, serializer:)
|
|
94
155
|
id = dead_letter_store.write(
|
|
95
156
|
event: event,
|
|
96
157
|
destination_name: sink.name,
|
|
97
158
|
error: error,
|
|
98
|
-
retry_count: attempts
|
|
159
|
+
retry_count: attempts,
|
|
160
|
+
serializer: serializer
|
|
99
161
|
)
|
|
100
162
|
{ status: "dead_lettered", dead_letter_id: id, attempts: attempts }
|
|
101
163
|
end
|
|
@@ -5,14 +5,17 @@ module Mammoth
|
|
|
5
5
|
#
|
|
6
6
|
# ReplicationConsumer is intentionally upstream-agnostic. It does not know
|
|
7
7
|
# which upstream system produced the work. Its
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# job is to consume CDC Ecosystem work and yield either individual change
|
|
9
|
+
# events or transaction envelopes depending on Mammoth's configured delivery
|
|
10
|
+
# unit.
|
|
10
11
|
class ReplicationConsumer
|
|
11
|
-
attr_reader :source
|
|
12
|
+
attr_reader :source, :delivery_unit
|
|
12
13
|
|
|
13
14
|
# @param source [#each, nil] injectable CDC work stream
|
|
14
|
-
|
|
15
|
+
# @param delivery_unit [String, Symbol] :event or :transaction
|
|
16
|
+
def initialize(source: nil, delivery_unit: :event)
|
|
15
17
|
@source = source
|
|
18
|
+
@delivery_unit = delivery_unit.to_sym
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# Consume normalized CDC work from the configured source.
|
|
@@ -46,16 +49,45 @@ module Mammoth
|
|
|
46
49
|
|
|
47
50
|
def flatten_cdc_work(work)
|
|
48
51
|
return [] if work.nil?
|
|
52
|
+
return validate_transaction_envelope(work) if transaction_envelope?(work) && transaction_delivery?
|
|
49
53
|
return validate_events(work.events) if transaction_envelope?(work)
|
|
50
54
|
return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
|
|
55
|
+
return [synthetic_transaction_envelope(work)] if transaction_delivery?
|
|
51
56
|
|
|
52
57
|
validate_events([work])
|
|
53
58
|
end
|
|
54
59
|
|
|
60
|
+
def transaction_delivery?
|
|
61
|
+
delivery_unit == :transaction
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_transaction_envelope(envelope)
|
|
65
|
+
validate_events(envelope.events)
|
|
66
|
+
[envelope]
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
def validate_events(events)
|
|
56
70
|
events.each { |event| validate_cdc_event!(event) }
|
|
57
71
|
end
|
|
58
72
|
|
|
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] || {}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
90
|
+
|
|
59
91
|
def validate_cdc_event!(event)
|
|
60
92
|
return event if event.respond_to?(:to_h) && cdc_event_hash?(event.to_h)
|
|
61
93
|
|
|
@@ -74,5 +106,8 @@ module Mammoth
|
|
|
74
106
|
def transaction_envelope?(work)
|
|
75
107
|
work.respond_to?(:events) && work.respond_to?(:transaction_id)
|
|
76
108
|
end
|
|
109
|
+
|
|
110
|
+
SyntheticTransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
|
|
111
|
+
private_constant :SyntheticTransactionEnvelope
|
|
77
112
|
end
|
|
78
113
|
end
|
|
@@ -12,6 +12,7 @@ module Mammoth
|
|
|
12
12
|
# This class may mention pgoutput implementation details because it is the
|
|
13
13
|
# concrete PostgreSQL source adapter used by Mammoth. The rest of Mammoth
|
|
14
14
|
# should remain source-agnostic and consume only the work yielded here.
|
|
15
|
+
# rubocop:disable Metrics/ClassLength
|
|
15
16
|
class Postgres
|
|
16
17
|
# @return [Mammoth::Configuration] loaded Mammoth configuration
|
|
17
18
|
attr_reader :config
|
|
@@ -23,6 +24,8 @@ module Mammoth
|
|
|
23
24
|
attr_reader :decoder
|
|
24
25
|
# @return [Object, nil] injected CDC source adapter
|
|
25
26
|
attr_reader :adapter
|
|
27
|
+
# @return [Mammoth::CheckpointStore, nil] checkpoint store used for restart resume
|
|
28
|
+
attr_reader :checkpoint_store
|
|
26
29
|
|
|
27
30
|
# Build a PostgreSQL CDC source.
|
|
28
31
|
#
|
|
@@ -31,12 +34,14 @@ module Mammoth
|
|
|
31
34
|
# @param parser [Object, nil] injected pgoutput parser or relation tracker
|
|
32
35
|
# @param decoder [Object, nil] injected pgoutput decoder
|
|
33
36
|
# @param adapter [Object, nil] injected source adapter
|
|
34
|
-
|
|
37
|
+
# @param checkpoint_store [Mammoth::CheckpointStore, nil] persisted checkpoints for restart resume
|
|
38
|
+
def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil)
|
|
35
39
|
@config = config
|
|
36
40
|
@runner = runner
|
|
37
41
|
@parser = parser
|
|
38
42
|
@decoder = decoder
|
|
39
43
|
@adapter = adapter
|
|
44
|
+
@checkpoint_store = checkpoint_store
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
# Stream CDC::Core-shaped work from PostgreSQL logical replication.
|
|
@@ -67,9 +72,39 @@ module Mammoth
|
|
|
67
72
|
def process_payload(payload, metadata, &block)
|
|
68
73
|
parsed = parse_payload(payload)
|
|
69
74
|
decoded = decode_message(parsed, metadata)
|
|
70
|
-
|
|
75
|
+
process_decoded(decoded, metadata, &block)
|
|
71
76
|
end
|
|
72
77
|
|
|
78
|
+
# rubocop:disable Metrics/MethodLength
|
|
79
|
+
def process_decoded(decoded, metadata, &block)
|
|
80
|
+
return if decoded.nil?
|
|
81
|
+
|
|
82
|
+
if decoded.is_a?(Array)
|
|
83
|
+
decoded.each { |item| process_decoded(item, metadata, &block) }
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if begin_message?(decoded)
|
|
88
|
+
start_transaction_buffer(decoded)
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if commit_message?(decoded)
|
|
93
|
+
emit_transaction_buffer(decoded, metadata, &block)
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
normalize_decoded(decoded).each do |work|
|
|
98
|
+
work = enrich_work_position(work, metadata, decoded)
|
|
99
|
+
if transaction_buffer_active?
|
|
100
|
+
Array(@transaction_events) << work
|
|
101
|
+
else
|
|
102
|
+
block.call(work)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
# rubocop:enable Metrics/MethodLength
|
|
107
|
+
|
|
73
108
|
def parse_payload(payload)
|
|
74
109
|
parser = effective_parser
|
|
75
110
|
return parser.process(payload) if parser.respond_to?(:process)
|
|
@@ -107,20 +142,129 @@ module Mammoth
|
|
|
107
142
|
result.is_a?(Array) ? result.compact : [result].compact
|
|
108
143
|
end
|
|
109
144
|
|
|
145
|
+
def begin_message?(decoded)
|
|
146
|
+
message_kind(decoded).include?("begin")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def commit_message?(decoded)
|
|
150
|
+
message_kind(decoded).include?("commit")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def message_kind(decoded)
|
|
154
|
+
(value_from(decoded, :message_type, :type, :kind) || decoded.class.name.to_s.split("::").last).to_s.downcase
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def start_transaction_buffer(decoded)
|
|
158
|
+
@transaction_events = []
|
|
159
|
+
@transaction_id = value_from(decoded, :transaction_id, :xid, :final_lsn)
|
|
160
|
+
@transaction_metadata = value_hash(decoded, :metadata) || {}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def emit_transaction_buffer(decoded, metadata, &block)
|
|
164
|
+
return unless transaction_buffer_active?
|
|
165
|
+
|
|
166
|
+
block.call(
|
|
167
|
+
TransactionEnvelope.new(
|
|
168
|
+
@transaction_events,
|
|
169
|
+
transaction_id_for(decoded),
|
|
170
|
+
commit_lsn_for(decoded, metadata),
|
|
171
|
+
value_from(decoded, :commit_time, :committed_at, :timestamp),
|
|
172
|
+
@transaction_metadata
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
ensure
|
|
176
|
+
clear_transaction_buffer
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def transaction_id_for(decoded)
|
|
180
|
+
value_from(decoded, :transaction_id, :xid) || @transaction_id || first_event_value(:transaction_id, :xid)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def commit_lsn_for(decoded, metadata)
|
|
184
|
+
value_from(decoded, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
|
|
185
|
+
value_from(metadata, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
|
|
186
|
+
first_event_value(:commit_lsn, :source_position, :lsn)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def transaction_buffer_active?
|
|
190
|
+
!@transaction_events.nil?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def clear_transaction_buffer
|
|
194
|
+
@transaction_events = nil
|
|
195
|
+
@transaction_id = nil
|
|
196
|
+
@transaction_metadata = nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
200
|
+
def enrich_work_position(work, metadata, decoded)
|
|
201
|
+
position = value_from(work, :source_position, :commit_lsn) ||
|
|
202
|
+
value_from(metadata, :source_position, :commit_lsn, :lsn) ||
|
|
203
|
+
value_from(decoded, :source_position, :commit_lsn, :lsn)
|
|
204
|
+
return work unless position
|
|
205
|
+
|
|
206
|
+
work_hash = work.respond_to?(:to_h) ? work.to_h : work
|
|
207
|
+
return work unless work_hash.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
key_style = work_hash.key?("operation") ? :string : :symbol
|
|
210
|
+
source_position_key = key_style == :string ? "source_position" : :source_position
|
|
211
|
+
commit_lsn_key = key_style == :string ? "commit_lsn" : :commit_lsn
|
|
212
|
+
return work if work_hash[source_position_key] || work_hash[commit_lsn_key]
|
|
213
|
+
|
|
214
|
+
work_hash.merge(source_position_key => position, commit_lsn_key => position)
|
|
215
|
+
end
|
|
216
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
217
|
+
|
|
218
|
+
def first_event_value(*keys)
|
|
219
|
+
event = Array(@transaction_events).find do |event|
|
|
220
|
+
keys.any? { |key| value_from(event, key) }
|
|
221
|
+
end
|
|
222
|
+
value_from(event, *keys) if event
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def value_from(object, *keys)
|
|
226
|
+
return nil if object.nil?
|
|
227
|
+
|
|
228
|
+
keys.each do |key|
|
|
229
|
+
return object.public_send(key) if object.respond_to?(key)
|
|
230
|
+
|
|
231
|
+
hash = object.respond_to?(:to_h) ? object.to_h : object
|
|
232
|
+
next unless hash.respond_to?(:key?)
|
|
233
|
+
|
|
234
|
+
return hash[key] if hash.key?(key)
|
|
235
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def value_hash(object, key)
|
|
242
|
+
value = value_from(object, key)
|
|
243
|
+
value.is_a?(Hash) ? value : nil
|
|
244
|
+
end
|
|
245
|
+
|
|
110
246
|
def effective_runner
|
|
111
|
-
runner ||
|
|
247
|
+
runner || @effective_runner || begin
|
|
248
|
+
@effective_runner = build_runner
|
|
249
|
+
end
|
|
112
250
|
end
|
|
113
251
|
|
|
114
252
|
def effective_parser
|
|
115
|
-
parser ||
|
|
253
|
+
parser || @effective_parser || begin
|
|
254
|
+
@effective_parser = build_parser
|
|
255
|
+
end
|
|
116
256
|
end
|
|
117
257
|
|
|
118
258
|
def effective_decoder
|
|
119
|
-
decoder ||
|
|
259
|
+
decoder || @effective_decoder || begin
|
|
260
|
+
@effective_decoder = build_decoder
|
|
261
|
+
end
|
|
120
262
|
end
|
|
121
263
|
|
|
122
264
|
def effective_adapter
|
|
123
|
-
adapter ||
|
|
265
|
+
adapter || @effective_adapter || begin
|
|
266
|
+
@effective_adapter = build_adapter
|
|
267
|
+
end
|
|
124
268
|
end
|
|
125
269
|
|
|
126
270
|
def build_runner
|
|
@@ -152,15 +296,47 @@ module Mammoth
|
|
|
152
296
|
database_url: database_url,
|
|
153
297
|
slot_name: required_config("replication", "slot"),
|
|
154
298
|
publication_names: required_publications,
|
|
155
|
-
start_lsn:
|
|
156
|
-
auto_create_slot:
|
|
157
|
-
temporary_slot:
|
|
299
|
+
start_lsn: replication_start_lsn,
|
|
300
|
+
auto_create_slot: config.dig("replication", "auto_create_slot") == true,
|
|
301
|
+
temporary_slot: config.dig("replication", "temporary_slot") == true
|
|
158
302
|
}.tap do |options|
|
|
159
303
|
feedback_interval = config.dig("replication", "feedback_interval")
|
|
160
304
|
options[:feedback_interval] = feedback_interval unless feedback_interval.nil?
|
|
161
305
|
end
|
|
162
306
|
end
|
|
163
307
|
|
|
308
|
+
def replication_start_lsn
|
|
309
|
+
configured = config.dig("replication", "start_lsn")
|
|
310
|
+
return configured unless blank?(configured)
|
|
311
|
+
|
|
312
|
+
checkpoint_lsn
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def checkpoint_lsn
|
|
316
|
+
return nil unless checkpoint_store
|
|
317
|
+
|
|
318
|
+
row = checkpoint_store.fetch(source_name: source_name, slot_name: required_config("replication", "slot"))
|
|
319
|
+
normalize_lsn(row&.fetch("last_lsn", nil))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def source_name
|
|
323
|
+
required_config("mammoth", "name")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def normalize_lsn(value)
|
|
327
|
+
return nil if blank?(value)
|
|
328
|
+
|
|
329
|
+
lsn = value.to_s
|
|
330
|
+
return lsn if lsn.include?("/")
|
|
331
|
+
return "0/#{lsn.to_i.to_s(16).upcase}" if lsn.match?(/\A\d+\z/)
|
|
332
|
+
|
|
333
|
+
lsn
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def blank?(value)
|
|
337
|
+
value.nil? || value == ""
|
|
338
|
+
end
|
|
339
|
+
|
|
164
340
|
def required_publications
|
|
165
341
|
publications = required_config("replication", "publications")
|
|
166
342
|
unless publications.is_a?(Array) && publications.any? &&
|
|
@@ -198,6 +374,9 @@ module Mammoth
|
|
|
198
374
|
rescue LoadError => e
|
|
199
375
|
raise ReplicationError, "#{gem_name} is required for PostgreSQL CDC source integration: #{e.message}"
|
|
200
376
|
end
|
|
377
|
+
TransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
|
|
378
|
+
private_constant :TransactionEnvelope
|
|
201
379
|
end
|
|
380
|
+
# rubocop:enable Metrics/ClassLength
|
|
202
381
|
end
|
|
203
382
|
end
|
|
@@ -53,3 +53,27 @@ ON dead_letters(namespace, entity);
|
|
|
53
53
|
|
|
54
54
|
CREATE INDEX IF NOT EXISTS idx_dead_letters_failed_at
|
|
55
55
|
ON dead_letters(failed_at);
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS delivered_envelopes (
|
|
59
|
+
id INTEGER PRIMARY KEY,
|
|
60
|
+
idempotency_key TEXT NOT NULL,
|
|
61
|
+
source_name TEXT NOT NULL,
|
|
62
|
+
slot_name TEXT NOT NULL,
|
|
63
|
+
destination_name TEXT NOT NULL,
|
|
64
|
+
delivery_unit TEXT NOT NULL,
|
|
65
|
+
transaction_id TEXT,
|
|
66
|
+
source_position TEXT,
|
|
67
|
+
delivered_at TEXT NOT NULL,
|
|
68
|
+
|
|
69
|
+
UNIQUE (idempotency_key)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source
|
|
73
|
+
ON delivered_envelopes(source_name, slot_name);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_destination
|
|
76
|
+
ON delivered_envelopes(destination_name);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source_position
|
|
79
|
+
ON delivered_envelopes(source_position);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mammoth
|
|
8
|
+
# Serializes CDC transaction envelopes into webhook payloads.
|
|
9
|
+
#
|
|
10
|
+
# Mammoth uses transaction envelopes as the safest delivery and checkpointing
|
|
11
|
+
# boundary for concurrent delivery. A transaction payload preserves the commit
|
|
12
|
+
# position and groups the row-level changes that belong to the same database
|
|
13
|
+
# transaction.
|
|
14
|
+
class TransactionEnvelopeSerializer
|
|
15
|
+
# Default payload type for transaction webhook delivery.
|
|
16
|
+
PAYLOAD_TYPE = "transaction.committed"
|
|
17
|
+
|
|
18
|
+
# Serialize a CDC::Core::TransactionEnvelope-like object into a Hash.
|
|
19
|
+
#
|
|
20
|
+
# @param envelope [#events, #transaction_id] transaction envelope
|
|
21
|
+
# @return [Hash] webhook-ready transaction payload
|
|
22
|
+
def self.call(envelope)
|
|
23
|
+
new(envelope).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param envelope [#events, #transaction_id] transaction envelope
|
|
27
|
+
def initialize(envelope)
|
|
28
|
+
@envelope = envelope
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Return the webhook payload.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] transaction webhook payload
|
|
34
|
+
def call
|
|
35
|
+
event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
|
|
36
|
+
{
|
|
37
|
+
"event_id" => envelope_value("event_id") || SecureRandom.uuid,
|
|
38
|
+
"type" => PAYLOAD_TYPE,
|
|
39
|
+
"source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
|
|
40
|
+
"transaction_id" => envelope.transaction_id,
|
|
41
|
+
"source_position" => source_position(event_payloads),
|
|
42
|
+
"commit_lsn" => source_position(event_payloads),
|
|
43
|
+
"committed_at" => committed_at,
|
|
44
|
+
"event_count" => event_payloads.length,
|
|
45
|
+
"events" => event_payloads,
|
|
46
|
+
"metadata" => envelope_value("metadata") || {}
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return JSON representation of the transaction payload.
|
|
51
|
+
#
|
|
52
|
+
# @return [String] JSON representation
|
|
53
|
+
def to_json(*_args)
|
|
54
|
+
JSON.generate(call)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :envelope
|
|
60
|
+
|
|
61
|
+
def envelope_hash
|
|
62
|
+
@envelope_hash ||= envelope.respond_to?(:to_h) ? stringify_keys(envelope.to_h) : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stringify_keys(hash)
|
|
66
|
+
hash.to_h.transform_keys(&:to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def envelope_value(key)
|
|
70
|
+
return envelope.public_send(key) if envelope.respond_to?(key)
|
|
71
|
+
|
|
72
|
+
envelope_hash[key]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def source_position(event_payloads)
|
|
76
|
+
envelope_value("source_position") || envelope_value("commit_lsn") ||
|
|
77
|
+
first_event_value(event_payloads.reverse, "source_position")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def committed_at
|
|
81
|
+
value = envelope_value("committed_at") || envelope_value("commit_time")
|
|
82
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
83
|
+
|
|
84
|
+
value || Time.now.utc.iso8601
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def first_event_value(event_payloads, key)
|
|
88
|
+
event_payloads.find { |payload| payload[key] }&.fetch(key)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/mammoth/version.rb
CHANGED