mammoth 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d7d570675d2b6731d783c6eba0f347ab00c1d7f9e524c20eef4908af129c41a
4
- data.tar.gz: f14c3223b20cd1ea8e7d795e1cd2fe4e512766a0a4ed275515e4d1174bb3d6f5
3
+ metadata.gz: 8f299dc783af6453331a635200b8fe3ddf03159b156a3a0cec6cc6377ede1c34
4
+ data.tar.gz: f56eabc963912066610c22a98af55af9a6db192f40869dfb1d5743c8f1eff98e
5
5
  SHA512:
6
- metadata.gz: cf649aba9068bdc1c3b0e01c1254f8f16804e80ec88de725d2743e99be9b9010bd880590fde044c9d7dff3b0b99fac54c6c9776af4504fb566eeceb1e5b00fd9
7
- data.tar.gz: a7396f91f4fe84b3780f9b176931d728707b6e55ecab9b168b5eb3e87cb384c2066d9ed0b944dd14405d2e06a02e0e363a28348325578bb652aa4ba1df56caee
6
+ metadata.gz: c3167effc0333430719118b2ad3f8d8b53841c8c584b1e45949e256411e1a275eb56b71bd023d4d3778e31ec92f179c338d5814220f11c251f447aad7eee0233
7
+ data.tar.gz: d6db35472dfb2847e00f2158fd8677f26da5cb3c19d91e002566d598d9ecd8d67681e35dc2310ffd7fd41dcc0aec5535e822fd52f55f3865f9f65cc267633383
data/CHANGELOG.md CHANGED
@@ -2,7 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 0.3.0
5
+ ## 0.5.0
6
+
7
+ ### Added
8
+
9
+ - Added multi-destination webhook fanout through `destinations`, with independent per-destination retry, delivered-ledger, and dead-letter state.
10
+ - Added targeted fanout dead-letter replay so replay sends failed work back only to the original destination.
11
+ - Added Helm rendering for fanout destinations, including secret-backed environment variables for Authorization headers and HMAC signing secrets.
12
+
13
+ ## 0.4.0
14
+
15
+ ### Added
16
+
17
+ - Added optional observability support with `/healthz`, `/readyz`, and `/metrics` endpoints, plus CLI startup, configuration, and documentation coverage.
18
+
19
+ ## 0.4.0
6
20
 
7
21
  ### Added
8
22
 
data/README.md CHANGED
@@ -17,7 +17,7 @@ CDC::Core::TransactionEnvelope
17
17
 
18
18
  Mammoth
19
19
 
20
- Webhook
20
+ Webhook fanout
21
21
  ```
22
22
 
23
23
  🦣 Mammoth is intentionally boring infrastructure. It uses YAML configuration,
@@ -46,6 +46,7 @@ https://kanutocd.github.io/mammoth/Mammoth.html
46
46
  - checkpoint persistence
47
47
  - dead letter persistence
48
48
  - webhook delivery sink
49
+ - webhook fanout to multiple destinations
49
50
  - delivery worker with retry, checkpoint, and DLQ handling
50
51
  - dead-letter inspection and replay commands
51
52
  - CDC-core event serialization boundary
@@ -53,10 +54,11 @@ https://kanutocd.github.io/mammoth/Mammoth.html
53
54
  - Docker image support
54
55
  - public Helm chart support
55
56
  - unit and e2e test tasks
57
+ - health and metrics endpoints
56
58
 
57
59
  ## Boundary
58
60
 
59
- Mammoth begins at CDC-core work items and ends at webhook delivery.
61
+ Mammoth begins at CDC-core work items and ends at webhook fanout delivery.
60
62
 
61
63
  Mammoth does not own pgoutput protocol parsing, value decoding, source
62
64
  normalization, ordering policy, or runtime execution. Those belong to the
@@ -84,6 +86,7 @@ bundle exec ./exe/mammoth validate config/mammoth.example.yml
84
86
  bundle exec ./exe/mammoth bootstrap config/mammoth.example.yml
85
87
  bundle exec ./exe/mammoth status config/mammoth.example.yml
86
88
  bundle exec ./exe/mammoth start config/mammoth.example.yml
89
+ bundle exec ./exe/mammoth observability config/mammoth.example.yml
87
90
  ```
88
91
 
89
92
  Deliver a single normalized event JSON file through Mammoth's delivery path:
@@ -101,6 +104,7 @@ Mammoth stores operational memory in SQLite:
101
104
  - `schema_migrations`
102
105
  - `checkpoints`
103
106
  - `dead_letters`
107
+ - `delivered_envelopes`
104
108
 
105
109
  ## Performance
106
110
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  # Mammoth example configuration.
4
4
  #
5
- # This file is intentionally verbose. It is both a runnable starting point and
6
- # the primary operator-facing documentation for local development, Kubernetes
7
- # values, and production deployments.
5
+ # This file is intentionally verbose. It is the primary operator-facing
6
+ # reference for local development, Kubernetes values, and production
7
+ # deployments. Update URLs, environment variables, and PostgreSQL settings
8
+ # before using it as a runtime config.
8
9
  #
9
10
  # Architecture reminder:
10
11
  #
@@ -24,7 +25,7 @@
24
25
  mammoth:
25
26
  # Human-readable Mammoth instance name.
26
27
  #
27
- # Used in logs, status output, metrics, and future operational dashboards.
28
+ # Used in logs, status output, metrics, and operational dashboards.
28
29
  # Keep this stable per environment.
29
30
  #
30
31
  # Examples:
@@ -177,8 +178,7 @@ runtime:
177
178
  #
178
179
  # concurrent:
179
180
  # Use CDC::Concurrent::ProcessorPool for I/O-heavy delivery workloads such as
180
- # webhook HTTP calls, retry sleeps/backoff, and future multi-destination
181
- # fanout.
181
+ # webhook HTTP calls, retry sleeps/backoff, and multi-destination fanout.
182
182
  adapter: concurrent
183
183
 
184
184
  # Number of downstream delivery workers.
@@ -193,6 +193,12 @@ runtime:
193
193
  # webhook-heavy production: 10-50, after soak testing
194
194
  concurrency: 1
195
195
 
196
+ # Number of work units submitted to the runtime together.
197
+ #
198
+ # Keep this at 1 unless you have validated downstream ordering, retry, and
199
+ # receiver idempotency behavior under load.
200
+ batch_size: 1
201
+
196
202
  # Preserve configured ordering while using the runtime.
197
203
  #
198
204
  # true:
@@ -206,11 +212,17 @@ runtime:
206
212
  # Optional runtime timeout in seconds.
207
213
  #
208
214
  # Leave blank to let destination-specific timeouts control delivery behavior.
209
- # Useful later for bounding end-to-end processor execution.
215
+ # Use this to bound end-to-end processor execution.
210
216
  timeout_seconds:
211
217
 
218
+ # Single webhook destination shorthand.
219
+ #
220
+ # Use this for one destination. Use destinations below instead when Mammoth
221
+ # should fan out to multiple webhook receivers. Do not configure both unless you
222
+ # intentionally want destinations to replace this shorthand.
212
223
  webhook:
213
- # Destination name used in logs, dead-letter records, and future metrics.
224
+ # Destination name used in logs, dead-letter records, delivered ledgers, and
225
+ # metrics.
214
226
  name: primary_webhook
215
227
 
216
228
  # Destination URL for delivered CDC payloads.
@@ -230,6 +242,7 @@ webhook:
230
242
  X-Mammoth-Source: local_mammoth
231
243
 
232
244
  # Optional HTTP headers whose values are read from environment variables.
245
+ # In Kubernetes, set these variables from Secrets.
233
246
  #
234
247
  # Example:
235
248
  # header_env:
@@ -244,6 +257,7 @@ webhook:
244
257
  #
245
258
  # using the secret from secret_env, then sends the timestamp and signature in
246
259
  # configurable headers. The signature value is prefixed with "sha256=".
260
+ # In Kubernetes, set the secret_env variable from a Secret.
247
261
  #
248
262
  # signing:
249
263
  # algorithm: hmac_sha256
@@ -251,6 +265,28 @@ webhook:
251
265
  # signature_header: X-Mammoth-Signature
252
266
  # timestamp_header: X-Mammoth-Timestamp
253
267
 
268
+ # Optional fanout configuration.
269
+ #
270
+ # Use destinations instead of webhook when one PostgreSQL replication stream
271
+ # should deliver the same CDC work item to multiple webhook receivers. Each
272
+ # destination gets independent retry, delivered-ledger, and dead-letter state.
273
+ # The destinations list replaces the webhook shorthand above.
274
+ #
275
+ # destinations:
276
+ # - name: primary_webhook
277
+ # type: webhook
278
+ # url: https://example.com/webhooks/postgres
279
+ # timeout_seconds: 5
280
+ # header_env:
281
+ # Authorization: MAMMOTH_PRIMARY_WEBHOOK_AUTHORIZATION
282
+ # signing:
283
+ # algorithm: hmac_sha256
284
+ # secret_env: MAMMOTH_PRIMARY_WEBHOOK_SIGNING_SECRET
285
+ # - name: audit_webhook
286
+ # type: webhook
287
+ # url: https://audit.example.com/cdc
288
+ # timeout_seconds: 5
289
+
254
290
  retry:
255
291
  # Maximum delivery attempts before the event/envelope is moved to the dead
256
292
  # letter store.
@@ -270,10 +306,17 @@ retry:
270
306
  sqlite:
271
307
  # Local operational database path.
272
308
  #
273
- # Mammoth stores checkpoints, delivery attempts, and dead-letter records here.
274
- # In Kubernetes, back this path with a PersistentVolumeClaim.
309
+ # Mammoth stores checkpoints, delivered-envelope ledgers, and dead-letter
310
+ # records here. In Kubernetes, back this path with a PersistentVolumeClaim.
275
311
  path: data/mammoth.db
276
312
 
313
+ observability:
314
+ # Bind host for the optional health, readiness, and metrics server.
315
+ host: 0.0.0.0
316
+
317
+ # HTTP port for /healthz, /readyz, and /metrics.
318
+ port: 9393
319
+
277
320
  logging:
278
321
  # Log verbosity.
279
322
  #
@@ -8,11 +8,22 @@
8
8
  "mammoth",
9
9
  "postgres",
10
10
  "replication",
11
- "webhook",
12
11
  "retry",
13
12
  "sqlite",
14
13
  "logging"
15
14
  ],
15
+ "anyOf": [
16
+ {
17
+ "required": [
18
+ "webhook"
19
+ ]
20
+ },
21
+ {
22
+ "required": [
23
+ "destinations"
24
+ ]
25
+ }
26
+ ],
16
27
  "properties": {
17
28
  "mammoth": {
18
29
  "type": "object",
@@ -187,7 +198,93 @@
187
198
  "description": "Optional HMAC-SHA256 request signing for webhook receivers."
188
199
  }
189
200
  },
190
- "description": "HTTP webhook destination settings."
201
+ "description": "HTTP webhook destination settings. This is shorthand for a single webhook destination; use destinations for fanout."
202
+ },
203
+ "destinations": {
204
+ "type": "array",
205
+ "minItems": 1,
206
+ "items": {
207
+ "type": "object",
208
+ "additionalProperties": false,
209
+ "required": [
210
+ "name",
211
+ "type",
212
+ "url",
213
+ "timeout_seconds"
214
+ ],
215
+ "properties": {
216
+ "name": {
217
+ "type": "string",
218
+ "minLength": 1,
219
+ "description": "Destination name used in delivered ledgers, dead-letter records, logs, and metrics."
220
+ },
221
+ "type": {
222
+ "type": "string",
223
+ "enum": [
224
+ "webhook"
225
+ ],
226
+ "description": "Destination adapter type. Mammoth OSS 0.5.0 supports webhook destinations."
227
+ },
228
+ "url": {
229
+ "type": "string",
230
+ "format": "uri",
231
+ "description": "Webhook URL for delivered CDC payloads. Transaction delivery sends a TransactionEnvelope payload; event delivery sends a single event payload."
232
+ },
233
+ "timeout_seconds": {
234
+ "type": "integer",
235
+ "minimum": 1,
236
+ "description": "HTTP timeout in seconds for this destination's delivery attempts."
237
+ },
238
+ "headers": {
239
+ "type": "object",
240
+ "additionalProperties": {
241
+ "type": "string"
242
+ },
243
+ "description": "Static HTTP headers added to every request for this destination. Do not put secrets here; use header_env for secret-backed values."
244
+ },
245
+ "header_env": {
246
+ "type": "object",
247
+ "additionalProperties": {
248
+ "type": "string",
249
+ "minLength": 1
250
+ },
251
+ "description": "HTTP headers whose values are read from environment variables. Keys are header names; values are environment variable names."
252
+ },
253
+ "signing": {
254
+ "type": "object",
255
+ "additionalProperties": false,
256
+ "required": [
257
+ "secret_env"
258
+ ],
259
+ "properties": {
260
+ "algorithm": {
261
+ "type": "string",
262
+ "enum": [
263
+ "hmac_sha256"
264
+ ],
265
+ "description": "Webhook signing algorithm. Mammoth signs '<timestamp>.<body>' using HMAC-SHA256."
266
+ },
267
+ "secret_env": {
268
+ "type": "string",
269
+ "minLength": 1,
270
+ "description": "Environment variable containing the webhook signing secret."
271
+ },
272
+ "signature_header": {
273
+ "type": "string",
274
+ "minLength": 1,
275
+ "description": "Header name used for the generated signature. Defaults to X-Mammoth-Signature."
276
+ },
277
+ "timestamp_header": {
278
+ "type": "string",
279
+ "minLength": 1,
280
+ "description": "Header name used for the signing timestamp. Defaults to X-Mammoth-Timestamp."
281
+ }
282
+ },
283
+ "description": "Optional HMAC-SHA256 request signing for this webhook destination."
284
+ }
285
+ }
286
+ },
287
+ "description": "Fanout destination list. Each CDC work item is delivered independently to each configured destination."
191
288
  },
192
289
  "retry": {
193
290
  "type": "object",
@@ -319,6 +416,24 @@
319
416
  }
320
417
  },
321
418
  "description": "Downstream delivery runtime settings. Runtime concurrency affects webhook/destination delivery only, not PostgreSQL replication stream count."
419
+ },
420
+ "observability": {
421
+ "type": "object",
422
+ "additionalProperties": false,
423
+ "properties": {
424
+ "host": {
425
+ "type": "string",
426
+ "minLength": 1,
427
+ "description": "Bind host for the optional Mammoth observability HTTP server exposing /healthz, /readyz, and /metrics."
428
+ },
429
+ "port": {
430
+ "type": "integer",
431
+ "minimum": 1,
432
+ "maximum": 65535,
433
+ "description": "TCP port for the optional Mammoth observability HTTP server."
434
+ }
435
+ },
436
+ "description": "Optional health, readiness, and metrics endpoint settings."
322
437
  }
323
438
  },
324
439
  "description": "Mammoth relay configuration. Mammoth consumes PostgreSQL logical replication changes and delivers them to downstream destinations while preserving checkpoint, retry, and dead-letter semantics."
@@ -21,7 +21,7 @@ module Mammoth
21
21
  @sqlite_store = SQLiteStore.connect(config.dig("sqlite", "path")).bootstrap!
22
22
  @checkpoint_store = CheckpointStore.new(sqlite_store)
23
23
  @consumer = ReplicationConsumer.new(source: source || build_source, delivery_unit: delivery_unit)
24
- @delivery_worker = build_delivery_worker(sink: sink || WebhookSink.from_config(config), sleeper: sleeper)
24
+ @delivery_worker = sink ? build_delivery_worker(sink: sink, sleeper: sleeper) : build_configured_delivery_worker(sleeper:)
25
25
  end
26
26
 
27
27
  # Start the application runtime and deliver consumed CDC work.
@@ -97,6 +97,22 @@ module Mammoth
97
97
  )
98
98
  end
99
99
 
100
+ def build_configured_delivery_worker(sleeper:)
101
+ workers = destination_sinks.map { |sink| build_delivery_worker(sink:, sleeper:) }
102
+ return workers.fetch(0) if workers.one?
103
+
104
+ FanoutDeliveryWorker.new(workers)
105
+ end
106
+
107
+ def destination_sinks
108
+ destinations = config.data["destinations"]
109
+ return [WebhookSink.from_config(config)] unless destinations
110
+
111
+ destinations.map.with_index(1) do |destination, index|
112
+ WebhookSink.from_destination_config(destination, label: "destinations[#{index - 1}]")
113
+ end
114
+ end
115
+
100
116
  def transaction_delivery?
101
117
  delivery_unit == :transaction
102
118
  end
data/lib/mammoth/cli.rb CHANGED
@@ -26,7 +26,8 @@ module Mammoth
26
26
  " mammoth deliver-sample CONFIG EVENT_JSON",
27
27
  " mammoth dead-letters list CONFIG [--status STATUS] [--limit N]",
28
28
  " mammoth dead-letters show CONFIG ID",
29
- " mammoth dead-letters replay CONFIG [ID ...]"
29
+ " mammoth dead-letters replay CONFIG [ID ...]",
30
+ " mammoth observability CONFIG"
30
31
  ].join("\n")
31
32
 
32
33
  # Run the CLI.
@@ -56,6 +57,7 @@ module Mammoth
56
57
  when "start" then start
57
58
  when "deliver-sample" then deliver_sample
58
59
  when "dead-letters" then dead_letters
60
+ when "observability" then observability
59
61
  else
60
62
  warn USAGE
61
63
  1
@@ -130,6 +132,14 @@ module Mammoth
130
132
  DeadLetterCommands.call(argv)
131
133
  end
132
134
 
135
+ def observability
136
+ config = load_config
137
+ server = ObservabilityServer.new(config)
138
+ puts "Mammoth observability listening on #{server.host}:#{server.port}"
139
+ server.start
140
+ 0
141
+ end
142
+
133
143
  def load_config
134
144
  raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
135
145
 
@@ -44,6 +44,7 @@ module Mammoth
44
44
  raise ConfigurationError, "configuration must be a YAML mapping" unless data.is_a?(Hash)
45
45
 
46
46
  validate_schema!
47
+ validate_destination_names!
47
48
  self
48
49
  rescue Psych::SyntaxError => e
49
50
  raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
@@ -73,5 +74,16 @@ module Mammoth
73
74
  def schema_error_message(errors)
74
75
  (["configuration failed schema validation:"] + errors.map { |error| "- #{error}" }).join("\n")
75
76
  end
77
+
78
+ def validate_destination_names!
79
+ destinations = data["destinations"]
80
+ return unless destinations
81
+
82
+ names = destinations.map { |destination| destination["name"] }
83
+ duplicates = names.tally.select { |_name, count| count > 1 }.keys
84
+ return if duplicates.empty?
85
+
86
+ raise ConfigurationError, "destination names must be unique: #{duplicates.join(", ")}"
87
+ end
76
88
  end
77
89
  end
@@ -163,7 +163,23 @@ module Mammoth
163
163
 
164
164
  def replay_row(row)
165
165
  payload = JSON.parse(row.fetch("payload_json"))
166
- transaction_payload?(payload) ? worker.deliver_transaction(transaction_envelope(payload)) : worker.deliver(payload)
166
+ if transaction_payload?(payload)
167
+ replay_transaction(row.fetch("destination_name"), transaction_envelope(payload))
168
+ else
169
+ replay_event(row.fetch("destination_name"), payload)
170
+ end
171
+ end
172
+
173
+ def replay_event(destination_name, payload)
174
+ return worker.deliver_to(destination_name, payload) if worker.respond_to?(:deliver_to)
175
+
176
+ worker.deliver(payload)
177
+ end
178
+
179
+ def replay_transaction(destination_name, envelope)
180
+ return worker.deliver_transaction_to(destination_name, envelope) if worker.respond_to?(:deliver_transaction_to)
181
+
182
+ worker.deliver_transaction(envelope)
167
183
  end
168
184
 
169
185
  def transaction_payload?(payload)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Delivers one CDC work item to multiple destination-specific workers.
5
+ class FanoutDeliveryWorker
6
+ attr_reader :delivery_workers
7
+
8
+ # @param delivery_workers [Array<Mammoth::DeliveryWorker>] destination workers
9
+ def initialize(delivery_workers)
10
+ raise ConfigurationError, "at least one destination is required" if delivery_workers.empty?
11
+
12
+ @delivery_workers = delivery_workers
13
+ end
14
+
15
+ # Deliver an event to every configured destination.
16
+ #
17
+ # @param event [Hash, #to_h] normalized event
18
+ # @return [Hash] aggregate fanout summary
19
+ def deliver(event)
20
+ fanout(:deliver, event)
21
+ end
22
+
23
+ # Deliver a transaction envelope to every configured destination.
24
+ #
25
+ # @param envelope [#events, #transaction_id] CDC transaction envelope
26
+ # @return [Hash] aggregate fanout summary
27
+ def deliver_transaction(envelope)
28
+ fanout(:deliver_transaction, envelope)
29
+ end
30
+
31
+ # Deliver an event to one configured destination.
32
+ #
33
+ # @param destination_name [String] destination name
34
+ # @param event [Hash, #to_h] normalized event
35
+ # @return [Hash] destination delivery summary
36
+ def deliver_to(destination_name, event)
37
+ worker_for(destination_name).deliver(event)
38
+ end
39
+
40
+ # Deliver a transaction envelope to one configured destination.
41
+ #
42
+ # @param destination_name [String] destination name
43
+ # @param envelope [#events, #transaction_id] CDC transaction envelope
44
+ # @return [Hash] destination delivery summary
45
+ def deliver_transaction_to(destination_name, envelope)
46
+ worker_for(destination_name).deliver_transaction(envelope)
47
+ end
48
+
49
+ private
50
+
51
+ def fanout(delivery_method, work)
52
+ results = delivery_workers.map { |worker| worker.public_send(delivery_method, work) }
53
+ {
54
+ status: aggregate_status(results),
55
+ destinations: results,
56
+ delivered: results.count { |result| result.fetch(:status) == "delivered" },
57
+ skipped: results.count { |result| result.fetch(:status) == "skipped" },
58
+ dead_lettered: results.count { |result| result.fetch(:status) == "dead_lettered" }
59
+ }
60
+ end
61
+
62
+ def aggregate_status(results)
63
+ return "fanout_delivered" if results.all? { |result| %w[delivered skipped].include?(result.fetch(:status)) }
64
+
65
+ "fanout_partial"
66
+ end
67
+
68
+ def worker_for(destination_name)
69
+ delivery_workers.find { |worker| worker.send(:destination_name) == destination_name } ||
70
+ raise(ConfigurationError, "destination not configured: #{destination_name}")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "webrick"
5
+
6
+ module Mammoth
7
+ # Small HTTP server exposing Mammoth health, readiness, and metrics endpoints.
8
+ #
9
+ # The server is intentionally independent from the replication loop. Operators
10
+ # may run it as a sidecar-like process or in a separate process that points at
11
+ # the same SQLite operational database.
12
+ class ObservabilityServer
13
+ DEFAULT_HOST = "0.0.0.0"
14
+ DEFAULT_PORT = 9393
15
+
16
+ attr_reader :config, :host, :port, :sqlite_store, :logger, :server
17
+
18
+ # @param config [Mammoth::Configuration] loaded configuration
19
+ # @param host [String, nil] bind host override
20
+ # @param port [Integer, nil] bind port override
21
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
22
+ # @param logger [WEBrick::Log, nil] optional WEBrick logger
23
+ def initialize(config, host: nil, port: nil, sqlite_store: nil, logger: nil)
24
+ @config = config
25
+ @host = host || config.dig("observability", "host") || DEFAULT_HOST
26
+ @port = port || config.dig("observability", "port") || DEFAULT_PORT
27
+ @sqlite_store = sqlite_store
28
+ @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
29
+ @server = build_server
30
+ mount_endpoints
31
+ end
32
+
33
+ # Start the blocking HTTP server.
34
+ #
35
+ # @return [void]
36
+ def start
37
+ server.start
38
+ end
39
+
40
+ # Stop the HTTP server.
41
+ #
42
+ # @return [void]
43
+ def shutdown
44
+ server.shutdown
45
+ end
46
+
47
+ private
48
+
49
+ def build_server
50
+ WEBrick::HTTPServer.new(
51
+ BindAddress: host,
52
+ Port: port,
53
+ Logger: logger,
54
+ AccessLog: []
55
+ )
56
+ end
57
+
58
+ def mount_endpoints
59
+ server.mount_proc("/healthz") { |_request, response| write_json(response, snapshot.health, status: 200) }
60
+ server.mount_proc("/readyz") { |_request, response| write_readiness(response) }
61
+ server.mount_proc("/metrics") { |_request, response| write_metrics(response) }
62
+ end
63
+
64
+ def write_readiness(response)
65
+ payload = snapshot.readiness
66
+ write_json(response, payload, status: payload.fetch(:status) == "ready" ? 200 : 503)
67
+ end
68
+
69
+ def write_metrics(response)
70
+ response.status = 200
71
+ response["Content-Type"] = "text/plain; version=0.0.4; charset=utf-8"
72
+ response.body = snapshot.prometheus
73
+ end
74
+
75
+ def write_json(response, payload, status:)
76
+ response.status = status
77
+ response["Content-Type"] = "application/json"
78
+ response.body = JSON.generate(payload)
79
+ end
80
+
81
+ def snapshot
82
+ ObservabilitySnapshot.new(config, sqlite_store: sqlite_store)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mammoth
6
+ # Builds health, readiness, and metrics snapshots from Mammoth's local
7
+ # operational state.
8
+ #
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
+ # SQLite operational-state status in a predictable format.
13
+ class ObservabilitySnapshot
14
+ attr_reader :config, :sqlite_store, :clock
15
+
16
+ # @param config [Mammoth::Configuration] loaded configuration
17
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
18
+ # @param clock [#call] time source returning a Time-like object
19
+ def initialize(config, sqlite_store: nil, clock: -> { Time.now.utc })
20
+ @config = config
21
+ @sqlite_store = sqlite_store
22
+ @clock = clock
23
+ end
24
+
25
+ # Build a liveness response.
26
+ #
27
+ # @return [Hash] health payload
28
+ def health
29
+ {
30
+ status: "ok",
31
+ service: "mammoth",
32
+ name: mammoth_name,
33
+ version: Mammoth::VERSION,
34
+ checked_at: checked_at
35
+ }
36
+ end
37
+
38
+ # Build a readiness response.
39
+ #
40
+ # @return [Hash] readiness payload
41
+ def readiness
42
+ store = operational_store
43
+ store.bootstrap!
44
+
45
+ {
46
+ status: "ready",
47
+ service: "mammoth",
48
+ name: mammoth_name,
49
+ sqlite: "ok",
50
+ tables: store.tables,
51
+ checked_at: checked_at
52
+ }
53
+ rescue Mammoth::Error, SQLite3::Exception => e
54
+ {
55
+ status: "unready",
56
+ service: "mammoth",
57
+ name: mammoth_name,
58
+ sqlite: "error",
59
+ error_class: e.class.name,
60
+ error_message: e.message,
61
+ checked_at: checked_at
62
+ }
63
+ end
64
+
65
+ # Build a Prometheus text exposition document.
66
+ #
67
+ # @return [String] Prometheus metrics text
68
+ def prometheus
69
+ store = operational_store.bootstrap!
70
+ checkpoint_store = CheckpointStore.new(store)
71
+ dead_letter_store = DeadLetterStore.new(store)
72
+ delivered_store = DeliveredEnvelopeStore.new(store)
73
+
74
+ lines = metric_headers + [
75
+ metric_line("mammoth_up", 1),
76
+ metric_line("mammoth_checkpoints_total", checkpoint_store.count),
77
+ metric_line("mammoth_dead_letters_total", dead_letter_store.count),
78
+ metric_line("mammoth_dead_letters_pending_total", dead_letter_store.count(status: "pending")),
79
+ metric_line("mammoth_dead_letters_resolved_total", dead_letter_store.count(status: "resolved")),
80
+ metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
81
+ metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
82
+ ]
83
+ "#{lines.join("\n")}\n"
84
+ rescue Mammoth::Error, SQLite3::Exception
85
+ "#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
86
+ end
87
+
88
+ private
89
+
90
+ def operational_store
91
+ sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
92
+ end
93
+
94
+ def mammoth_name
95
+ config.dig("mammoth", "name")
96
+ end
97
+
98
+ def checked_at
99
+ clock.call.utc.iso8601
100
+ end
101
+
102
+ def metric_headers
103
+ [
104
+ "# HELP mammoth_up 1 when Mammoth operational state can be inspected, 0 otherwise.",
105
+ "# TYPE mammoth_up gauge",
106
+ "# HELP mammoth_checkpoints_total Number of checkpoint rows stored by Mammoth.",
107
+ "# TYPE mammoth_checkpoints_total gauge",
108
+ "# HELP mammoth_dead_letters_total Number of dead-letter rows stored by Mammoth.",
109
+ "# TYPE mammoth_dead_letters_total gauge",
110
+ "# HELP mammoth_dead_letters_pending_total Number of pending dead-letter rows.",
111
+ "# TYPE mammoth_dead_letters_pending_total gauge",
112
+ "# HELP mammoth_dead_letters_resolved_total Number of resolved dead-letter rows.",
113
+ "# TYPE mammoth_dead_letters_resolved_total gauge",
114
+ "# HELP mammoth_dead_letters_ignored_total Number of ignored dead-letter rows.",
115
+ "# TYPE mammoth_dead_letters_ignored_total gauge",
116
+ "# HELP mammoth_delivered_envelopes_total Number of delivered-envelope ledger rows.",
117
+ "# TYPE mammoth_delivered_envelopes_total gauge"
118
+ ]
119
+ end
120
+
121
+ def metric_line(name, value)
122
+ %(#{name}{mammoth_name="#{escape_label(mammoth_name)}"} #{Integer(value)})
123
+ end
124
+
125
+ def escape_label(value)
126
+ value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
127
+ end
128
+ end
129
+ end
@@ -30,7 +30,7 @@ module Mammoth
30
30
  puts "Replication publications: #{Array(config.dig("replication", "publications")).join(", ")}"
31
31
  puts "Runtime: not started"
32
32
  puts "SQLite: #{sqlite_path}"
33
- puts "Webhook: #{config.dig("webhook", "name")}"
33
+ puts "Destinations: #{destination_names.join(", ")}"
34
34
  print_store_state if sqlite_store
35
35
  end
36
36
 
@@ -40,6 +40,13 @@ module Mammoth
40
40
  config.dig("sqlite", "path")
41
41
  end
42
42
 
43
+ def destination_names
44
+ destinations = config.data["destinations"]
45
+ return destinations.map { |destination| destination.fetch("name") } if destinations
46
+
47
+ [config.dig("webhook", "name")]
48
+ end
49
+
43
50
  def print_store_state
44
51
  store = sqlite_store.bootstrap!
45
52
  puts "Tables: #{store.tables.join(", ")}"
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
@@ -39,43 +39,52 @@ module Mammoth
39
39
  # @param config [Mammoth::Configuration] loaded configuration
40
40
  # @return [Mammoth::WebhookSink]
41
41
  def from_config(config)
42
+ from_destination_config(config.data["webhook"], label: "webhook")
43
+ end
44
+
45
+ # Build a sink from one destination configuration entry.
46
+ #
47
+ # @param destination [Hash] destination configuration
48
+ # @param label [String] configuration path used in error messages
49
+ # @return [Mammoth::WebhookSink]
50
+ def from_destination_config(destination, label: "destination")
42
51
  new(
43
- name: config.dig("webhook", "name"),
44
- url: config.dig("webhook", "url"),
45
- timeout_seconds: config.dig("webhook", "timeout_seconds"),
46
- headers: configured_headers(config),
47
- signing: configured_signing(config)
52
+ name: destination.fetch("name"),
53
+ url: destination.fetch("url"),
54
+ timeout_seconds: destination.fetch("timeout_seconds"),
55
+ headers: configured_headers(destination, label: label),
56
+ signing: configured_signing(destination, label: label)
48
57
  )
49
58
  end
50
59
 
51
60
  private
52
61
 
53
- def configured_headers(config)
54
- static_headers = config.dig("webhook", "headers") || {}
55
- env_headers = config.dig("webhook", "header_env") || {}
62
+ def configured_headers(destination, label:)
63
+ static_headers = destination["headers"] || {}
64
+ env_headers = destination["header_env"] || {}
56
65
 
57
- static_headers.merge(resolve_env_headers(env_headers))
66
+ static_headers.merge(resolve_env_headers(env_headers, label: label))
58
67
  end
59
68
 
60
- def resolve_env_headers(env_headers)
69
+ def resolve_env_headers(env_headers, label:)
61
70
  env_headers.each_with_object(Hash.new) do |(header, env_name), resolved| # rubocop:disable Style/EmptyLiteral
62
71
  resolved[header] = ENV.fetch(env_name) do
63
- raise ConfigurationError, "webhook.header_env.#{header} references missing environment variable #{env_name}"
72
+ raise ConfigurationError, "#{label}.header_env.#{header} references missing environment variable #{env_name}"
64
73
  end
65
74
  end
66
75
  end
67
76
 
68
- def configured_signing(config)
69
- signing = config.dig("webhook", "signing")
77
+ def configured_signing(destination, label:)
78
+ signing = destination["signing"]
70
79
  return unless signing
71
80
 
72
81
  algorithm = signing.fetch("algorithm", SIGNING_ALGORITHM)
73
- raise ConfigurationError, "webhook.signing.algorithm must be #{SIGNING_ALGORITHM}" unless algorithm == SIGNING_ALGORITHM
82
+ raise ConfigurationError, "#{label}.signing.algorithm must be #{SIGNING_ALGORITHM}" unless algorithm == SIGNING_ALGORITHM
74
83
 
75
84
  secret_env = signing.fetch("secret_env")
76
85
  {
77
86
  secret: ENV.fetch(secret_env) do
78
- raise ConfigurationError, "webhook.signing.secret_env references missing environment variable #{secret_env}"
87
+ raise ConfigurationError, "#{label}.signing.secret_env references missing environment variable #{secret_env}"
79
88
  end,
80
89
  signature_header: signing.fetch("signature_header", "X-Mammoth-Signature"),
81
90
  timestamp_header: signing.fetch("timestamp_header", "X-Mammoth-Timestamp")
data/lib/mammoth.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "mammoth/version"
4
4
  require_relative "mammoth/errors"
5
5
  require_relative "mammoth/configuration"
6
6
  require_relative "mammoth/status"
7
+ require_relative "mammoth/observability_snapshot"
8
+ require_relative "mammoth/observability_server"
7
9
  require_relative "mammoth/sqlite_store"
8
10
  require_relative "mammoth/checkpoint_store"
9
11
  require_relative "mammoth/dead_letter_store"
@@ -12,6 +14,7 @@ require_relative "mammoth/event_serializer"
12
14
  require_relative "mammoth/transaction_envelope_serializer"
13
15
  require_relative "mammoth/webhook_sink"
14
16
  require_relative "mammoth/delivery_worker"
17
+ require_relative "mammoth/fanout_delivery_worker"
15
18
  require_relative "mammoth/delivery_processor"
16
19
  require_relative "mammoth/concurrent_delivery_runtime"
17
20
  require_relative "mammoth/sources/postgres"
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: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -157,6 +157,9 @@ files:
157
157
  - lib/mammoth/delivery_worker.rb
158
158
  - lib/mammoth/errors.rb
159
159
  - lib/mammoth/event_serializer.rb
160
+ - lib/mammoth/fanout_delivery_worker.rb
161
+ - lib/mammoth/observability_server.rb
162
+ - lib/mammoth/observability_snapshot.rb
160
163
  - lib/mammoth/replication_consumer.rb
161
164
  - lib/mammoth/sources/postgres.rb
162
165
  - lib/mammoth/sql/__bootstrap__.sql