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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +93 -10
  4. data/config/mammoth.example.yml +19 -9
  5. data/config/mammoth.schema.json +3 -3
  6. data/lib/mammoth/application.rb +54 -43
  7. data/lib/mammoth/cli.rb +1 -13
  8. data/lib/mammoth/commands/bootstrap_command.rb +11 -7
  9. data/lib/mammoth/commands/dead_letters_command.rb +5 -3
  10. data/lib/mammoth/commands/deliver_sample_command.rb +1 -1
  11. data/lib/mammoth/commands/status_command.rb +5 -5
  12. data/lib/mammoth/concurrent_delivery_runtime.rb +15 -3
  13. data/lib/mammoth/dead_letter_commands.rb +16 -25
  14. data/lib/mammoth/dead_letter_store.rb +1 -1
  15. data/lib/mammoth/delivery_processor.rb +51 -9
  16. data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
  17. data/lib/mammoth/delivery_worker.rb +19 -33
  18. data/lib/mammoth/dispatch_metrics.rb +55 -0
  19. data/lib/mammoth/event_serializer.rb +23 -12
  20. data/lib/mammoth/fanout_delivery_worker.rb +4 -4
  21. data/lib/mammoth/metrics_observer.rb +52 -0
  22. data/lib/mammoth/observability_metrics.rb +71 -0
  23. data/lib/mammoth/observability_server.rb +8 -6
  24. data/lib/mammoth/observability_snapshot.rb +118 -66
  25. data/lib/mammoth/operational_state/adapter.rb +16 -0
  26. data/lib/mammoth/operational_state/registry.rb +8 -0
  27. data/lib/mammoth/operational_state/sqlite_adapter.rb +18 -2
  28. data/lib/mammoth/persisted_payload_deserializer.rb +85 -0
  29. data/lib/mammoth/postgres_observability_metrics.rb +80 -0
  30. data/lib/mammoth/replication_consumer.rb +41 -52
  31. data/lib/mammoth/runtimes/batching_runtime.rb +59 -0
  32. data/lib/mammoth/runtimes/concurrent_adapter.rb +4 -2
  33. data/lib/mammoth/runtimes/inline_adapter.rb +19 -5
  34. data/lib/mammoth/runtimes/registry.rb +3 -2
  35. data/lib/mammoth/sources/postgres.rb +261 -4
  36. data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
  37. data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
  38. data/lib/mammoth/status.rb +22 -18
  39. data/lib/mammoth/transaction_envelope_serializer.rb +13 -15
  40. data/lib/mammoth/version.rb +1 -1
  41. data/lib/mammoth/webhook_sink.rb +2 -2
  42. data/lib/mammoth.rb +12 -1
  43. 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, :sqlite_store, :output
8
+ attr_reader :config, :state_adapter, :output
9
9
 
10
10
  # @param config [Mammoth::Configuration] loaded configuration
11
- # @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
11
+ # @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
12
12
  # @param output [#puts] output stream
13
- def initialize(config, sqlite_store: nil, output: $stdout)
13
+ def initialize(config, state_adapter: nil, output: $stdout)
14
14
  @config = config
15
- @sqlite_store = sqlite_store
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, sqlite_store: sqlite_store, output: output)
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
- def initialize(processor:, concurrency:, timeout:, preserve_order:)
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
- pool.process_many(items)
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 ||= DeadLetterStore.new(SQLiteStore.connect(load_config.dig("sqlite", "path")).bootstrap!)
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
- return worker.deliver_to(destination_name, payload) if worker.respond_to?(:deliver_to)
249
+ event = PersistedPayloadDeserializer.event(payload)
250
+ return worker.deliver_to(destination_name, event) if worker.respond_to?(:deliver_to)
254
251
 
255
- worker.deliver(payload)
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
- DEAD_LETTER_TRANSACTION_ENVELOPE.new(
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 [Hash] normalized event payload
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
- def initialize(delivery_worker:, delivery_unit: :event)
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 [Hash] delivery summary
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 [Hash] delivery summary
65
+ # @return [CDC::Core::ProcessorResult] normalized processor result
63
66
  def process(work)
64
- case delivery_unit
65
- when :transaction
66
- delivery_worker.deliver_transaction(work)
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
- delivery_worker.deliver(work)
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, checkpoint, and dead-letter handling.
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, advance the checkpoint
8
- # after success, and persist the failed event to the dead letter queue after
9
- # retry exhaustion.
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] checkpoint persistence
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, nil] downstream delivery ledger
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:, publication_name:,
30
- max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep),
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 || DeliveredEnvelopeStore.new(checkpoint_store.sqlite_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] checkpoint persistence
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:, sleeper: Kernel.method(:sleep),
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, checkpoint, and DLQ handling.
73
+ # Deliver a transaction envelope with retry, ledger, and DLQ handling.
72
74
  #
73
- # @param envelope [#events, #transaction_id] CDC transaction 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, checkpoint, and DLQ handling.
81
+ # Deliver an event with retry, ledger, and DLQ handling.
80
82
  #
81
- # @param event [Hash, #to_h] normalized 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(payload, idempotency_key:, reason:)
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 an event-like object into a webhook-ready Hash.
18
+ # Serialize a core change event into a webhook-ready Hash.
19
19
  #
20
- # @param event [Hash, #to_h] normalized CDC 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 [Hash, #to_h] normalized CDC event
26
+ # @param event [CDC::Core::ChangeEvent] normalized CDC event
27
27
  def initialize(event)
28
- @event = event.respond_to?(:to_h) ? event.to_h : event
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" => event_hash["event_id"] || SecureRandom.uuid,
38
- "source" => event_hash["source"] || DEFAULT_SOURCE,
40
+ "event_id" => event_id(metadata),
41
+ "source" => source(metadata),
39
42
  "operation" => normalize_operation(event_hash.fetch("operation")),
40
- "namespace" => event_hash["namespace"] || event_hash["schema"],
41
- "entity" => event_hash["entity"] || event_hash["table"],
42
- "identity" => event_hash["identity"] || event_hash["primary_key"],
43
- "source_position" => event_hash["source_position"] || event_hash["commit_lsn"],
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" => event_hash["changes"] || [],
48
- "metadata" => event_hash["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)