mammoth 1.4.0 → 1.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55bd93a58c383e07fe1930ed71f20ea52c05dbfa77a2354e258caddd726865ce
4
- data.tar.gz: 2b01106f391f82ed0079d7efdf934447cdeb168d2ee5d6029fb8ac7cd8a617b8
3
+ metadata.gz: 3d7fd1f170a51138c5f3f1b8cc31c388428dd9052020f38653242c774bc6a92e
4
+ data.tar.gz: 134f6734ff5a1c377ba318a924630f6792dd51f66b0510ca37157836caacc993
5
5
  SHA512:
6
- metadata.gz: 4359b50a25fada22578bca1ab80c3afcc187ce6d181110d83157d7231bdc9fb5f32ab407b24d39619cd7e63fa8d4a4fc01c14fd27474f640d53c874c5411eff8
7
- data.tar.gz: 62eb55bba9825caaf491f8ed38ceb9f3da95580a57736c6998bb2349fa1980eb472e2ce7c775972ce7d7d9f8d6fa1ad43daffb22da7847b03c6b898c02e25762
6
+ metadata.gz: 7421871882b9288892ee4c3c2e3e0701839dd60165b238b771a98cd2ec21aa0ed1323b4605ce1f1c9270cd711430aae97f61b0e5f7edd5316a6b77a8686e1f39
7
+ data.tar.gz: 6b9d480c5c71beadbbf2633597e6af88176764b1a8e44e49b41cfa24c4636e0f14a08287f2522ce8bf885b0b7b95930600836697cd66acefb67452e5e2c3f2b5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@
4
4
 
5
5
  # Changelog
6
6
 
7
+ ## Unreleased
8
+
9
+ ## 1.5.1 - 2026-07-25
10
+
11
+ ### Changed
12
+
13
+ - Adopt Mammoth as the ecosystem brand, identify this open-source project as
14
+ Mammoth Data Plane, and align documentation, published gem metadata,
15
+ configuration descriptions, and branding assets with the new hierarchy.
16
+
17
+ ## 1.5.0 - 2026-07-24
18
+
19
+ ### Changed
20
+
21
+ - Default the unauthenticated observability HTTP server to the loopback
22
+ interface and document the network controls required for non-loopback
23
+ bindings.
24
+ - Implement `logging.level` with container-friendly JSON stdout logs for
25
+ application and replication lifecycle, delivery outcomes, retries, dead
26
+ letters, checkpoints, and debug-level work progress.
27
+ - Require a patched `json` gem and remove the vulnerable bundled default-gem
28
+ specification from the runtime image to address CVE-2026-33210.
29
+
30
+ ### Quality
31
+
32
+ - Run the container quickstart smoke test on pull requests and whenever Docker,
33
+ application, configuration, or dependency inputs change.
34
+ - Added Dependabot updates, Ruby dependency auditing, dependency review,
35
+ CodeQL analysis, and high- and critical-severity container vulnerability
36
+ scanning.
37
+ - Pin the Ruby container base image to an immutable digest and publish release
38
+ images with SBOM and maximum provenance attestations.
39
+ - Published a full reference benchmark snapshot with machine-readable results,
40
+ raw output, and documented interpretation across projection, delivery,
41
+ fanout, SQLite operational state, observability, and dead-letter replay.
42
+
7
43
  ## 1.4.0 - 2026-07-19
8
44
 
9
45
  ### Added
data/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  [![Gem Version](https://img.shields.io/gem/v/mammoth?logo=rubygems&logoColor=white)](https://rubygems.org/gems/mammoth)
18
18
  [![Requires Ruby 4.0+](https://img.shields.io/badge/Requires-Ruby%204.0%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
19
19
  [![CI](https://github.com/kanutocd/mammoth/actions/workflows/ci.yml/badge.svg)](https://github.com/kanutocd/mammoth/actions/workflows/ci.yml)
20
+ [![Security](https://img.shields.io/github/actions/workflow/status/kanutocd/mammoth/security.yml?branch=main&event=push&label=security)](https://github.com/kanutocd/mammoth/actions/workflows/security.yml)
20
21
  [![Tested Against PostgreSQL 14–18](https://img.shields.io/badge/Tested%20Against-PostgreSQL%2014--18-336791?logo=postgresql&logoColor=white)](https://github.com/kanutocd/mammoth/blob/main/.github/workflows/ci.yml#L50)
21
22
  [![License](https://img.shields.io/badge/License-MIT-22C55E)](LICENSE.txt)
22
23
 
@@ -24,6 +25,17 @@
24
25
  Mammoth is a self-hosted PostgreSQL event relay focused on reliable delivery
25
26
  of database change events.
26
27
 
28
+ This repository contains the **Mammoth Data Plane**, the open-source runtime in
29
+ the broader Mammoth ecosystem:
30
+
31
+ ```text
32
+ Mammoth
33
+ ├── Mammoth Data Plane
34
+ ├── Mammoth Control Plane
35
+ ├── Mammoth Control Agent
36
+ └── Mammoth Extensions
37
+ ```
38
+
27
39
  ```text
28
40
  PostgreSQL
29
41
 
@@ -31,7 +43,7 @@ CDC Ecosystem source adapter
31
43
 
32
44
  CDC::Core::TransactionEnvelope
33
45
 
34
- Mammoth
46
+ Mammoth Data Plane
35
47
 
36
48
  Webhook fanout
37
49
  ```
@@ -106,6 +118,7 @@ Mammoth 1.x includes:
106
118
  - per-destination payload removal and masking policies
107
119
  - per-destination enable/disable and retry policy controls
108
120
  - delivery worker with retry, delivered-ledger, and DLQ handling
121
+ - configurable `debug`, `info`, `warn`, and `error` JSON logging to stdout
109
122
  - contiguous delivery watermark for checkpoint and PostgreSQL acknowledgement
110
123
  - source-owned transport LSN preservation independent of payload `commit_lsn`
111
124
  - fail-closed PostgreSQL slot and checkpoint continuity preflight
@@ -196,7 +209,7 @@ payload policy.
196
209
 
197
210
  ## Extensions
198
211
 
199
- Mammoth OSS exposes small adapter registries for future extensions:
212
+ Mammoth Data Plane exposes small adapter registries for future extensions:
200
213
 
201
214
  - operational state adapters
202
215
  - destination adapters
@@ -257,6 +270,30 @@ bundle exec ./exe/mammoth start config/mammoth.example.yml
257
270
  bundle exec ./exe/mammoth observability config/mammoth.example.yml
258
271
  ```
259
272
 
273
+ ## Logging
274
+
275
+ Mammoth emits one JSON object per line to standard output. Set
276
+ `logging.level` to `debug`, `info`, `warn`, or `error`; `info` is the
277
+ recommended normal setting, while `debug` adds per-work and WAL acknowledgement
278
+ records.
279
+
280
+ ```yaml
281
+ logging:
282
+ level: info
283
+ ```
284
+
285
+ In Docker Compose, follow the application, replication, delivery, retry,
286
+ dead-letter, and checkpoint activity with:
287
+
288
+ ```bash
289
+ docker compose logs -f mammoth
290
+ ```
291
+
292
+ Operational identifiers are included, but payload bodies, configured headers,
293
+ credentials, signing secrets, and exception messages are excluded. See
294
+ [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md#logging) for the event and level
295
+ details.
296
+
260
297
  Reconstruct and deliver a single persisted event JSON file through Mammoth's
261
298
  core delivery path:
262
299
 
@@ -281,22 +318,35 @@ rather than opening SQLite directly.
281
318
 
282
319
  ## Performance
283
320
 
284
- Mammoth includes local benchmarks for the product surfaces operators tune in
285
- production:
286
-
287
- - event and transaction serialization, including deterministic fallback IDs
288
- - concurrent delivery runtime
289
- - real webhook delivery
290
- - multi-destination webhook fanout
291
- - SQLite operational state
292
- - observability snapshots
293
- - DLQ replay
294
-
295
- The historical numbers in [Benchmarks](https://github.com/kanutocd/mammoth/tree/main/docs/BENCHMARKS.md) are retained as a
296
- snapshot, not a universal performance claim. Re-run the scripts in
297
- [`benchmark/`](https://github.com/kanutocd/mammoth/tree/main/benchmark) on your own hardware when choosing
298
- `runtime.concurrency`, `destinations`, SQLite storage, scrape frequency, and
299
- DLQ replay expectations.
321
+ A reference benchmark run on Ruby 4.0.5 produced:
322
+
323
+ | Benchmark | Result |
324
+ | --------------------------------------------------- | --------------------: |
325
+ | Event payload projection with explicit ID | **56,265 events/sec** |
326
+ | Transaction payload projection with explicit IDs | **55,612 events/sec** |
327
+ | Mask payload-policy projection | **72,403 events/sec** |
328
+ | Concurrent delivery, 25 workers | **3,825 events/sec** |
329
+ | Concurrent delivery, 50 workers | **7,447 events/sec** |
330
+ | Signed webhook delivery with 10 ms receiver latency | **83.5 requests/sec** |
331
+ | SQLite delivered-ledger writes | **1,630 writes/sec** |
332
+ | Dead-letter replay mechanics | **1,030 entries/sec** |
333
+
334
+ At concurrency 50 with a synthetic 25 ms destination, Mammoth processed
335
+ **1,862 transactions/sec** against a theoretical ceiling of 2,000, while
336
+ maintaining **28.4 ms P95 latency**. This indicates low scheduling overhead for
337
+ I/O-bound downstream delivery.
338
+
339
+ These are local, single-trial reference measurements, not universal capacity
340
+ claims or comparisons with other CDC platforms. The benchmarks isolate specific
341
+ Mammoth components and may exclude PostgreSQL transport, decoding, checkpoint
342
+ coordination, network latency, or destination processing.
343
+
344
+ The [reference snapshot](https://github.com/kanutocd/mammoth/blob/main/benchmark/results/20260724T104950Z/snapshot.md)
345
+ records the exact commands, environment, raw output, and detailed
346
+ interpretation. Re-run the scripts in
347
+ [`benchmark/`](https://github.com/kanutocd/mammoth/tree/main/benchmark)
348
+ on your own hardware when choosing `runtime.concurrency`, destination count,
349
+ SQLite storage, scrape frequency, and DLQ replay expectations.
300
350
 
301
351
  Create a publishable benchmark snapshot with:
302
352
 
@@ -352,4 +402,4 @@ building a writable database copy. See
352
402
 
353
403
  ## License
354
404
 
355
- Mammoth OSS is licensed under the [MIT License](LICENSE.txt).
405
+ Mammoth OSS Data Plane is licensed under the [MIT License](LICENSE.txt).
@@ -380,12 +380,16 @@ sqlite:
380
380
  path: data/mammoth.db
381
381
 
382
382
  operational_state:
383
- # Mammoth OSS 0.7.x ships the sqlite operational state adapter.
383
+ # Mammoth Data Plane 0.7.x ships the sqlite operational state adapter.
384
384
  adapter: sqlite
385
385
 
386
386
  observability:
387
387
  # Bind host for the optional health, readiness, and metrics server.
388
- host: 0.0.0.0
388
+ #
389
+ # The endpoints do not authenticate clients and may expose operational
390
+ # details. Keep the loopback default unless a trusted network boundary or
391
+ # authenticated reverse proxy protects the listener.
392
+ host: 127.0.0.1
389
393
 
390
394
  # HTTP port for /healthz, /readyz, and /metrics.
391
395
  port: 9393
@@ -397,7 +401,8 @@ logging:
397
401
  # Very chatty; useful while diagnosing delivery or replication behavior.
398
402
  #
399
403
  # info:
400
- # Recommended normal default.
404
+ # Recommended normal default. Emits lifecycle, delivery, checkpoint, retry,
405
+ # and dead-letter JSON records to stdout for container log collection.
401
406
  #
402
407
  # warn/error:
403
408
  # Quieter production modes.
@@ -158,7 +158,7 @@
158
158
  "description": "Operator-defined node metadata."
159
159
  }
160
160
  },
161
- "description": "Optional local node identity. Mammoth OSS uses this for status and future extension surfaces only."
161
+ "description": "Optional local node identity. Mammoth Data Plane uses this for status and future extension surfaces only."
162
162
  },
163
163
  "postgres": {
164
164
  "type": "object",
@@ -346,7 +346,7 @@
346
346
  "enum": [
347
347
  "webhook"
348
348
  ],
349
- "description": "Destination adapter type. Mammoth OSS 0.7.x supports webhook destinations."
349
+ "description": "Destination adapter type. Mammoth Data Plane 0.7.x supports webhook destinations."
350
350
  },
351
351
  "enabled": {
352
352
  "type": "boolean",
@@ -520,7 +520,7 @@
520
520
  "enum": [
521
521
  "sqlite"
522
522
  ],
523
- "description": "Operational state adapter. Mammoth OSS 0.7.x ships the sqlite adapter."
523
+ "description": "Operational state adapter. Mammoth Data Plane 0.7.x ships the sqlite adapter."
524
524
  }
525
525
  },
526
526
  "description": "Operational state adapter selection. SQLite remains the default OSS adapter."
@@ -12,7 +12,7 @@ module Mammoth
12
12
  # rubocop:disable Metrics/ClassLength
13
13
  class Application
14
14
  attr_reader :config, :state_adapter, :consumer, :delivery_worker, :checkpoint_store, :lifecycle_hooks, :observer,
15
- :progress_coordinator
15
+ :progress_coordinator, :logger
16
16
 
17
17
  # @param config [Mammoth::Configuration] loaded configuration
18
18
  # @param source [#each, nil] injectable event source for tests and demos
@@ -21,9 +21,11 @@ module Mammoth
21
21
  # @param sleeper [#call] retry sleep strategy
22
22
  # @param lifecycle_hooks [Mammoth::LifecycleHooks, Hash] local lifecycle callbacks
23
23
  # @param observer [CDC::Core::Observer, nil] dispatch lifecycle observer
24
+ # @param logger [Mammoth::Logging::Logger, nil] structured operational logger
24
25
  def initialize(config, source: nil, sink: nil, state_adapter: nil, sleeper: Kernel.method(:sleep),
25
- lifecycle_hooks: LifecycleHooks.new, observer: nil)
26
+ lifecycle_hooks: LifecycleHooks.new, observer: nil, logger: nil)
26
27
  @config = config
28
+ @logger = logger || Logging.build(config)
27
29
  @lifecycle_hooks = build_lifecycle_hooks(lifecycle_hooks)
28
30
  @observer = observer || MetricsObserver.new
29
31
  @state_adapter = state_adapter || build_state_adapter
@@ -49,22 +51,37 @@ module Mammoth
49
51
  # Start the application runtime and deliver consumed CDC work.
50
52
  #
51
53
  # @return [Integer] number of processed work units
54
+ # rubocop:disable Metrics/AbcSize
52
55
  def start
53
56
  runtime = build_runtime
54
57
  processed = nil
55
58
 
59
+ operational_logger.info("application_started", mammoth_name: config&.dig("mammoth", "name"),
60
+ runtime: config&.dig("runtime", "adapter"),
61
+ delivery_unit: config&.dig("delivery", "unit"))
56
62
  lifecycle_hooks.call(:before_start, application_context(runtime: runtime))
57
63
  processed = process_consumer(runtime)
58
64
  lifecycle_hooks.call(:after_start, application_context(runtime: runtime, processed: processed))
65
+ operational_logger.info("application_completed", mammoth_name: config&.dig("mammoth", "name"), processed:)
59
66
  processed
67
+ rescue StandardError => e
68
+ operational_logger.error("application_failed", mammoth_name: config&.dig("mammoth", "name"),
69
+ error_class: e.class.name)
70
+ raise
60
71
  ensure
61
72
  lifecycle_hooks.call(:before_shutdown, application_context(runtime: runtime, processed: processed))
62
73
  runtime.shutdown if runtime.respond_to?(:shutdown)
63
74
  lifecycle_hooks.call(:after_shutdown, application_context(runtime: runtime, processed: processed))
75
+ operational_logger.info("application_stopped", mammoth_name: config&.dig("mammoth", "name"), processed:)
64
76
  end
77
+ # rubocop:enable Metrics/AbcSize
65
78
 
66
79
  private
67
80
 
81
+ def operational_logger
82
+ logger || Logging::NullLogger::INSTANCE
83
+ end
84
+
68
85
  def build_lifecycle_hooks(hooks)
69
86
  return hooks if hooks.is_a?(LifecycleHooks)
70
87
 
@@ -84,6 +101,7 @@ module Mammoth
84
101
  processed = 0
85
102
 
86
103
  consumer.start_with_boundaries do |work, group_end|
104
+ operational_logger.debug("work_received", work_type: work.class.name, work_id: work_identifier(work), group_end:)
87
105
  progress_coordinator.register(work, group_end:)
88
106
  process_work(runtime, work)
89
107
  processed += 1
@@ -94,6 +112,13 @@ module Mammoth
94
112
  processed
95
113
  end
96
114
 
115
+ def work_identifier(work)
116
+ return work.transaction_id if work.respond_to?(:transaction_id)
117
+ return work.event_id if work.respond_to?(:event_id)
118
+
119
+ nil
120
+ end
121
+
97
122
  def process_work(runtime, work)
98
123
  runtime.process(work)
99
124
  end
@@ -115,7 +140,7 @@ module Mammoth
115
140
  end
116
141
 
117
142
  def build_source
118
- Sources::Postgres.new(config, checkpoint_store: checkpoint_store)
143
+ Sources::Postgres.new(config, checkpoint_store: checkpoint_store, logger: logger)
119
144
  end
120
145
 
121
146
  def build_progress_coordinator
@@ -125,7 +150,8 @@ module Mammoth
125
150
  slot_name: config.dig("replication", "slot"),
126
151
  publication_name: Array(config.dig("replication", "publications")).join(","),
127
152
  acknowledger: source_acknowledger,
128
- position_resolver: source_position_resolver
153
+ position_resolver: source_position_resolver,
154
+ logger: logger
129
155
  )
130
156
  end
131
157
 
@@ -147,7 +173,8 @@ module Mammoth
147
173
  dead_letter_store: state_adapter.dead_letter_store,
148
174
  delivered_envelope_store: state_adapter.delivered_envelope_store,
149
175
  sleeper: sleeper,
150
- delivery_policy: delivery_policy
176
+ delivery_policy: delivery_policy,
177
+ logger: logger
151
178
  )
152
179
  end
153
180
 
data/lib/mammoth/cli.rb CHANGED
@@ -109,7 +109,7 @@ module Mammoth
109
109
  def observability
110
110
  config = load_config
111
111
  server = ObservabilityServer.new(config)
112
- puts "Mammoth observability listening on #{server.host}:#{server.port}"
112
+ Logging.build(config).info("observability_started", host: server.host, port: server.port)
113
113
  server.start
114
114
  0
115
115
  end
@@ -36,7 +36,7 @@ module Mammoth
36
36
  end
37
37
  end
38
38
 
39
- attr_reader :checkpoint_store, :source_name, :slot_name, :publication_name
39
+ attr_reader :checkpoint_store, :source_name, :slot_name, :publication_name, :logger
40
40
 
41
41
  # @param checkpoint_store [Mammoth::CheckpointStore] durable checkpoint persistence
42
42
  # @param source_name [String] logical source name
@@ -45,13 +45,14 @@ module Mammoth
45
45
  # @param acknowledger [#call, nil] upstream durable-progress acknowledgement
46
46
  # @param position_resolver [#call, nil] source-owned durable position resolver
47
47
  def initialize(checkpoint_store:, source_name:, slot_name:, publication_name:, acknowledger: nil,
48
- position_resolver: nil)
48
+ position_resolver: nil, logger: Logging::NullLogger::INSTANCE)
49
49
  @checkpoint_store = checkpoint_store
50
50
  @source_name = source_name
51
51
  @slot_name = slot_name
52
52
  @publication_name = publication_name
53
53
  @acknowledger = acknowledger
54
54
  @position_resolver = position_resolver
55
+ @logger = logger
55
56
  # rubocop:disable Layout/LeadingCommentSpace -- Steep inline type syntax requires `#:`.
56
57
  @groups = [] #: Array[Group]
57
58
  @entries_by_work = {} #: Hash[untyped, Array[Entry]]
@@ -133,6 +134,8 @@ module Mammoth
133
134
  last_lsn: position
134
135
  )
135
136
  @acknowledger&.call(position)
137
+ logger.info("checkpoint_advanced", mammoth_name: source_name, slot: slot_name, publication: publication_name,
138
+ source_position: position)
136
139
  end
137
140
 
138
141
  def remove_group(group)
@@ -7,12 +7,12 @@ module Mammoth
7
7
  # the delivery contract small: attempt webhook delivery, record idempotent
8
8
  # success, and persist exhausted failures to the dead letter queue. Contiguous
9
9
  # checkpointing is owned by DeliveryProgressCoordinator.
10
- class DeliveryWorker
10
+ class DeliveryWorker # rubocop:disable Metrics/ClassLength
11
11
  # Default source name used when an event does not provide one.
12
12
  DEFAULT_SOURCE = "postgresql"
13
13
 
14
14
  attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
15
- :sleeper, :source_name, :slot_name, :publication_name, :route_filter, :payload_policy, :enabled
15
+ :sleeper, :source_name, :slot_name, :publication_name, :route_filter, :payload_policy, :enabled, :logger
16
16
 
17
17
  # @param sink [#deliver] destination sink
18
18
  # @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
@@ -29,7 +29,7 @@ module Mammoth
29
29
  # @param enabled [Boolean] whether this destination accepts new deliveries
30
30
  def initialize(sink:, checkpoint_store:, dead_letter_store:, delivered_envelope_store:, source_name:, slot_name:,
31
31
  publication_name:, max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep),
32
- route_filter: nil, payload_policy: nil, enabled: true)
32
+ route_filter: nil, payload_policy: nil, enabled: true, logger: Logging::NullLogger::INSTANCE)
33
33
  @sink = sink
34
34
  @checkpoint_store = checkpoint_store
35
35
  @dead_letter_store = dead_letter_store
@@ -43,6 +43,7 @@ module Mammoth
43
43
  @route_filter = route_filter || RouteFilter.new
44
44
  @payload_policy = payload_policy || PayloadPolicy.new
45
45
  @enabled = enabled
46
+ @logger = logger
46
47
  return unless @payload_policy.active? && !sink.respond_to?(:deliver_payload)
47
48
 
48
49
  raise ConfigurationError, "destination #{destination_name} does not accept prepared payloads"
@@ -58,7 +59,7 @@ module Mammoth
58
59
  # @param sleeper [#call] sleep strategy
59
60
  # @return [Mammoth::DeliveryWorker]
60
61
  def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, delivered_envelope_store:,
61
- sleeper: Kernel.method(:sleep), delivery_policy: {})
62
+ sleeper: Kernel.method(:sleep), delivery_policy: {}, logger: Logging::NullLogger::INSTANCE)
62
63
  new(
63
64
  sink: sink,
64
65
  checkpoint_store: checkpoint_store,
@@ -72,7 +73,8 @@ module Mammoth
72
73
  sleeper: sleeper,
73
74
  route_filter: delivery_policy.fetch("route_filter", RouteFilter.new),
74
75
  payload_policy: delivery_policy.fetch("payload_policy", PayloadPolicy.new),
75
- enabled: delivery_policy.fetch("enabled", true)
76
+ enabled: delivery_policy.fetch("enabled", true),
77
+ logger: logger
76
78
  )
77
79
  end
78
80
 
@@ -109,7 +111,7 @@ module Mammoth
109
111
  deliver_prepared(prepared, work: work, delivery_method: delivery_method)
110
112
  end
111
113
 
112
- # rubocop:disable Metrics/MethodLength
114
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
113
115
  def deliver_prepared(prepared, work:, delivery_method:)
114
116
  attempts = 0
115
117
  canonical_payload = prepared.canonical_payload
@@ -118,16 +120,21 @@ module Mammoth
118
120
  idempotency_key = idempotency_key_for(payload: canonical_payload, delivery_unit: delivery_unit)
119
121
 
120
122
  skip_result = skip_result_for(canonical_payload, idempotency_key: idempotency_key)
121
- return skip_result if skip_result
123
+ if skip_result
124
+ log_outcome("delivery_skipped", skip_result, delivery_unit:)
125
+ return skip_result
126
+ end
122
127
 
123
128
  if delivered_envelope_store.delivered?(idempotency_key)
124
- return {
129
+ result = {
125
130
  status: "skipped",
126
131
  duplicate: true,
127
132
  idempotency_key: idempotency_key,
128
133
  attempts: attempts,
129
134
  destination: destination_name
130
135
  }
136
+ log_outcome("delivery_skipped", result, delivery_unit:)
137
+ return result
131
138
  end
132
139
 
133
140
  begin
@@ -142,15 +149,19 @@ module Mammoth
142
149
  transaction_id: payload["transaction_id"],
143
150
  source_position: payload["source_position"]
144
151
  )
145
- result.merge(attempts: attempts, idempotency_key: idempotency_key)
152
+ result = result.merge(attempts: attempts, idempotency_key: idempotency_key)
153
+ log_outcome("delivery_succeeded", result, delivery_unit:)
154
+ result
146
155
  rescue DeliveryError => e
147
156
  return dead_letter(payload, e, attempts) if attempts >= max_attempts
148
157
 
149
- wait_before_retry(attempts)
158
+ wait_seconds = wait_before_retry(attempts)
159
+ logger.warn("delivery_retry", destination: destination_name, delivery_unit:, attempt: attempts,
160
+ wait_seconds:, error_class: e.class.name)
150
161
  retry
151
162
  end
152
163
  end
153
- # rubocop:enable Metrics/MethodLength
164
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
154
165
 
155
166
  def idempotency_key_for(payload:, delivery_unit:)
156
167
  [
@@ -191,6 +202,7 @@ module Mammoth
191
202
  def wait_before_retry(attempts)
192
203
  wait_seconds = retry_schedule.fetch(attempts - 1, retry_schedule.last)
193
204
  sleeper.call(wait_seconds)
205
+ wait_seconds
194
206
  end
195
207
 
196
208
  def deliver_to_sink(prepared, work:, delivery_method:)
@@ -207,9 +219,18 @@ module Mammoth
207
219
  error: error,
208
220
  retry_count: attempts
209
221
  )
222
+ logger.error(
223
+ "delivery_dead_lettered",
224
+ destination: destination_name, attempts:, dead_letter_id: id, error_class: error.class.name
225
+ )
210
226
  { status: "dead_lettered", dead_letter_id: id, attempts: attempts }
211
227
  end
212
228
 
229
+ def log_outcome(event, result, delivery_unit:)
230
+ logger.info(event, destination: destination_name, delivery_unit:, status: result[:status],
231
+ attempts: result[:attempts], reason: result[:reason], duplicate: result[:duplicate])
232
+ end
233
+
213
234
  def transaction_payload?(payload)
214
235
  payload["type"] == TransactionEnvelopeSerializer::PAYLOAD_TYPE
215
236
  end
@@ -7,6 +7,7 @@ module Mammoth
7
7
  # future control agents stable observation points without changing the data
8
8
  # plane or introducing remote behavior into OSS.
9
9
  class LifecycleHooks
10
+ # Supported lifecycle callback names.
10
11
  EVENTS = %i[
11
12
  before_start
12
13
  after_start
@@ -15,6 +16,8 @@ module Mammoth
15
16
  before_replay
16
17
  after_replay
17
18
  ].freeze
19
+
20
+ # Frozen empty callback list used for events without registrations.
18
21
  EMPTY_CALLBACKS = [nil].compact.freeze
19
22
 
20
23
  attr_reader :callbacks
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Mammoth
7
+ # Container-friendly structured application logging.
8
+ module Logging
9
+ # Supported severity names mapped to their filtering priority.
10
+ LEVELS = { "debug" => 0, "info" => 1, "warn" => 2, "error" => 3 }.freeze
11
+
12
+ # Logger that emits one JSON object per line.
13
+ class Logger
14
+ attr_reader :level, :output, :clock
15
+
16
+ def initialize(level:, output: $stdout, clock: -> { Time.now.utc })
17
+ @level = level.to_s
18
+ @output = output
19
+ @clock = clock
20
+ raise ConfigurationError, "unsupported logging level: #{@level}" unless LEVELS.key?(@level)
21
+ end
22
+
23
+ # Emit a debug event when debug logging is enabled.
24
+ #
25
+ # @param event [String, Symbol] stable event name
26
+ # @param context [Hash] structured event fields
27
+ # @return [Boolean] whether the event was emitted
28
+ def debug(event, **context) = write("debug", event, context)
29
+
30
+ # Emit an informational event when info logging is enabled.
31
+ #
32
+ # @param event [String, Symbol] stable event name
33
+ # @param context [Hash] structured event fields
34
+ # @return [Boolean] whether the event was emitted
35
+ def info(event, **context) = write("info", event, context)
36
+
37
+ # Emit a warning event when warn logging is enabled.
38
+ #
39
+ # @param event [String, Symbol] stable event name
40
+ # @param context [Hash] structured event fields
41
+ # @return [Boolean] whether the event was emitted
42
+ def warn(event, **context) = write("warn", event, context)
43
+
44
+ # Emit an error event.
45
+ #
46
+ # @param event [String, Symbol] stable event name
47
+ # @param context [Hash] structured event fields
48
+ # @return [Boolean] whether the event was emitted
49
+ def error(event, **context) = write("error", event, context)
50
+
51
+ def enabled?(severity)
52
+ LEVELS.fetch(severity.to_s) >= LEVELS.fetch(level)
53
+ end
54
+
55
+ private
56
+
57
+ def write(severity, event, context)
58
+ return false unless enabled?(severity)
59
+
60
+ output.puts JSON.generate(
61
+ { timestamp: clock.call.utc.iso8601(6), severity:, service: "mammoth", event: }.merge(context.compact)
62
+ )
63
+ true
64
+ end
65
+ end
66
+
67
+ # No-op logger used at injectable library boundaries.
68
+ class NullLogger
69
+ # Shared immutable no-op logger.
70
+ INSTANCE = new
71
+
72
+ # Suppress a debug event.
73
+ #
74
+ # @return [false]
75
+ def debug(_event, **_context) = false
76
+
77
+ # Suppress an informational event.
78
+ #
79
+ # @return [false]
80
+ def info(_event, **_context) = false
81
+
82
+ # Suppress a warning event.
83
+ #
84
+ # @return [false]
85
+ def warn(_event, **_context) = false
86
+
87
+ # Suppress an error event.
88
+ #
89
+ # @return [false]
90
+ def error(_event, **_context) = false
91
+
92
+ def enabled?(_severity) = false
93
+ end
94
+
95
+ module_function
96
+
97
+ # Build the configured structured logger.
98
+ #
99
+ # @param config [Mammoth::Configuration, Hash] loaded Mammoth configuration
100
+ # @param output [IO] destination for newline-delimited JSON
101
+ # @param clock [#call] UTC-compatible time source
102
+ # @return [Mammoth::Logging::Logger]
103
+ def build(config, output: $stdout, clock: -> { Time.now.utc })
104
+ Logger.new(level: config.dig("logging", "level") || "info", output:, clock:)
105
+ end
106
+ end
107
+ end
@@ -11,7 +11,7 @@ module Mammoth
11
11
  # the same configured operational-state backend.
12
12
  class ObservabilityServer
13
13
  # Default bind host for the observability server.
14
- DEFAULT_HOST = "0.0.0.0"
14
+ DEFAULT_HOST = "127.0.0.1"
15
15
  # Default TCP port for the observability server.
16
16
  DEFAULT_PORT = 9393
17
17
 
@@ -29,7 +29,7 @@ module Mammoth
29
29
  @port = port || config.dig("observability", "port") || DEFAULT_PORT
30
30
  @state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
31
31
  @slot_health_provider = slot_health_provider || Sources::Postgres.new(config)
32
- @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
32
+ @logger = logger || WEBrick::Log.new($stdout, webrick_log_level)
33
33
  @server = build_server
34
34
  mount_endpoints
35
35
  end
@@ -50,6 +50,15 @@ module Mammoth
50
50
 
51
51
  private
52
52
 
53
+ def webrick_log_level
54
+ {
55
+ "debug" => WEBrick::Log::DEBUG,
56
+ "info" => WEBrick::Log::INFO,
57
+ "warn" => WEBrick::Log::WARN,
58
+ "error" => WEBrick::Log::ERROR
59
+ }.fetch(config.dig("logging", "level") || "info")
60
+ end
61
+
53
62
  def build_server
54
63
  WEBrick::HTTPServer.new(
55
64
  BindAddress: host,
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Mammoth
4
4
  module OperationalState
5
- # SQLite-backed operational state adapter used by Mammoth OSS.
5
+ # SQLite-backed operational state adapter used by Mammoth Data Plane.
6
6
  class SQLiteAdapter < Adapter
7
7
  attr_reader :sqlite_store
8
8
 
@@ -10,8 +10,13 @@ module Mammoth
10
10
  # items. Column rules scrub every canonical row-value representation: data,
11
11
  # identity, and old/new values in changes.
12
12
  class PayloadPolicy
13
+ # Default replacement used by mask rules.
13
14
  DEFAULT_MASK = "[REDACTED]"
15
+
16
+ # Payload metadata key containing the applied policy fingerprint.
14
17
  POLICY_METADATA_KEY = "mammoth_payload_policy"
18
+
19
+ # Transformation actions accepted by payload-policy rules.
15
20
  SUPPORTED_ACTIONS = %w[remove mask].freeze
16
21
 
17
22
  attr_reader :config, :fingerprint
@@ -29,6 +29,8 @@ module Mammoth
29
29
  attr_reader :checkpoint_store
30
30
  # @return [#inspect, nil] injected PostgreSQL publication inspector
31
31
  attr_reader :publication_inspector
32
+ # @return [Mammoth::Logging::Logger, Mammoth::Logging::NullLogger] operational logger
33
+ attr_reader :logger
32
34
 
33
35
  # Build a PostgreSQL CDC source.
34
36
  #
@@ -40,7 +42,7 @@ module Mammoth
40
42
  # @param checkpoint_store [Mammoth::CheckpointStore, nil] persisted checkpoints for restart resume
41
43
  # @param publication_inspector [#inspect, nil] injected publication metadata inspector
42
44
  def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil,
43
- publication_inspector: nil)
45
+ publication_inspector: nil, logger: Logging::NullLogger::INSTANCE)
44
46
  @config = config
45
47
  @runner = runner
46
48
  @parser = parser
@@ -48,6 +50,7 @@ module Mammoth
48
50
  @adapter = adapter
49
51
  @checkpoint_store = checkpoint_store
50
52
  @publication_inspector = publication_inspector
53
+ @logger = logger
51
54
  end
52
55
 
53
56
  # Stream CDC::Core work from PostgreSQL logical replication.
@@ -61,9 +64,12 @@ module Mammoth
61
64
  # @yieldparam work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope]
62
65
  # @return [Enumerator, nil]
63
66
  # @raise [Mammoth::ReplicationError] when the source cannot stream CDC work
67
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
64
68
  def each(&block)
65
69
  return enum_for(:each) unless block_given?
66
70
 
71
+ logger.info("replication_starting", slot: config.dig("replication", "slot"),
72
+ publications: Array(config.dig("replication", "publications")))
67
73
  preflight_slot!
68
74
  preflight_replica_identity!
69
75
  normalizer = effective_adapter
@@ -72,22 +78,29 @@ module Mammoth
72
78
  end
73
79
 
74
80
  normalizer.each_normalized(decoded_stream) do |work|
75
- yield_with_progress_position(validate_core_work!(work), &block)
81
+ core_work = validate_core_work!(work)
82
+ logger.debug("replication_work_normalized", work_type: core_work.class.name,
83
+ source_position: @latest_progress_position)
84
+ yield_with_progress_position(core_work, &block)
76
85
  end
86
+ logger.info("replication_stopped", slot: config.dig("replication", "slot"))
77
87
  nil
78
88
  rescue StandardError => e
89
+ logger.error("replication_failed", slot: config.dig("replication", "slot"), error_class: e.class.name)
79
90
  raise e if e.is_a?(ReplicationError)
80
91
 
81
92
  raise ReplicationError, "PostgreSQL CDC source failed: #{e.message}"
82
93
  end
94
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
83
95
 
84
96
  # Acknowledge a durably handled PostgreSQL WAL position.
85
97
  #
86
98
  # @param lsn [String, Integer] pgoutput-client compatible WAL position
87
99
  # @return [Integer] normalized acknowledged WAL position
88
100
  def acknowledge(lsn)
89
- effective_runner.ack(lsn)
101
+ effective_runner.ack(lsn).tap { logger.debug("wal_acknowledged", slot: config.dig("replication", "slot"), lsn:) }
90
102
  rescue StandardError => e
103
+ logger.error("wal_acknowledgement_failed", slot: config.dig("replication", "slot"), error_class: e.class.name)
91
104
  raise ReplicationError, "PostgreSQL WAL acknowledgement failed: #{e.message}"
92
105
  end
93
106
 
@@ -70,6 +70,7 @@ module Mammoth
70
70
 
71
71
  # Read-only PostgreSQL publication and replica-identity catalog inspector.
72
72
  class PostgresPublicationInspector
73
+ # Catalog query used to inspect publication actions and replica identity.
73
74
  QUERY = <<~SQL
74
75
  SELECT
75
76
  relation.oid AS relation_id,
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "1.4.0"
5
+ VERSION = "1.5.1"
6
6
  end
data/lib/mammoth.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "mammoth/version"
6
6
  require_relative "mammoth/errors"
7
7
  require_relative "mammoth/registry"
8
8
  require_relative "mammoth/configuration"
9
+ require_relative "mammoth/logging"
9
10
  require_relative "mammoth/lifecycle_hooks"
10
11
  require_relative "mammoth/node_identity"
11
12
  require_relative "mammoth/capabilities"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mammoth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -37,6 +37,26 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.19.2
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '3.0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 2.19.2
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '3.0'
40
60
  - !ruby/object:Gem::Dependency
41
61
  name: json-schema
42
62
  requirement: !ruby/object:Gem::Requirement
@@ -162,14 +182,15 @@ dependencies:
162
182
  - !ruby/object:Gem::Version
163
183
  version: '1.9'
164
184
  description: |
165
- Mammoth is an OSS PostgreSQL change-event delivery appliance for Ruby.
185
+ Mammoth Data Plane is the open-source PostgreSQL change-event delivery
186
+ runtime in the Mammoth ecosystem.
166
187
 
167
188
  It realizes the CDC Ecosystem pgoutput and cdc-core libraries for PostgreSQL,
168
189
  then delivers normalized changes to webhook endpoints with durable
169
190
  checkpointing, retry state, dead letters, and operational visibility.
170
191
 
171
- Mammoth is application-first: it can be installed as a Ruby gem, packaged
172
- into a container image, or deployed into Kubernetes with Helm.
192
+ Mammoth Data Plane is application-first: it can be installed as a Ruby gem,
193
+ packaged into a container image, or deployed into Kubernetes with Helm.
173
194
  email:
174
195
  - kenneth.c.demanawa@gmail.com
175
196
  executables:
@@ -211,6 +232,7 @@ files:
211
232
  - lib/mammoth/event_serializer.rb
212
233
  - lib/mammoth/fanout_delivery_worker.rb
213
234
  - lib/mammoth/lifecycle_hooks.rb
235
+ - lib/mammoth/logging.rb
214
236
  - lib/mammoth/metrics_observer.rb
215
237
  - lib/mammoth/node_identity.rb
216
238
  - lib/mammoth/observability_metrics.rb
@@ -265,6 +287,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
265
287
  requirements: []
266
288
  rubygems_version: 4.0.10
267
289
  specification_version: 4
268
- summary: Reliable delivery of PostgreSQL logical replication events with retries,
269
- dead letters, and operational state.
290
+ summary: Mammoth Data Plane for reliable PostgreSQL change-event delivery.
270
291
  test_files: []