mammoth 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,7 +15,7 @@ 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
20
  # @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
21
21
  # @param source_name [String] logical source name
@@ -47,7 +47,7 @@ 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
52
  # @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
53
53
  # @param sleeper [#call] sleep strategy
@@ -70,7 +70,7 @@ module Mammoth
70
70
  )
71
71
  end
72
72
 
73
- # Deliver a transaction envelope with retry, checkpoint, and DLQ handling.
73
+ # Deliver a transaction envelope with retry, ledger, and DLQ handling.
74
74
  #
75
75
  # @param envelope [CDC::Core::TransactionEnvelope] CDC transaction envelope
76
76
  # @return [Hash] delivery summary
@@ -78,7 +78,7 @@ module Mammoth
78
78
  deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
79
79
  end
80
80
 
81
- # Deliver an event with retry, checkpoint, and DLQ handling.
81
+ # Deliver an event with retry, ledger, and DLQ handling.
82
82
  #
83
83
  # @param event [CDC::Core::ChangeEvent] normalized event
84
84
  # @return [Hash] delivery summary
@@ -99,7 +99,6 @@ module Mammoth
99
99
  return skip_result if skip_result
100
100
 
101
101
  if delivered_envelope_store.delivered?(idempotency_key)
102
- checkpoint_payload(payload)
103
102
  return {
104
103
  status: "skipped",
105
104
  duplicate: true,
@@ -121,7 +120,6 @@ module Mammoth
121
120
  transaction_id: payload["transaction_id"],
122
121
  source_position: payload["source_position"]
123
122
  )
124
- checkpoint_payload(payload)
125
123
  result.merge(attempts: attempts, idempotency_key: idempotency_key)
126
124
  rescue DeliveryError => e
127
125
  return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
@@ -132,19 +130,6 @@ module Mammoth
132
130
  end
133
131
  # rubocop:enable Metrics/MethodLength
134
132
 
135
- def checkpoint(work, serializer:)
136
- checkpoint_payload(serializer.call(work))
137
- end
138
-
139
- def checkpoint_payload(payload)
140
- checkpoint_store.write(
141
- source_name: source_name,
142
- slot_name: slot_name,
143
- publication_name: publication_name,
144
- last_lsn: payload["source_position"]
145
- )
146
- end
147
-
148
133
  def idempotency_key_for(payload:, delivery_unit:)
149
134
  [
150
135
  source_name,
@@ -170,8 +155,7 @@ module Mammoth
170
155
  skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
171
156
  end
172
157
 
173
- def skipped(payload, idempotency_key:, reason:)
174
- checkpoint_payload(payload)
158
+ def skipped(_payload, idempotency_key:, reason:)
175
159
  {
176
160
  status: "skipped",
177
161
  reason: reason,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
4
+ require "digest"
5
5
  require "time"
6
6
 
7
7
  module Mammoth
@@ -27,17 +27,17 @@ module Mammoth
27
27
  def initialize(event)
28
28
  raise ArgumentError, "event must be a CDC::Core::ChangeEvent" unless event.is_a?(CDC::Core::ChangeEvent)
29
29
 
30
- @event = event.to_h
30
+ @event = event
31
31
  end
32
32
 
33
33
  # Return the webhook payload.
34
34
  #
35
35
  # @return [Hash] webhook payload
36
36
  def call
37
- event_hash = stringify_keys(@event)
37
+ event_hash = stringify_keys(@event.to_h)
38
38
  metadata = stringify_keys(event_hash["metadata"] || {})
39
39
  {
40
- "event_id" => event_id(metadata),
40
+ "event_id" => event_id(event_hash, metadata),
41
41
  "source" => source(metadata),
42
42
  "operation" => normalize_operation(event_hash.fetch("operation")),
43
43
  "namespace" => event_hash["schema"],
@@ -47,7 +47,7 @@ module Mammoth
47
47
  "transaction_id" => event_hash["transaction_id"],
48
48
  "occurred_at" => occurred_at(event_hash),
49
49
  "data" => event_data(event_hash),
50
- "changes" => metadata["changes"] || [],
50
+ "changes" => serialized_changes(event_hash, metadata),
51
51
  "metadata" => metadata
52
52
  }
53
53
  end
@@ -69,8 +69,25 @@ module Mammoth
69
69
  operation.to_s
70
70
  end
71
71
 
72
- def event_id(metadata)
73
- metadata["event_id"] || SecureRandom.uuid
72
+ def event_id(event_hash, metadata)
73
+ metadata["event_id"] || deterministic_event_id(event_hash, metadata)
74
+ end
75
+
76
+ def deterministic_event_id(event_hash, metadata)
77
+ identity = {
78
+ source: source(metadata),
79
+ transaction_id: event_hash["transaction_id"],
80
+ source_position: event_hash["commit_lsn"],
81
+ sequence_number: event_hash["sequence_number"],
82
+ occurred_at: event_hash["occurred_at"],
83
+ namespace: event_hash["schema"],
84
+ entity: event_hash["table"],
85
+ operation: normalize_operation(event_hash.fetch("operation")),
86
+ primary_key: event_hash["primary_key"],
87
+ old_values: event_hash["old_values"],
88
+ new_values: event_hash["new_values"]
89
+ }
90
+ "evt_#{Digest::SHA256.hexdigest(JSON.generate(identity))}"
74
91
  end
75
92
 
76
93
  def source(metadata)
@@ -87,5 +104,20 @@ module Mammoth
87
104
  def event_data(event_hash)
88
105
  event_hash["data"] || event_hash["new_values"] || event_hash["old_values"] || {}
89
106
  end
107
+
108
+ def serialized_changes(event_hash, metadata)
109
+ return metadata["changes"] || [] if metadata.key?("changes")
110
+ return [] unless changes_available?(event_hash)
111
+
112
+ @event.changes.map(&:to_h)
113
+ end
114
+
115
+ def changes_available?(event_hash)
116
+ return true unless normalize_operation(event_hash.fetch("operation")) == "update"
117
+
118
+ old_values = event_hash["old_values"]
119
+ new_values = event_hash["new_values"]
120
+ old_values.is_a?(Hash) && new_values.is_a?(Hash) && old_values.keys.sort == new_values.keys.sort
121
+ end
90
122
  end
91
123
  end
@@ -3,6 +3,8 @@
3
3
  module Mammoth
4
4
  # Prometheus formatting helpers for operational and dispatch metrics.
5
5
  module ObservabilityMetrics
6
+ include PostgresObservabilityMetrics
7
+
6
8
  # Maps canonical CDC core metric names to Mammoth's Prometheus counters.
7
9
  DISPATCH_METRIC_NAMES = {
8
10
  CDC::Core::Observer.started_metric_name => "mammoth_dispatch_started_total",
@@ -14,7 +16,7 @@ module Mammoth
14
16
  private
15
17
 
16
18
  def metric_headers
17
- operational_metric_headers + dispatch_metric_headers
19
+ operational_metric_headers + dispatch_metric_headers + postgres_metric_headers
18
20
  end
19
21
 
20
22
  def operational_metric_headers
@@ -15,18 +15,20 @@ module Mammoth
15
15
  # Default TCP port for the observability server.
16
16
  DEFAULT_PORT = 9393
17
17
 
18
- attr_reader :config, :host, :port, :state_adapter, :logger, :server
18
+ attr_reader :config, :host, :port, :state_adapter, :slot_health_provider, :logger, :server
19
19
 
20
20
  # @param config [Mammoth::Configuration] loaded configuration
21
21
  # @param host [String, nil] bind host override
22
22
  # @param port [Integer, nil] bind port override
23
23
  # @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
24
+ # @param slot_health_provider [#slot_health, nil] PostgreSQL slot health dependency
24
25
  # @param logger [WEBrick::Log, nil] optional WEBrick logger
25
- def initialize(config, host: nil, port: nil, state_adapter: nil, logger: nil)
26
+ def initialize(config, host: nil, port: nil, state_adapter: nil, slot_health_provider: nil, logger: nil)
26
27
  @config = config
27
28
  @host = host || config.dig("observability", "host") || DEFAULT_HOST
28
29
  @port = port || config.dig("observability", "port") || DEFAULT_PORT
29
30
  @state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
31
+ @slot_health_provider = slot_health_provider || Sources::Postgres.new(config)
30
32
  @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
31
33
  @server = build_server
32
34
  mount_endpoints
@@ -81,7 +83,7 @@ module Mammoth
81
83
  end
82
84
 
83
85
  def snapshot
84
- ObservabilitySnapshot.new(config, state_adapter: state_adapter)
86
+ ObservabilitySnapshot.new(config, state_adapter:, slot_health_provider:)
85
87
  end
86
88
  end
87
89
  end
@@ -3,27 +3,30 @@
3
3
  require "time"
4
4
 
5
5
  module Mammoth
6
- # Builds health, readiness, and metrics snapshots from Mammoth's local
7
- # operational state.
6
+ # Builds health, readiness, and metrics snapshots from Mammoth's operational
7
+ # state and optional PostgreSQL slot-health provider.
8
8
  #
9
9
  # ObservabilitySnapshot is intentionally read-only. It does not start the
10
- # relay, mutate checkpoints, replay dead letters, or inspect PostgreSQL. The
11
- # health and metrics endpoints use this object to expose Mammoth process and
12
- # operational-state adapter status in a predictable format.
13
- class ObservabilitySnapshot
10
+ # relay, mutate checkpoints, replay dead letters, or change PostgreSQL slot
11
+ # state. An injected provider may perform read-only slot inspection. The
12
+ # endpoints expose process, operational-state, and source status predictably.
13
+ class ObservabilitySnapshot # rubocop:disable Metrics/ClassLength
14
14
  include ObservabilityMetrics
15
15
 
16
- attr_reader :config, :state_adapter, :clock, :dispatch_metrics
16
+ attr_reader :config, :state_adapter, :clock, :dispatch_metrics, :slot_health_provider
17
17
 
18
18
  # @param config [Mammoth::Configuration] loaded configuration
19
19
  # @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
20
20
  # @param clock [#call] time source returning a Time-like object
21
21
  # @param dispatch_metrics [Mammoth::DispatchMetrics] dispatch counter registry
22
- def initialize(config, state_adapter: nil, clock: -> { Time.now.utc }, dispatch_metrics: DispatchMetrics::INSTANCE)
22
+ # @param slot_health_provider [#slot_health, nil] PostgreSQL slot health dependency
23
+ def initialize(config, state_adapter: nil, clock: -> { Time.now.utc }, dispatch_metrics: DispatchMetrics::INSTANCE,
24
+ slot_health_provider: nil)
23
25
  @config = config
24
26
  @state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
25
27
  @clock = clock
26
28
  @dispatch_metrics = dispatch_metrics
29
+ @slot_health_provider = slot_health_provider
27
30
  end
28
31
 
29
32
  # Build a liveness response.
@@ -45,7 +48,7 @@ module Mammoth
45
48
  def readiness
46
49
  return unready_payload unless state_adapter.ready?
47
50
 
48
- {
51
+ payload = {
49
52
  status: "ready",
50
53
  service: "mammoth",
51
54
  name: mammoth_name,
@@ -54,6 +57,14 @@ module Mammoth
54
57
  summary: state_summary,
55
58
  checked_at: checked_at
56
59
  }
60
+ return payload unless slot_health_provider
61
+
62
+ health = postgres_slot_health
63
+ return postgres_unready_payload(health) unless health.ready?
64
+
65
+ payload.merge(postgres_slot: health.summary)
66
+ rescue ReplicationError => e
67
+ postgres_error_payload(e)
57
68
  rescue Mammoth::Error => e
58
69
  adapter_error_payload(e)
59
70
  end
@@ -71,7 +82,7 @@ module Mammoth
71
82
  ) + destination_metric_lines(
72
83
  dead_letter_store: state_adapter.dead_letter_store,
73
84
  delivered_store: state_adapter.delivered_envelope_store
74
- ) + dispatch_metric_lines
85
+ ) + dispatch_metric_lines + postgres_slot_metric_lines
75
86
  "#{lines.join("\n")}\n"
76
87
  rescue Mammoth::Error
77
88
  down_metrics
@@ -92,6 +103,36 @@ module Mammoth
92
103
  }
93
104
  end
94
105
 
106
+ def postgres_unready_payload(health)
107
+ {
108
+ status: "unready",
109
+ service: "mammoth",
110
+ name: mammoth_name,
111
+ operational_state: "ok",
112
+ adapter: state_summary.fetch(:adapter),
113
+ summary: state_summary,
114
+ postgres_slot: health.summary,
115
+ checked_at: checked_at
116
+ }
117
+ end
118
+
119
+ def postgres_error_payload(error)
120
+ {
121
+ status: "unready",
122
+ service: "mammoth",
123
+ name: mammoth_name,
124
+ operational_state: "ok",
125
+ adapter: configured_adapter_name,
126
+ postgres_slot: {
127
+ ready: false,
128
+ reason: "inspection failed",
129
+ error_class: error.class.name,
130
+ error_message: error.message
131
+ },
132
+ checked_at: checked_at
133
+ }
134
+ end
135
+
95
136
  def aggregate_metric_lines(checkpoint_store:, dead_letter_store:, delivered_store:)
96
137
  [
97
138
  metric_line("mammoth_up", 1),
@@ -136,6 +177,18 @@ module Mammoth
136
177
  "#{(metric_headers + [metric_line("mammoth_up", 0)] + dispatch_metric_lines).join("\n")}\n"
137
178
  end
138
179
 
180
+ def postgres_slot_metric_lines
181
+ return [] unless slot_health_provider
182
+
183
+ postgres_metric_lines(postgres_slot_health)
184
+ rescue ReplicationError
185
+ postgres_inspection_error_metric_lines
186
+ end
187
+
188
+ def postgres_slot_health
189
+ slot_health_provider.slot_health
190
+ end
191
+
139
192
  def state_summary
140
193
  @state_summary ||= state_adapter.summary
141
194
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # PostgreSQL slot-specific Prometheus formatting helpers.
5
+ module PostgresObservabilityMetrics
6
+ # Metric names and help text for PostgreSQL replication-slot gauges.
7
+ POSTGRES_METRICS = {
8
+ "mammoth_postgres_slot_inspection_up" => "1 when PostgreSQL slot inspection succeeds.",
9
+ "mammoth_postgres_slot_present" => "1 when the configured replication slot exists.",
10
+ "mammoth_postgres_slot_ready" => "1 when the configured slot is healthy and active.",
11
+ "mammoth_postgres_slot_active" => "1 when PostgreSQL reports the slot as active.",
12
+ "mammoth_postgres_slot_retained_wal_bytes" => "WAL retained from the slot restart LSN.",
13
+ "mammoth_postgres_slot_safe_wal_size_bytes" => "WAL bytes that may be written before the slot risks loss.",
14
+ "mammoth_postgres_slot_wal_status" => "Current PostgreSQL WAL retention status for the slot.",
15
+ "mammoth_postgres_slot_invalidated" => "1 when PostgreSQL reports invalidation or conflict.",
16
+ "mammoth_postgres_slot_inactive_since_timestamp_seconds" => "Unix timestamp when the slot became inactive.",
17
+ "mammoth_postgres_slot_restart_lsn_bytes" => "Numeric PostgreSQL restart LSN.",
18
+ "mammoth_postgres_slot_confirmed_flush_lsn_bytes" => "Numeric PostgreSQL confirmed flush LSN."
19
+ }.freeze
20
+
21
+ private
22
+
23
+ def postgres_metric_headers
24
+ POSTGRES_METRICS.flat_map { |name, help| ["# HELP #{name} #{help}", "# TYPE #{name} gauge"] }
25
+ end
26
+
27
+ def postgres_metric_lines(health)
28
+ labels = { slot_name: health.slot_name }
29
+ lines = postgres_presence_metric_lines(health, labels:)
30
+ return lines unless health.present
31
+
32
+ lines + postgres_status_metric_lines(health, labels:) + postgres_optional_metric_lines(health, labels:)
33
+ end
34
+
35
+ def postgres_presence_metric_lines(health, labels:)
36
+ [
37
+ metric_line("mammoth_postgres_slot_inspection_up", 1, labels:),
38
+ metric_line("mammoth_postgres_slot_present", health.present ? 1 : 0, labels:)
39
+ ]
40
+ end
41
+
42
+ def postgres_status_metric_lines(health, labels:)
43
+ [
44
+ metric_line("mammoth_postgres_slot_ready", health.ready? ? 1 : 0, labels:),
45
+ metric_line("mammoth_postgres_slot_active", health.active ? 1 : 0, labels:),
46
+ metric_line("mammoth_postgres_slot_wal_status", 1,
47
+ labels: labels.merge(wal_status: health.wal_status || "unknown")),
48
+ metric_line("mammoth_postgres_slot_invalidated", invalidated?(health) ? 1 : 0, labels:)
49
+ ]
50
+ end
51
+
52
+ def postgres_optional_metric_lines(health, labels:)
53
+ {
54
+ "mammoth_postgres_slot_retained_wal_bytes" => health.retained_wal_bytes,
55
+ "mammoth_postgres_slot_safe_wal_size_bytes" => health.safe_wal_size,
56
+ "mammoth_postgres_slot_inactive_since_timestamp_seconds" => timestamp_seconds(health.inactive_since),
57
+ "mammoth_postgres_slot_restart_lsn_bytes" => health.restart_lsn_bytes,
58
+ "mammoth_postgres_slot_confirmed_flush_lsn_bytes" => health.confirmed_flush_lsn_bytes
59
+ }.filter_map { |name, value| metric_line(name, value, labels:) unless value.nil? }
60
+ end
61
+
62
+ def postgres_inspection_error_metric_lines
63
+ labels = { slot_name: config.dig("replication", "slot") }
64
+ [metric_line("mammoth_postgres_slot_inspection_up", 0, labels:)]
65
+ end
66
+
67
+ def timestamp_seconds(value)
68
+ return nil if value.nil?
69
+ return value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
70
+
71
+ Time.parse(value.to_s).to_i
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ def invalidated?(health)
77
+ health.conflicting || (!health.invalidation_reason.nil? && health.invalidation_reason != "")
78
+ end
79
+ end
80
+ end
@@ -27,7 +27,7 @@ module Mammoth
27
27
 
28
28
  count = 0
29
29
 
30
- each_event do |event|
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 each_event(&block)
56
+ def each_grouped_event(&block)
41
57
  effective_source.each do |work|
42
- flatten_cdc_work(work).each(&block)
58
+ grouped_cdc_work(work).each { |event, group_end| block.call(event, group_end) }
43
59
  end
44
60
  end
45
61
 
@@ -47,15 +63,20 @@ module Mammoth
47
63
  source || raise(ReplicationError, "replication source is not configured")
48
64
  end
49
65
 
50
- def flatten_cdc_work(work)
66
+ def grouped_cdc_work(work)
51
67
  return [] if work.nil?
52
- return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
53
- return transaction_work(work) if work.is_a?(CDC::Core::TransactionEnvelope)
54
- return event_work(work) if work.is_a?(CDC::Core::ChangeEvent)
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)
55
71
 
56
72
  raise ReplicationError, "CDC source yielded non-core work: #{work.class}"
57
73
  end
58
74
 
75
+ def grouped_transaction_work(envelope)
76
+ items = transaction_work(envelope)
77
+ items.each_with_index.map { |item, index| [item, index == items.length - 1] }
78
+ end
79
+
59
80
  def transaction_delivery?
60
81
  delivery_unit == :transaction
61
82
  end