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
|
@@ -5,14 +5,14 @@ module Mammoth
|
|
|
5
5
|
module Commands
|
|
6
6
|
# Reusable local command object for status inspection.
|
|
7
7
|
class StatusCommand
|
|
8
|
-
attr_reader :config, :
|
|
8
|
+
attr_reader :config, :state_adapter, :output
|
|
9
9
|
|
|
10
10
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
11
|
-
# @param
|
|
11
|
+
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
12
12
|
# @param output [#puts] output stream
|
|
13
|
-
def initialize(config,
|
|
13
|
+
def initialize(config, state_adapter: nil, output: $stdout)
|
|
14
14
|
@config = config
|
|
15
|
-
@
|
|
15
|
+
@state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
|
|
16
16
|
@output = output
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ module Mammoth
|
|
|
20
20
|
#
|
|
21
21
|
# @return [Integer] process-style status code
|
|
22
22
|
def call
|
|
23
|
-
Status.call(config,
|
|
23
|
+
Status.call(config, state_adapter: state_adapter, output: output)
|
|
24
24
|
0
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -7,17 +7,19 @@ module Mammoth
|
|
|
7
7
|
# I/O fan-out to cdc-concurrent. This class is intentionally small so the
|
|
8
8
|
# runtime boundary remains easy to test and replace.
|
|
9
9
|
class ConcurrentDeliveryRuntime
|
|
10
|
-
attr_reader :processor, :concurrency, :timeout, :preserve_order, :pool
|
|
10
|
+
attr_reader :processor, :concurrency, :timeout, :preserve_order, :pool, :observer
|
|
11
11
|
|
|
12
12
|
# @param processor [#process] delivery processor
|
|
13
13
|
# @param concurrency [Integer] number of concurrent delivery workers
|
|
14
14
|
# @param timeout [Numeric, nil] optional per-item timeout
|
|
15
15
|
# @param preserve_order [Boolean] preserve output order when supported
|
|
16
|
-
|
|
16
|
+
# @param observer [CDC::Core::Observer] dispatch lifecycle observer
|
|
17
|
+
def initialize(processor:, concurrency:, timeout:, preserve_order:, observer: CDC::Core::Observer.new)
|
|
17
18
|
@processor = processor
|
|
18
19
|
@concurrency = concurrency
|
|
19
20
|
@timeout = timeout
|
|
20
21
|
@preserve_order = preserve_order
|
|
22
|
+
@observer = observer
|
|
21
23
|
@pool = build_pool
|
|
22
24
|
end
|
|
23
25
|
|
|
@@ -28,7 +30,10 @@ module Mammoth
|
|
|
28
30
|
def process_many(items)
|
|
29
31
|
return [] if items.empty?
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
items.each { |item| observer.dispatch_started(item) }
|
|
34
|
+
pool.process_many(items).tap do |results|
|
|
35
|
+
results.each { |result| observe_result(result) }
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
# Shutdown the underlying runtime when supported.
|
|
@@ -41,6 +46,13 @@ module Mammoth
|
|
|
41
46
|
|
|
42
47
|
private
|
|
43
48
|
|
|
49
|
+
def observe_result(result)
|
|
50
|
+
return observer.dispatch_succeeded(result) if result.success?
|
|
51
|
+
return observer.dispatch_failed(result) if result.failure?
|
|
52
|
+
|
|
53
|
+
observer.dispatch_skipped(result)
|
|
54
|
+
end
|
|
55
|
+
|
|
44
56
|
def build_pool
|
|
45
57
|
require "cdc/concurrent"
|
|
46
58
|
CDC::Concurrent::ProcessorPool.new(processor:, concurrency:, timeout:, preserve_order:)
|
|
@@ -7,27 +7,19 @@ module Mammoth
|
|
|
7
7
|
# Operator commands for inspecting and replaying dead letters.
|
|
8
8
|
# rubocop:disable Metrics/ClassLength
|
|
9
9
|
class DeadLetterCommands
|
|
10
|
-
# Internal replay envelope used for transaction dead-letter recovery.
|
|
11
|
-
DEAD_LETTER_TRANSACTION_ENVELOPE = Data.define(
|
|
12
|
-
:events,
|
|
13
|
-
:transaction_id,
|
|
14
|
-
:source_position,
|
|
15
|
-
:commit_lsn,
|
|
16
|
-
:committed_at,
|
|
17
|
-
:metadata
|
|
18
|
-
)
|
|
19
|
-
|
|
20
10
|
attr_reader :argv, :lifecycle_hooks
|
|
21
11
|
|
|
22
12
|
# @param argv [Array<String>] command line arguments
|
|
23
|
-
def self.call(argv, lifecycle_hooks: LifecycleHooks.new)
|
|
24
|
-
new(argv, lifecycle_hooks: lifecycle_hooks).call
|
|
13
|
+
def self.call(argv, state_adapter: nil, lifecycle_hooks: LifecycleHooks.new)
|
|
14
|
+
new(argv, state_adapter: state_adapter, lifecycle_hooks: lifecycle_hooks).call
|
|
25
15
|
end
|
|
26
16
|
|
|
27
17
|
# @param argv [Array<String>] command line arguments
|
|
18
|
+
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
28
19
|
# @param lifecycle_hooks [Mammoth::LifecycleHooks, Hash] local lifecycle callbacks
|
|
29
|
-
def initialize(argv, lifecycle_hooks: LifecycleHooks.new)
|
|
20
|
+
def initialize(argv, state_adapter: nil, lifecycle_hooks: LifecycleHooks.new)
|
|
30
21
|
@argv = argv
|
|
22
|
+
@state_adapter = state_adapter
|
|
31
23
|
@lifecycle_hooks = lifecycle_hooks.is_a?(LifecycleHooks) ? lifecycle_hooks : LifecycleHooks.new(lifecycle_hooks)
|
|
32
24
|
end
|
|
33
25
|
|
|
@@ -98,11 +90,15 @@ module Mammoth
|
|
|
98
90
|
end
|
|
99
91
|
|
|
100
92
|
def dead_letter_store
|
|
101
|
-
@dead_letter_store ||=
|
|
93
|
+
@dead_letter_store ||= state_adapter.dead_letter_store
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def state_adapter
|
|
97
|
+
@state_adapter ||= OperationalState::Registry.build_configured(load_config)
|
|
102
98
|
end
|
|
103
99
|
|
|
104
100
|
def worker
|
|
105
|
-
@worker ||= Application.new(load_config).delivery_worker
|
|
101
|
+
@worker ||= Application.new(load_config, state_adapter: state_adapter).delivery_worker
|
|
106
102
|
end
|
|
107
103
|
|
|
108
104
|
def dead_letter_id
|
|
@@ -250,9 +246,10 @@ module Mammoth
|
|
|
250
246
|
end
|
|
251
247
|
|
|
252
248
|
def replay_event(destination_name, payload)
|
|
253
|
-
|
|
249
|
+
event = PersistedPayloadDeserializer.event(payload)
|
|
250
|
+
return worker.deliver_to(destination_name, event) if worker.respond_to?(:deliver_to)
|
|
254
251
|
|
|
255
|
-
worker.deliver(
|
|
252
|
+
worker.deliver(event)
|
|
256
253
|
end
|
|
257
254
|
|
|
258
255
|
def replay_transaction(destination_name, envelope)
|
|
@@ -270,14 +267,7 @@ module Mammoth
|
|
|
270
267
|
end
|
|
271
268
|
|
|
272
269
|
def transaction_envelope(payload)
|
|
273
|
-
|
|
274
|
-
payload.fetch("events"),
|
|
275
|
-
payload.fetch("transaction_id"),
|
|
276
|
-
payload["source_position"],
|
|
277
|
-
payload["commit_lsn"],
|
|
278
|
-
payload["committed_at"],
|
|
279
|
-
payload["metadata"] || {}
|
|
280
|
-
)
|
|
270
|
+
PersistedPayloadDeserializer.transaction(payload)
|
|
281
271
|
end
|
|
282
272
|
|
|
283
273
|
def show_payload(row)
|
|
@@ -315,6 +305,7 @@ module Mammoth
|
|
|
315
305
|
def replay_context(extra = {})
|
|
316
306
|
{
|
|
317
307
|
config: load_config,
|
|
308
|
+
state_adapter: state_adapter,
|
|
318
309
|
dead_letter_store: dead_letter_store,
|
|
319
310
|
delivery_worker: worker
|
|
320
311
|
}.merge(extra)
|
|
@@ -15,7 +15,7 @@ module Mammoth
|
|
|
15
15
|
|
|
16
16
|
# Store a failed delivery.
|
|
17
17
|
#
|
|
18
|
-
# @param event [
|
|
18
|
+
# @param event [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] normalized core work item
|
|
19
19
|
# @param destination_name [String] destination name
|
|
20
20
|
# @param error [Exception, nil] delivery failure
|
|
21
21
|
# @param retry_count [Integer] number of delivery attempts
|
|
@@ -6,7 +6,7 @@ module Mammoth
|
|
|
6
6
|
# The processor keeps cdc-concurrent integration narrow: cdc-concurrent owns
|
|
7
7
|
# I/O-heavy fan-out mechanics, while DeliveryWorker owns Mammoth relay
|
|
8
8
|
# semantics such as retries, dead letters, and checkpoint writes.
|
|
9
|
-
class DeliveryProcessor
|
|
9
|
+
class DeliveryProcessor < CDC::Core::Processor
|
|
10
10
|
@concurrent_safe = false
|
|
11
11
|
|
|
12
12
|
class << self
|
|
@@ -32,13 +32,16 @@ module Mammoth
|
|
|
32
32
|
|
|
33
33
|
concurrent_safe!
|
|
34
34
|
|
|
35
|
-
attr_reader :delivery_worker, :delivery_unit
|
|
35
|
+
attr_reader :delivery_worker, :delivery_unit, :progress_coordinator
|
|
36
36
|
|
|
37
37
|
# @param delivery_worker [Mammoth::DeliveryWorker] relay-aware delivery worker
|
|
38
38
|
# @param delivery_unit [String, Symbol] event or transaction
|
|
39
|
-
|
|
39
|
+
# @param progress_coordinator [Mammoth::DeliveryProgressCoordinator, nil] contiguous progress coordinator
|
|
40
|
+
def initialize(delivery_worker:, delivery_unit: :event, progress_coordinator: nil)
|
|
41
|
+
super()
|
|
40
42
|
@delivery_worker = delivery_worker
|
|
41
43
|
@delivery_unit = delivery_unit.to_sym
|
|
44
|
+
@progress_coordinator = progress_coordinator
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
# @return [Boolean] true when this processor instance is safe for concurrent execution.
|
|
@@ -51,7 +54,7 @@ module Mammoth
|
|
|
51
54
|
# Process one work item from CDC::Concurrent::ProcessorPool.
|
|
52
55
|
#
|
|
53
56
|
# @param work [Object] event or transaction envelope
|
|
54
|
-
# @return [
|
|
57
|
+
# @return [CDC::Core::ProcessorResult] normalized processor result
|
|
55
58
|
def call(work)
|
|
56
59
|
process(work)
|
|
57
60
|
end
|
|
@@ -59,14 +62,53 @@ module Mammoth
|
|
|
59
62
|
# Process one work item using the configured delivery unit.
|
|
60
63
|
#
|
|
61
64
|
# @param work [Object] event or transaction envelope
|
|
62
|
-
# @return [
|
|
65
|
+
# @return [CDC::Core::ProcessorResult] normalized processor result
|
|
63
66
|
def process(work)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
summary = deliver(work)
|
|
68
|
+
progress_coordinator&.complete(work)
|
|
69
|
+
build_result(work, summary)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
failure_result(work, e, retryable: e.is_a?(DeliveryError))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def deliver(work)
|
|
77
|
+
return delivery_worker.deliver_transaction(work) if delivery_unit == :transaction
|
|
78
|
+
|
|
79
|
+
delivery_worker.deliver(work)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_result(work, summary)
|
|
83
|
+
case summary[:status].to_s
|
|
84
|
+
when "skipped"
|
|
85
|
+
CDC::Core::ProcessorResult.skipped(work, metadata: result_metadata(summary))
|
|
86
|
+
when "dead_lettered", "fanout_partial"
|
|
87
|
+
failure_result(work, DeliveryError.new(failure_reason(summary)), retryable: false, summary: summary)
|
|
67
88
|
else
|
|
68
|
-
|
|
89
|
+
CDC::Core::ProcessorResult.success(work, value: summary, metadata: result_metadata(summary))
|
|
69
90
|
end
|
|
70
91
|
end
|
|
92
|
+
|
|
93
|
+
def failure_result(work, error, retryable:, summary: nil)
|
|
94
|
+
CDC::Core::ProcessorResult.failure(
|
|
95
|
+
error,
|
|
96
|
+
event: work,
|
|
97
|
+
reason: error.message,
|
|
98
|
+
retryable: retryable,
|
|
99
|
+
processor: self.class.name,
|
|
100
|
+
metadata: result_metadata(summary)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def failure_reason(summary)
|
|
105
|
+
"delivery completed with #{summary.fetch(:status)} status"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def result_metadata(summary)
|
|
109
|
+
metadata = { processor: self.class.name, delivery_unit: delivery_unit.to_s }
|
|
110
|
+
metadata[:delivery] = summary if summary
|
|
111
|
+
metadata
|
|
112
|
+
end
|
|
71
113
|
end
|
|
72
114
|
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Coordinates durable delivery progress across out-of-order workers.
|
|
5
|
+
#
|
|
6
|
+
# Work is registered in source order before dispatch. Completion may arrive
|
|
7
|
+
# in any order, but a checkpoint and upstream acknowledgement advance only
|
|
8
|
+
# when every item in the oldest closed source group has a durable outcome.
|
|
9
|
+
# A source group is normally one PostgreSQL transaction.
|
|
10
|
+
class DeliveryProgressCoordinator
|
|
11
|
+
# Mutable source transaction tracked by the coordinator.
|
|
12
|
+
class Group
|
|
13
|
+
attr_reader :items
|
|
14
|
+
attr_accessor :closed
|
|
15
|
+
|
|
16
|
+
# @param items [Array<DeliveryProgressCoordinator::Entry>] registered work
|
|
17
|
+
# @param closed [Boolean] whether the source group is complete
|
|
18
|
+
def initialize(items:, closed:)
|
|
19
|
+
@items = items
|
|
20
|
+
@closed = closed
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Mutable completion state for one registered work item.
|
|
25
|
+
class Entry
|
|
26
|
+
attr_reader :work, :source_position
|
|
27
|
+
attr_accessor :completed
|
|
28
|
+
|
|
29
|
+
# @param work [Object] registered work
|
|
30
|
+
# @param source_position [String, Integer, nil] upstream position
|
|
31
|
+
# @param completed [Boolean] whether a durable outcome was recorded
|
|
32
|
+
def initialize(work:, source_position:, completed:)
|
|
33
|
+
@work = work
|
|
34
|
+
@source_position = source_position
|
|
35
|
+
@completed = completed
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :checkpoint_store, :source_name, :slot_name, :publication_name
|
|
40
|
+
|
|
41
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] durable checkpoint persistence
|
|
42
|
+
# @param source_name [String] logical source name
|
|
43
|
+
# @param slot_name [String] PostgreSQL replication slot name
|
|
44
|
+
# @param publication_name [String] publication name
|
|
45
|
+
# @param acknowledger [#call, nil] upstream durable-progress acknowledgement
|
|
46
|
+
# @param position_resolver [#call, nil] source-owned durable position resolver
|
|
47
|
+
def initialize(checkpoint_store:, source_name:, slot_name:, publication_name:, acknowledger: nil,
|
|
48
|
+
position_resolver: nil)
|
|
49
|
+
@checkpoint_store = checkpoint_store
|
|
50
|
+
@source_name = source_name
|
|
51
|
+
@slot_name = slot_name
|
|
52
|
+
@publication_name = publication_name
|
|
53
|
+
@acknowledger = acknowledger
|
|
54
|
+
@position_resolver = position_resolver
|
|
55
|
+
# rubocop:disable Layout/LeadingCommentSpace -- Steep inline type syntax requires `#:`.
|
|
56
|
+
@groups = [] #: Array[Group]
|
|
57
|
+
@entries_by_work = {} #: Hash[untyped, Array[Entry]]
|
|
58
|
+
# rubocop:enable Layout/LeadingCommentSpace
|
|
59
|
+
@entries_by_work.compare_by_identity
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Register work before dispatch.
|
|
64
|
+
#
|
|
65
|
+
# @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] work item
|
|
66
|
+
# @param group_end [Boolean] whether this is the final item in its source group
|
|
67
|
+
# @return [void]
|
|
68
|
+
def register(work, group_end:)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
group = current_group
|
|
71
|
+
entry = Entry.new(work: work, source_position: source_position(work), completed: false)
|
|
72
|
+
group.items << entry
|
|
73
|
+
(@entries_by_work[work] ||= []) << entry # steep:ignore
|
|
74
|
+
group.closed = true if group_end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Mark a work item durably resolved and advance all newly contiguous groups.
|
|
80
|
+
#
|
|
81
|
+
# @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] work item
|
|
82
|
+
# @return [String, nil] latest source position advanced by this call
|
|
83
|
+
def complete(work)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
entry = pending_entry_for(work)
|
|
86
|
+
raise ReplicationError, "delivery progress completed before registration" unless entry
|
|
87
|
+
|
|
88
|
+
entry.completed = true
|
|
89
|
+
advance_contiguous_groups
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Close and advance a final source group after a clean end-of-stream.
|
|
94
|
+
#
|
|
95
|
+
# @return [String, nil] latest source position advanced by this call
|
|
96
|
+
def finalize
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@groups.last.closed = true if @groups.last
|
|
99
|
+
advance_contiguous_groups
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def current_group
|
|
106
|
+
return @groups.last if @groups.last && !@groups.last.closed
|
|
107
|
+
|
|
108
|
+
Group.new(items: [], closed: false).tap { |group| @groups << group }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pending_entry_for(work)
|
|
112
|
+
@entries_by_work.fetch(work, []).find { |entry| !entry.completed } # steep:ignore
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def advance_contiguous_groups
|
|
116
|
+
advanced_position = nil
|
|
117
|
+
|
|
118
|
+
while (group = @groups.first) && group.closed && group.items.all?(&:completed)
|
|
119
|
+
position = group.items.reverse_each.lazy.map(&:source_position).find { |value| !value.nil? }
|
|
120
|
+
persist_and_acknowledge(position) if position
|
|
121
|
+
advanced_position = position || advanced_position
|
|
122
|
+
remove_group(group)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
advanced_position
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def persist_and_acknowledge(position)
|
|
129
|
+
checkpoint_store.write(
|
|
130
|
+
source_name: source_name,
|
|
131
|
+
slot_name: slot_name,
|
|
132
|
+
publication_name: publication_name,
|
|
133
|
+
last_lsn: position
|
|
134
|
+
)
|
|
135
|
+
@acknowledger&.call(position)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_group(group)
|
|
139
|
+
@groups.shift
|
|
140
|
+
group.items.each do |entry|
|
|
141
|
+
entries = @entries_by_work[entry.work]
|
|
142
|
+
entries.delete(entry)
|
|
143
|
+
@entries_by_work.delete(entry.work) if entries.empty?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def source_position(work)
|
|
148
|
+
return @position_resolver.call(work) if @position_resolver
|
|
149
|
+
|
|
150
|
+
work.commit_lsn if work.respond_to?(:commit_lsn)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mammoth
|
|
4
|
-
# Delivers normalized events with retry,
|
|
4
|
+
# Delivers normalized events with retry, ledger, and dead-letter handling.
|
|
5
5
|
#
|
|
6
6
|
# DeliveryWorker is Mammoth's first reliable delivery unit. It intentionally keeps
|
|
7
|
-
# the delivery contract small: attempt webhook delivery,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# the delivery contract small: attempt webhook delivery, record idempotent
|
|
8
|
+
# success, and persist exhausted failures to the dead letter queue. Contiguous
|
|
9
|
+
# checkpointing is owned by DeliveryProgressCoordinator.
|
|
10
10
|
class DeliveryWorker
|
|
11
11
|
# Default source name used when an event does not provide one.
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
@@ -15,9 +15,9 @@ module Mammoth
|
|
|
15
15
|
:sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
18
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
19
19
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
20
|
-
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore
|
|
20
|
+
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
21
21
|
# @param source_name [String] logical source name
|
|
22
22
|
# @param slot_name [String] replication slot name
|
|
23
23
|
# @param publication_name [String] publication name
|
|
@@ -26,13 +26,13 @@ module Mammoth
|
|
|
26
26
|
# @param sleeper [#call] sleep strategy, injectable for tests
|
|
27
27
|
# @param route_filter [Mammoth::RouteFilter, nil] optional destination route matcher
|
|
28
28
|
# @param enabled [Boolean] whether this destination accepts new deliveries
|
|
29
|
-
def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:,
|
|
30
|
-
max_attempts:, retry_schedule:,
|
|
29
|
+
def initialize(sink:, checkpoint_store:, dead_letter_store:, delivered_envelope_store:, source_name:, slot_name:,
|
|
30
|
+
publication_name:, max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep),
|
|
31
31
|
route_filter: nil, enabled: true)
|
|
32
32
|
@sink = sink
|
|
33
33
|
@checkpoint_store = checkpoint_store
|
|
34
34
|
@dead_letter_store = dead_letter_store
|
|
35
|
-
@delivered_envelope_store = delivered_envelope_store
|
|
35
|
+
@delivered_envelope_store = delivered_envelope_store
|
|
36
36
|
@source_name = source_name
|
|
37
37
|
@slot_name = slot_name
|
|
38
38
|
@publication_name = publication_name
|
|
@@ -47,16 +47,18 @@ module Mammoth
|
|
|
47
47
|
#
|
|
48
48
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
49
49
|
# @param sink [#deliver] destination sink
|
|
50
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
50
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
51
51
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
52
|
+
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
52
53
|
# @param sleeper [#call] sleep strategy
|
|
53
54
|
# @return [Mammoth::DeliveryWorker]
|
|
54
|
-
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:,
|
|
55
|
-
delivery_policy: {})
|
|
55
|
+
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, delivered_envelope_store:,
|
|
56
|
+
sleeper: Kernel.method(:sleep), delivery_policy: {})
|
|
56
57
|
new(
|
|
57
58
|
sink: sink,
|
|
58
59
|
checkpoint_store: checkpoint_store,
|
|
59
60
|
dead_letter_store: dead_letter_store,
|
|
61
|
+
delivered_envelope_store: delivered_envelope_store,
|
|
60
62
|
source_name: config.dig("mammoth", "name"),
|
|
61
63
|
slot_name: config.dig("replication", "slot"),
|
|
62
64
|
publication_name: Array(config.dig("replication", "publications")).join(","),
|
|
@@ -68,17 +70,17 @@ module Mammoth
|
|
|
68
70
|
)
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
# Deliver a transaction envelope with retry,
|
|
73
|
+
# Deliver a transaction envelope with retry, ledger, and DLQ handling.
|
|
72
74
|
#
|
|
73
|
-
# @param envelope [
|
|
75
|
+
# @param envelope [CDC::Core::TransactionEnvelope] CDC transaction envelope
|
|
74
76
|
# @return [Hash] delivery summary
|
|
75
77
|
def deliver_transaction(envelope)
|
|
76
78
|
deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
# Deliver an event with retry,
|
|
81
|
+
# Deliver an event with retry, ledger, and DLQ handling.
|
|
80
82
|
#
|
|
81
|
-
# @param event [
|
|
83
|
+
# @param event [CDC::Core::ChangeEvent] normalized event
|
|
82
84
|
# @return [Hash] delivery summary
|
|
83
85
|
def deliver(event)
|
|
84
86
|
deliver_work(event, serializer: EventSerializer, delivery_method: :deliver)
|
|
@@ -97,7 +99,6 @@ module Mammoth
|
|
|
97
99
|
return skip_result if skip_result
|
|
98
100
|
|
|
99
101
|
if delivered_envelope_store.delivered?(idempotency_key)
|
|
100
|
-
checkpoint_payload(payload)
|
|
101
102
|
return {
|
|
102
103
|
status: "skipped",
|
|
103
104
|
duplicate: true,
|
|
@@ -119,7 +120,6 @@ module Mammoth
|
|
|
119
120
|
transaction_id: payload["transaction_id"],
|
|
120
121
|
source_position: payload["source_position"]
|
|
121
122
|
)
|
|
122
|
-
checkpoint_payload(payload)
|
|
123
123
|
result.merge(attempts: attempts, idempotency_key: idempotency_key)
|
|
124
124
|
rescue DeliveryError => e
|
|
125
125
|
return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
|
|
@@ -130,19 +130,6 @@ module Mammoth
|
|
|
130
130
|
end
|
|
131
131
|
# rubocop:enable Metrics/MethodLength
|
|
132
132
|
|
|
133
|
-
def checkpoint(work, serializer:)
|
|
134
|
-
checkpoint_payload(serializer.call(work))
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def checkpoint_payload(payload)
|
|
138
|
-
checkpoint_store.write(
|
|
139
|
-
source_name: source_name,
|
|
140
|
-
slot_name: slot_name,
|
|
141
|
-
publication_name: publication_name,
|
|
142
|
-
last_lsn: payload["source_position"]
|
|
143
|
-
)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
133
|
def idempotency_key_for(payload:, delivery_unit:)
|
|
147
134
|
[
|
|
148
135
|
source_name,
|
|
@@ -168,8 +155,7 @@ module Mammoth
|
|
|
168
155
|
skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
|
|
169
156
|
end
|
|
170
157
|
|
|
171
|
-
def skipped(
|
|
172
|
-
checkpoint_payload(payload)
|
|
158
|
+
def skipped(_payload, idempotency_key:, reason:)
|
|
173
159
|
{
|
|
174
160
|
status: "skipped",
|
|
175
161
|
reason: reason,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Thread-safe in-process counters populated by Mammoth's CDC core observer.
|
|
5
|
+
#
|
|
6
|
+
# The registry deliberately stores CDC core metric names and tags. Rendering
|
|
7
|
+
# those counters for a specific backend remains an observability concern.
|
|
8
|
+
class DispatchMetrics
|
|
9
|
+
# Create an empty dispatch metric registry.
|
|
10
|
+
def initialize
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@counters = Hash.new(0)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Process-wide dispatch metrics used by the default observer and snapshot.
|
|
16
|
+
INSTANCE = new
|
|
17
|
+
|
|
18
|
+
# Increment a canonical CDC core metric.
|
|
19
|
+
#
|
|
20
|
+
# @param name [String] canonical CDC core metric name
|
|
21
|
+
# @param tags [Hash] canonical metric tags
|
|
22
|
+
# @return [Integer] updated counter value
|
|
23
|
+
def increment(name, tags = {})
|
|
24
|
+
key = [name.to_s.freeze, normalized_tags(tags)]
|
|
25
|
+
@mutex.synchronize { @counters[key] += 1 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return an immutable point-in-time copy of all counters.
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<Hash>] metric entries with name, tags, and value
|
|
31
|
+
def snapshot
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@counters.map do |(name, tags), value|
|
|
34
|
+
{ name: name, tags: tags.to_h, value: value }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Clear all counters.
|
|
40
|
+
#
|
|
41
|
+
# Intended for process lifecycle management and isolated tests.
|
|
42
|
+
#
|
|
43
|
+
# @return [self] cleared registry
|
|
44
|
+
def reset!
|
|
45
|
+
@mutex.synchronize { @counters.clear }
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def normalized_tags(tags)
|
|
52
|
+
tags.to_h.transform_keys(&:to_s).sort_by(&:first).freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -15,17 +15,19 @@ module Mammoth
|
|
|
15
15
|
# Default source label used in serialized webhook payloads.
|
|
16
16
|
DEFAULT_SOURCE = "postgresql"
|
|
17
17
|
|
|
18
|
-
# Serialize
|
|
18
|
+
# Serialize a core change event into a webhook-ready Hash.
|
|
19
19
|
#
|
|
20
|
-
# @param event [
|
|
20
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
21
21
|
# @return [Hash] webhook payload
|
|
22
22
|
def self.call(event)
|
|
23
23
|
new(event).call
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# @param event [
|
|
26
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
27
27
|
def initialize(event)
|
|
28
|
-
|
|
28
|
+
raise ArgumentError, "event must be a CDC::Core::ChangeEvent" unless event.is_a?(CDC::Core::ChangeEvent)
|
|
29
|
+
|
|
30
|
+
@event = event.to_h
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# Return the webhook payload.
|
|
@@ -33,19 +35,20 @@ module Mammoth
|
|
|
33
35
|
# @return [Hash] webhook payload
|
|
34
36
|
def call
|
|
35
37
|
event_hash = stringify_keys(@event)
|
|
38
|
+
metadata = stringify_keys(event_hash["metadata"] || {})
|
|
36
39
|
{
|
|
37
|
-
"event_id" =>
|
|
38
|
-
"source" =>
|
|
40
|
+
"event_id" => event_id(metadata),
|
|
41
|
+
"source" => source(metadata),
|
|
39
42
|
"operation" => normalize_operation(event_hash.fetch("operation")),
|
|
40
|
-
"namespace" => event_hash["
|
|
41
|
-
"entity" => event_hash["
|
|
42
|
-
"identity" => event_hash["
|
|
43
|
-
"source_position" => event_hash["
|
|
43
|
+
"namespace" => event_hash["schema"],
|
|
44
|
+
"entity" => event_hash["table"],
|
|
45
|
+
"identity" => event_hash["primary_key"],
|
|
46
|
+
"source_position" => event_hash["commit_lsn"],
|
|
44
47
|
"transaction_id" => event_hash["transaction_id"],
|
|
45
48
|
"occurred_at" => occurred_at(event_hash),
|
|
46
49
|
"data" => event_data(event_hash),
|
|
47
|
-
"changes" =>
|
|
48
|
-
"metadata" =>
|
|
50
|
+
"changes" => metadata["changes"] || [],
|
|
51
|
+
"metadata" => metadata
|
|
49
52
|
}
|
|
50
53
|
end
|
|
51
54
|
|
|
@@ -66,6 +69,14 @@ module Mammoth
|
|
|
66
69
|
operation.to_s
|
|
67
70
|
end
|
|
68
71
|
|
|
72
|
+
def event_id(metadata)
|
|
73
|
+
metadata["event_id"] || SecureRandom.uuid
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def source(metadata)
|
|
77
|
+
metadata["source"] || DEFAULT_SOURCE
|
|
78
|
+
end
|
|
79
|
+
|
|
69
80
|
def occurred_at(event_hash)
|
|
70
81
|
value = event_hash["occurred_at"]
|
|
71
82
|
return value.iso8601 if value.respond_to?(:iso8601)
|