mammoth 0.5.0 → 0.6.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: 8f299dc783af6453331a635200b8fe3ddf03159b156a3a0cec6cc6377ede1c34
4
- data.tar.gz: f56eabc963912066610c22a98af55af9a6db192f40869dfb1d5743c8f1eff98e
3
+ metadata.gz: b1249f6166082904d6ecac8d74819442681eaac72ae1e23c80a2134c412e235a
4
+ data.tar.gz: 292272e0696a218c3a75d970476efa3acaa71d54164da139aefa007bbd256ae5
5
5
  SHA512:
6
- metadata.gz: c3167effc0333430719118b2ad3f8d8b53841c8c584b1e45949e256411e1a275eb56b71bd023d4d3778e31ec92f179c338d5814220f11c251f447aad7eee0233
7
- data.tar.gz: d6db35472dfb2847e00f2158fd8677f26da5cb3c19d91e002566d598d9ecd8d67681e35dc2310ffd7fd41dcc0aec5535e822fd52f55f3865f9f65cc267633383
6
+ metadata.gz: 4e401e3a8a1778ac6b63338636cc15d5c06ccbeea77e2179af1189ba8d60d1005a2281884e3cc3a09208ac4836f37d94132ab16c3f00b08edfb1c3347fe0e944
7
+ data.tar.gz: fe8efe54a12f5dae1960eb4cf34c91b1eacd1d91a8609c082349b2a4e900ebda7f5160052b8f7d3bf062b09d16bb84438712f0f7d514ff23b4b16606da8b279a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.0
6
+
7
+ ### Added
8
+
9
+ - Added fanout route filters by schema, table, and operation for webhook destinations.
10
+ - Added destination `enabled` controls for config-driven delivery cutovers.
11
+ - Added per-destination retry policy overrides for `max_attempts` and `schedule_seconds`.
12
+ - Added dead-letter replay filters by destination, status, and failed-at time window.
13
+ - Added destination-labeled Prometheus metrics for dead-letter and delivered-envelope counts.
14
+
15
+ ### Changed
16
+
17
+ - Bumped Mammoth version to `0.6.0`.
18
+
19
+ ## 0.5.1
20
+
21
+ ### Added
22
+
23
+ - Added benchmark scripts for webhook delivery, webhook fanout, SQLite operational state, observability snapshots, and dead-letter replay.
24
+ - Added shared benchmark helpers for synthetic CDC work items, local HTTP receivers, table output, JSON output, and environment-driven benchmark knobs.
25
+ - Added a benchmark snapshot runner that writes Markdown, JSON, and per-trial logs for publishable benchmark results.
26
+
27
+ ### Changed
28
+
29
+ - Aligned benchmark documentation across `README.md`, `docs/BENCHMARKS.md`, and `benchmark/README.md` around operator config tuning.
30
+
5
31
  ## 0.5.0
6
32
 
7
33
  ### Added
data/README.md CHANGED
@@ -47,8 +47,10 @@ https://kanutocd.github.io/mammoth/Mammoth.html
47
47
  - dead letter persistence
48
48
  - webhook delivery sink
49
49
  - webhook fanout to multiple destinations
50
+ - fanout route filters by schema, table, and operation
51
+ - per-destination enable/disable and retry policy controls
50
52
  - delivery worker with retry, checkpoint, and DLQ handling
51
- - dead-letter inspection and replay commands
53
+ - dead-letter inspection and filtered replay commands
52
54
  - CDC-core event serialization boundary
53
55
  - CDC Ecosystem source-adapter integration boundary
54
56
  - Docker image support
@@ -78,6 +80,24 @@ Validate configuration:
78
80
  bundle exec ./exe/mammoth validate config/mammoth.example.yml
79
81
  ```
80
82
 
83
+ Fanout destinations can be routed and tuned independently:
84
+
85
+ ```yaml
86
+ destinations:
87
+ - name: audit_webhook
88
+ type: webhook
89
+ enabled: true
90
+ url: https://audit.example.com/cdc
91
+ timeout_seconds: 5
92
+ route:
93
+ schemas: [public]
94
+ tables: [orders]
95
+ operations: [insert, update]
96
+ retry:
97
+ max_attempts: 3
98
+ schedule_seconds: [1, 10]
99
+ ```
100
+
81
101
  ## CLI
82
102
 
83
103
  ```bash
@@ -108,47 +128,27 @@ Mammoth stores operational memory in SQLite:
108
128
 
109
129
  ## Performance
110
130
 
111
- Mammoth scales downstream delivery throughput using the `cdc-concurrent` runtime while maintaining a single PostgreSQL logical replication stream.
112
-
113
- ### Concurrent Delivery Benchmark
114
-
115
- Environment:
116
-
117
- * 10,000 transactions
118
- * 4 events per transaction
119
- * 40,000 total events
120
-
121
- #### Fast Downstream (10ms)
122
-
123
- | Concurrency | Transactions/sec |
124
- | ----------- | ---------------: |
125
- | 1 | 96.50 |
126
- | 25 | 2419.65 |
131
+ Mammoth includes local benchmarks for the product surfaces operators tune in
132
+ production:
127
133
 
128
- #### Realistic Webhook (50ms)
134
+ - concurrent delivery runtime
135
+ - real webhook delivery
136
+ - multi-destination webhook fanout
137
+ - SQLite operational state
138
+ - observability snapshots
139
+ - DLQ replay
129
140
 
130
- | Concurrency | Transactions/sec |
131
- | ----------- | ---------------: |
132
- | 1 | 19.85 |
133
- | 25 | 495.11 |
141
+ The historical numbers in [Benchmarks](https://github.com/kanutocd/mammoth/tree/main/docs/BENCHMARKS.md) are retained as a
142
+ snapshot, not a universal performance claim. Re-run the scripts in
143
+ [`benchmark/`](https://github.com/kanutocd/mammoth/tree/main/benchmark) on your own hardware when choosing
144
+ `runtime.concurrency`, `destinations`, SQLite storage, scrape frequency, and
145
+ DLQ replay expectations.
134
146
 
135
- Observed throughput improved from:
147
+ Create a publishable benchmark snapshot with:
136
148
 
137
- - 96.50 → 2419.65 transactions/sec (10ms sink latency)
138
- - 19.85 495.11 transactions/sec (50ms sink latency)
139
-
140
- when increasing delivery concurrency from 1 to 25.
141
-
142
- The benchmark demonstrates near-linear scaling of delivery throughput without increasing PostgreSQL replication connections.
143
-
144
- See:
145
-
146
- - docs/BENCHMARKS.md
147
- - examples/transaction_webhook
148
-
149
- For full benchmark methodology and results see:
150
-
151
- See [Benchmarks](docs/BENCHMARKS.md).
149
+ ```bash
150
+ bundle exec ruby benchmark/snapshot.rb
151
+ ```
152
152
 
153
153
  ## E2E
154
154
 
@@ -275,6 +275,7 @@ webhook:
275
275
  # destinations:
276
276
  # - name: primary_webhook
277
277
  # type: webhook
278
+ # enabled: true
278
279
  # url: https://example.com/webhooks/postgres
279
280
  # timeout_seconds: 5
280
281
  # header_env:
@@ -284,8 +285,28 @@ webhook:
284
285
  # secret_env: MAMMOTH_PRIMARY_WEBHOOK_SIGNING_SECRET
285
286
  # - name: audit_webhook
286
287
  # type: webhook
288
+ # enabled: true
287
289
  # url: https://audit.example.com/cdc
288
290
  # timeout_seconds: 5
291
+ #
292
+ # # Optional route filter. Omit route to receive every event/envelope.
293
+ # # For transaction delivery, a destination matches when any event in the
294
+ # # transaction matches; Mammoth still delivers the full transaction envelope.
295
+ # route:
296
+ # schemas:
297
+ # - public
298
+ # tables:
299
+ # - orders
300
+ # operations:
301
+ # - insert
302
+ # - update
303
+ #
304
+ # # Optional per-destination retry override. Omit to use top-level retry.
305
+ # retry:
306
+ # max_attempts: 3
307
+ # schedule_seconds:
308
+ # - 1
309
+ # - 10
289
310
 
290
311
  retry:
291
312
  # Maximum delivery attempts before the event/envelope is moved to the dead
@@ -223,7 +223,11 @@
223
223
  "enum": [
224
224
  "webhook"
225
225
  ],
226
- "description": "Destination adapter type. Mammoth OSS 0.5.0 supports webhook destinations."
226
+ "description": "Destination adapter type. Mammoth OSS 0.6.0 supports webhook destinations."
227
+ },
228
+ "enabled": {
229
+ "type": "boolean",
230
+ "description": "When false, Mammoth skips this destination, advances local progress for the work item, and does not attempt delivery."
227
231
  },
228
232
  "url": {
229
233
  "type": "string",
@@ -281,6 +285,61 @@
281
285
  }
282
286
  },
283
287
  "description": "Optional HMAC-SHA256 request signing for this webhook destination."
288
+ },
289
+ "route": {
290
+ "type": "object",
291
+ "additionalProperties": false,
292
+ "properties": {
293
+ "schemas": {
294
+ "type": "array",
295
+ "minItems": 1,
296
+ "items": {
297
+ "type": "string",
298
+ "minLength": 1
299
+ },
300
+ "description": "PostgreSQL schemas this destination should receive. Omit to match every schema."
301
+ },
302
+ "tables": {
303
+ "type": "array",
304
+ "minItems": 1,
305
+ "items": {
306
+ "type": "string",
307
+ "minLength": 1
308
+ },
309
+ "description": "PostgreSQL tables this destination should receive. Omit to match every table."
310
+ },
311
+ "operations": {
312
+ "type": "array",
313
+ "minItems": 1,
314
+ "items": {
315
+ "type": "string",
316
+ "minLength": 1
317
+ },
318
+ "description": "CDC operations this destination should receive, such as insert, update, or delete. Omit to match every operation."
319
+ }
320
+ },
321
+ "description": "Optional destination route filter. Event delivery matches one event; transaction delivery matches when any event in the transaction matches and delivers the full envelope."
322
+ },
323
+ "retry": {
324
+ "type": "object",
325
+ "additionalProperties": false,
326
+ "properties": {
327
+ "max_attempts": {
328
+ "type": "integer",
329
+ "minimum": 1,
330
+ "description": "Destination-specific maximum delivery attempts before dead-lettering. Defaults to the top-level retry.max_attempts."
331
+ },
332
+ "schedule_seconds": {
333
+ "type": "array",
334
+ "minItems": 1,
335
+ "items": {
336
+ "type": "integer",
337
+ "minimum": 1
338
+ },
339
+ "description": "Destination-specific retry backoff schedule in seconds. Defaults to the top-level retry.schedule_seconds."
340
+ }
341
+ },
342
+ "description": "Optional retry policy override for this destination."
284
343
  }
285
344
  }
286
345
  },
@@ -87,32 +87,51 @@ module Mammoth
87
87
  Sources::Postgres.new(config, checkpoint_store: checkpoint_store)
88
88
  end
89
89
 
90
- def build_delivery_worker(sink:, sleeper:)
90
+ def build_delivery_worker(sink:, sleeper:, delivery_policy: {})
91
91
  DeliveryWorker.from_config(
92
92
  config,
93
93
  sink: sink,
94
94
  checkpoint_store: checkpoint_store,
95
95
  dead_letter_store: DeadLetterStore.new(sqlite_store),
96
- sleeper: sleeper
96
+ sleeper: sleeper,
97
+ delivery_policy: delivery_policy
97
98
  )
98
99
  end
99
100
 
100
101
  def build_configured_delivery_worker(sleeper:)
101
- workers = destination_sinks.map { |sink| build_delivery_worker(sink:, sleeper:) }
102
+ workers = destination_specs.map do |spec|
103
+ build_delivery_worker(sink: spec.fetch(:sink), sleeper:, delivery_policy: spec.fetch(:delivery_policy))
104
+ end
102
105
  return workers.fetch(0) if workers.one?
103
106
 
104
107
  FanoutDeliveryWorker.new(workers)
105
108
  end
106
109
 
107
- def destination_sinks
110
+ def destination_specs
108
111
  destinations = config.data["destinations"]
109
- return [WebhookSink.from_config(config)] unless destinations
112
+ unless destinations
113
+ delivery_policy = {} # : Hash[String, untyped]
114
+ return [{ sink: WebhookSink.from_config(config), delivery_policy: delivery_policy }]
115
+ end
110
116
 
111
117
  destinations.map.with_index(1) do |destination, index|
112
- WebhookSink.from_destination_config(destination, label: "destinations[#{index - 1}]")
118
+ {
119
+ sink: WebhookSink.from_destination_config(destination, label: "destinations[#{index - 1}]"),
120
+ delivery_policy: destination_delivery_policy(destination)
121
+ }
113
122
  end
114
123
  end
115
124
 
125
+ def destination_delivery_policy(destination)
126
+ retry_config = destination["retry"] || {}
127
+ {
128
+ "enabled" => destination.fetch("enabled", true),
129
+ "max_attempts" => retry_config.fetch("max_attempts", config.dig("retry", "max_attempts")),
130
+ "schedule_seconds" => retry_config.fetch("schedule_seconds", config.dig("retry", "schedule_seconds")),
131
+ "route_filter" => RouteFilter.new(destination["route"])
132
+ }
133
+ end
134
+
116
135
  def transaction_delivery?
117
136
  delivery_unit == :transaction
118
137
  end
data/lib/mammoth/cli.rb CHANGED
@@ -24,9 +24,11 @@ module Mammoth
24
24
  " mammoth status CONFIG",
25
25
  " mammoth start CONFIG",
26
26
  " mammoth deliver-sample CONFIG EVENT_JSON",
27
- " mammoth dead-letters list CONFIG [--status STATUS] [--limit N]",
27
+ " mammoth dead-letters list CONFIG [--status STATUS] [--destination NAME] " \
28
+ "[--failed-after ISO8601] [--failed-before ISO8601] [--limit N]",
28
29
  " mammoth dead-letters show CONFIG ID",
29
- " mammoth dead-letters replay CONFIG [ID ...]",
30
+ " mammoth dead-letters replay CONFIG [ID ...] [--destination NAME] [--status STATUS] " \
31
+ "[--failed-after ISO8601] [--failed-before ISO8601] [--limit N]",
30
32
  " mammoth observability CONFIG"
31
33
  ].join("\n")
32
34
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module Mammoth
6
7
  # Operator commands for inspecting and replaying dead letters.
@@ -55,7 +56,14 @@ module Mammoth
55
56
  end
56
57
 
57
58
  def list
58
- rows = dead_letter_store.rows(status: list_options.fetch(:status), limit: list_options.fetch(:limit))
59
+ options = list_options
60
+ rows = dead_letter_store.rows(
61
+ status: options.fetch(:status),
62
+ destination: options.fetch(:destination),
63
+ failed_after: options.fetch(:failed_after),
64
+ failed_before: options.fetch(:failed_before),
65
+ limit: options.fetch(:limit)
66
+ )
59
67
  puts list_header
60
68
  rows.each { |row| puts list_row(row) }
61
69
  0
@@ -73,7 +81,7 @@ module Mammoth
73
81
 
74
82
  rows.each do |row|
75
83
  result = replay_row(row)
76
- dead_letter_store.resolve(row.fetch("id")) unless result.fetch(:status) == "dead_lettered"
84
+ dead_letter_store.resolve(row.fetch("id")) if replay_resolved?(result)
77
85
  puts replay_message(row, result)
78
86
  end
79
87
  0
@@ -106,7 +114,7 @@ module Mammoth
106
114
  #
107
115
  # @return [Hash] list options
108
116
  def list_options
109
- options = { status: "pending", limit: 100 }
117
+ options = { status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
110
118
  index = 0
111
119
  args = argv.drop(3)
112
120
 
@@ -122,17 +130,44 @@ module Mammoth
122
130
  def parse_list_option(args, index, options)
123
131
  case args.fetch(index)
124
132
  when "--status"
125
- options[:status] = args.fetch(index + 1)
126
- index + 2
133
+ assign_option(args, index, options, :status)
134
+ when "--destination"
135
+ assign_option(args, index, options, :destination)
136
+ when "--failed-after"
137
+ assign_time_option(args, index, options, :failed_after)
138
+ when "--failed-before"
139
+ assign_time_option(args, index, options, :failed_before)
127
140
  when "--limit"
128
- options[:limit] = Integer(args.fetch(index + 1))
129
- index + 2
141
+ assign_limit_option(args, index, options)
130
142
  else
131
143
  raise ConfigurationError, "unknown option #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
132
144
 
133
145
  raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}"
134
146
  end
135
- rescue ArgumentError, IndexError
147
+ rescue ArgumentError
148
+ raise ConfigurationError, "invalid dead letter list option"
149
+ rescue IndexError
150
+ raise ConfigurationError, "missing value for dead letter option"
151
+ end
152
+
153
+ def assign_option(args, index, options, key)
154
+ options[key] = args.fetch(index + 1)
155
+ index + 2
156
+ end
157
+
158
+ def assign_time_option(args, index, options, key)
159
+ options[key] = parse_time_option(args.fetch(index + 1), args.fetch(index))
160
+ index + 2
161
+ end
162
+
163
+ def assign_limit_option(args, index, options)
164
+ options[:limit] = parse_limit_option(args.fetch(index + 1))
165
+ index + 2
166
+ end
167
+
168
+ def parse_limit_option(value)
169
+ Integer(value)
170
+ rescue ArgumentError
136
171
  raise ConfigurationError, "dead letter limit must be an integer"
137
172
  end
138
173
 
@@ -140,8 +175,17 @@ module Mammoth
140
175
  #
141
176
  # @return [Array<Hash>] replay rows
142
177
  def replay_rows
143
- ids = argv.drop(3)
144
- return dead_letter_store.pending if ids.empty?
178
+ options = replay_options
179
+ ids = options.fetch(:ids)
180
+ if ids.empty?
181
+ return dead_letter_store.rows(
182
+ status: options.fetch(:status),
183
+ destination: options.fetch(:destination),
184
+ failed_after: options.fetch(:failed_after),
185
+ failed_before: options.fetch(:failed_before),
186
+ limit: options.fetch(:limit)
187
+ )
188
+ end
145
189
 
146
190
  ids.map do |raw_id|
147
191
  id = Integer(raw_id)
@@ -154,6 +198,41 @@ module Mammoth
154
198
  end
155
199
  end
156
200
 
201
+ def parse_time_option(value, option_name)
202
+ Time.iso8601(value).utc.iso8601
203
+ rescue ArgumentError
204
+ raise ConfigurationError, "#{option_name} must be an ISO-8601 timestamp"
205
+ end
206
+
207
+ def replay_options
208
+ ids = [] # : Array[Integer]
209
+ options = { ids: ids, status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
210
+ index = 0
211
+ args = argv.drop(3)
212
+
213
+ # rubocop:disable Style/WhileUntilModifier
214
+ while index < args.length
215
+ index = parse_replay_option(args, index, options)
216
+ end
217
+ # rubocop:enable Style/WhileUntilModifier
218
+
219
+ options
220
+ end
221
+
222
+ def parse_replay_option(args, index, options)
223
+ case args.fetch(index)
224
+ when "--status", "--destination", "--failed-after", "--failed-before", "--limit"
225
+ parse_list_option(args, index, options)
226
+ else
227
+ raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
228
+
229
+ options[:ids] << Integer(args.fetch(index))
230
+ index + 1
231
+ end
232
+ rescue ArgumentError
233
+ raise ConfigurationError, "dead letter id must be an integer"
234
+ end
235
+
157
236
  def fetch_dead_letter!(id)
158
237
  row = dead_letter_store.fetch(id)
159
238
  raise ConfigurationError, "dead letter not found: #{id}" unless row
@@ -182,6 +261,10 @@ module Mammoth
182
261
  worker.deliver_transaction(envelope)
183
262
  end
184
263
 
264
+ def replay_resolved?(result)
265
+ result.fetch(:status) == "delivered" || result.fetch(:duplicate, false)
266
+ end
267
+
185
268
  def transaction_payload?(payload)
186
269
  payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
187
270
  end
@@ -65,8 +65,9 @@ module Mammoth
65
65
  #
66
66
  # @param limit [Integer] maximum number of rows
67
67
  # @return [Array<Hash>] pending dead letter rows
68
- def pending(limit: 100)
69
- rows(status: "pending", limit: limit)
68
+ def pending(limit: 100, destination: nil, failed_after: nil, failed_before: nil)
69
+ rows(status: "pending", limit: limit, destination: destination, failed_after: failed_after,
70
+ failed_before: failed_before)
70
71
  end
71
72
 
72
73
  # Fetch dead letters, optionally filtered by status.
@@ -74,15 +75,12 @@ module Mammoth
74
75
  # @param status [String, nil] optional dead-letter status filter
75
76
  # @param limit [Integer] maximum number of rows
76
77
  # @return [Array<Hash>] dead letter rows
77
- def rows(status: nil, limit: 100)
78
- if status && status != "all"
79
- return database.execute(
80
- "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
81
- [status, limit]
82
- )
83
- end
84
-
85
- database.execute("SELECT * FROM dead_letters ORDER BY failed_at ASC LIMIT ?", [limit])
78
+ def rows(status: nil, limit: 100, destination: nil, failed_after: nil, failed_before: nil)
79
+ where, values = row_filters(status:, destination:, failed_after:, failed_before:)
80
+ database.execute(
81
+ "SELECT * FROM dead_letters#{where} ORDER BY failed_at ASC LIMIT ?",
82
+ values + [limit]
83
+ )
86
84
  end
87
85
 
88
86
  # Fetch one dead letter by id.
@@ -97,12 +95,17 @@ module Mammoth
97
95
  #
98
96
  # @param status [String, nil] optional status
99
97
  # @return [Integer] dead letter count
100
- def count(status: nil)
101
- if status
102
- database.get_first_value("SELECT COUNT(*) FROM dead_letters WHERE status = ?", [status])
103
- else
104
- database.get_first_value("SELECT COUNT(*) FROM dead_letters")
105
- end
98
+ def count(status: nil, destination: nil)
99
+ where, values = row_filters(status:, destination:)
100
+ database.get_first_value("SELECT COUNT(*) FROM dead_letters#{where}", values)
101
+ end
102
+
103
+ def counts_by_destination(status: nil)
104
+ where, values = row_filters(status:)
105
+ database.execute(
106
+ "SELECT destination_name, COUNT(*) AS count FROM dead_letters#{where} GROUP BY destination_name",
107
+ values
108
+ )
106
109
  end
107
110
 
108
111
  # Mark a dead letter as resolved.
@@ -133,5 +136,29 @@ module Mammoth
133
136
  [status, Time.now.utc.iso8601, id]
134
137
  )
135
138
  end
139
+
140
+ def row_filters(status: nil, destination: nil, failed_after: nil, failed_before: nil)
141
+ predicates = [] # : Array[String]
142
+ values = [] # : Array[untyped]
143
+
144
+ unless status.nil? || status == "all"
145
+ predicates << "status = ?"
146
+ values << status
147
+ end
148
+ if destination
149
+ predicates << "destination_name = ?"
150
+ values << destination
151
+ end
152
+ if failed_after
153
+ predicates << "failed_at >= ?"
154
+ values << failed_after
155
+ end
156
+ if failed_before
157
+ predicates << "failed_at <= ?"
158
+ values << failed_before
159
+ end
160
+
161
+ [predicates.empty? ? "" : " WHERE #{predicates.join(" AND ")}", values]
162
+ end
136
163
  end
137
164
  end
@@ -85,8 +85,18 @@ module Mammoth
85
85
  # Count delivered envelopes.
86
86
  #
87
87
  # @return [Integer] delivered envelope count
88
- def count
89
- database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
88
+ def count(destination: nil)
89
+ if destination
90
+ database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes WHERE destination_name = ?", [destination])
91
+ else
92
+ database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
93
+ end
94
+ end
95
+
96
+ def counts_by_destination
97
+ database.execute(
98
+ "SELECT destination_name, COUNT(*) AS count FROM delivered_envelopes GROUP BY destination_name"
99
+ )
90
100
  end
91
101
 
92
102
  private
@@ -12,7 +12,7 @@ module Mammoth
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
15
+ :sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
16
16
 
17
17
  # @param sink [#deliver] destination sink
18
18
  # @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
@@ -24,8 +24,11 @@ module Mammoth
24
24
  # @param max_attempts [Integer] maximum delivery attempts
25
25
  # @param retry_schedule [Array<Integer>] retry wait schedule in seconds
26
26
  # @param sleeper [#call] sleep strategy, injectable for tests
27
+ # @param route_filter [Mammoth::RouteFilter, nil] optional destination route matcher
28
+ # @param enabled [Boolean] whether this destination accepts new deliveries
27
29
  def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
28
- max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep))
30
+ max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep),
31
+ route_filter: nil, enabled: true)
29
32
  @sink = sink
30
33
  @checkpoint_store = checkpoint_store
31
34
  @dead_letter_store = dead_letter_store
@@ -36,6 +39,8 @@ module Mammoth
36
39
  @max_attempts = max_attempts
37
40
  @retry_schedule = retry_schedule
38
41
  @sleeper = sleeper
42
+ @route_filter = route_filter || RouteFilter.new
43
+ @enabled = enabled
39
44
  end
40
45
 
41
46
  # Build a delivery worker from Mammoth configuration and stores.
@@ -46,7 +51,8 @@ module Mammoth
46
51
  # @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
47
52
  # @param sleeper [#call] sleep strategy
48
53
  # @return [Mammoth::DeliveryWorker]
49
- def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep))
54
+ def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep),
55
+ delivery_policy: {})
50
56
  new(
51
57
  sink: sink,
52
58
  checkpoint_store: checkpoint_store,
@@ -54,9 +60,11 @@ module Mammoth
54
60
  source_name: config.dig("mammoth", "name"),
55
61
  slot_name: config.dig("replication", "slot"),
56
62
  publication_name: Array(config.dig("replication", "publications")).join(","),
57
- max_attempts: config.dig("retry", "max_attempts"),
58
- retry_schedule: config.dig("retry", "schedule_seconds"),
59
- sleeper: sleeper
63
+ max_attempts: delivery_policy.fetch("max_attempts", config.dig("retry", "max_attempts")),
64
+ retry_schedule: delivery_policy.fetch("schedule_seconds", config.dig("retry", "schedule_seconds")),
65
+ sleeper: sleeper,
66
+ route_filter: delivery_policy.fetch("route_filter", RouteFilter.new),
67
+ enabled: delivery_policy.fetch("enabled", true)
60
68
  )
61
69
  end
62
70
 
@@ -85,6 +93,9 @@ module Mammoth
85
93
  delivery_unit = delivery_unit_for(delivery_method)
86
94
  idempotency_key = idempotency_key_for(payload:, delivery_unit:)
87
95
 
96
+ skip_result = skip_result_for(payload, idempotency_key:)
97
+ return skip_result if skip_result
98
+
88
99
  if delivered_envelope_store.delivered?(idempotency_key)
89
100
  checkpoint_payload(payload)
90
101
  return {
@@ -151,6 +162,24 @@ module Mammoth
151
162
  sink.respond_to?(:name) ? sink.name : sink.class.name
152
163
  end
153
164
 
165
+ def skip_result_for(payload, idempotency_key:)
166
+ return skipped(payload, idempotency_key:, reason: "disabled") unless enabled
167
+
168
+ skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
169
+ end
170
+
171
+ def skipped(payload, idempotency_key:, reason:)
172
+ checkpoint_payload(payload)
173
+ {
174
+ status: "skipped",
175
+ reason: reason,
176
+ duplicate: false,
177
+ idempotency_key: idempotency_key,
178
+ attempts: 0,
179
+ destination: destination_name
180
+ }
181
+ end
182
+
154
183
  def dead_letter(event, error, attempts, serializer:)
155
184
  id = dead_letter_store.write(
156
185
  event: event,
@@ -71,7 +71,20 @@ module Mammoth
71
71
  dead_letter_store = DeadLetterStore.new(store)
72
72
  delivered_store = DeliveredEnvelopeStore.new(store)
73
73
 
74
- lines = metric_headers + [
74
+ lines = metric_headers + aggregate_metric_lines(
75
+ checkpoint_store: checkpoint_store,
76
+ dead_letter_store: dead_letter_store,
77
+ delivered_store: delivered_store
78
+ ) + destination_metric_lines(dead_letter_store:, delivered_store:)
79
+ "#{lines.join("\n")}\n"
80
+ rescue Mammoth::Error, SQLite3::Exception
81
+ "#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
82
+ end
83
+
84
+ private
85
+
86
+ def aggregate_metric_lines(checkpoint_store:, dead_letter_store:, delivered_store:)
87
+ [
75
88
  metric_line("mammoth_up", 1),
76
89
  metric_line("mammoth_checkpoints_total", checkpoint_store.count),
77
90
  metric_line("mammoth_dead_letters_total", dead_letter_store.count),
@@ -80,12 +93,24 @@ module Mammoth
80
93
  metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
81
94
  metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
82
95
  ]
83
- "#{lines.join("\n")}\n"
84
- rescue Mammoth::Error, SQLite3::Exception
85
- "#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
86
96
  end
87
97
 
88
- private
98
+ def destination_metric_lines(dead_letter_store:, delivered_store:)
99
+ destination_names.flat_map do |destination|
100
+ [
101
+ metric_line("mammoth_dead_letters_total", dead_letter_store.count(destination: destination),
102
+ destination: destination),
103
+ metric_line("mammoth_dead_letters_pending_total",
104
+ dead_letter_store.count(status: "pending", destination: destination), destination: destination),
105
+ metric_line("mammoth_dead_letters_resolved_total",
106
+ dead_letter_store.count(status: "resolved", destination: destination), destination: destination),
107
+ metric_line("mammoth_dead_letters_ignored_total",
108
+ dead_letter_store.count(status: "ignored", destination: destination), destination: destination),
109
+ metric_line("mammoth_delivered_envelopes_total", delivered_store.count(destination: destination),
110
+ destination: destination)
111
+ ]
112
+ end
113
+ end
89
114
 
90
115
  def operational_store
91
116
  sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
@@ -95,6 +120,13 @@ module Mammoth
95
120
  config.dig("mammoth", "name")
96
121
  end
97
122
 
123
+ def destination_names
124
+ destinations = config.data["destinations"]
125
+ return destinations.map { |destination| destination.fetch("name") } if destinations
126
+
127
+ [config.dig("webhook", "name")]
128
+ end
129
+
98
130
  def checked_at
99
131
  clock.call.utc.iso8601
100
132
  end
@@ -118,8 +150,10 @@ module Mammoth
118
150
  ]
119
151
  end
120
152
 
121
- def metric_line(name, value)
122
- %(#{name}{mammoth_name="#{escape_label(mammoth_name)}"} #{Integer(value)})
153
+ def metric_line(name, value, destination: nil)
154
+ labels = { "mammoth_name" => mammoth_name }
155
+ labels["destination"] = destination if destination
156
+ %(#{name}{#{labels.map { |label, label_value| %(#{label}="#{escape_label(label_value)}") }.join(",")}} #{Integer(value)})
123
157
  end
124
158
 
125
159
  def escape_label(value)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Matches CDC events and transaction envelopes against destination route rules.
5
+ class RouteFilter
6
+ attr_reader :schemas, :tables, :operations
7
+
8
+ def initialize(config = nil)
9
+ route = config || {}
10
+ @schemas = normalized_set(route["schemas"])
11
+ @tables = normalized_set(route["tables"])
12
+ @operations = normalized_set(route["operations"])
13
+ end
14
+
15
+ def match?(work, serializer:)
16
+ match_payload?(serializer.call(work))
17
+ end
18
+
19
+ def match_payload?(payload)
20
+ if transaction_payload?(payload)
21
+ payload.fetch("events").any? { |event| event_match?(event) }
22
+ else
23
+ event_match?(payload)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def normalized_set(values)
30
+ return [] unless values
31
+
32
+ values.map { |value| value.to_s.downcase }.freeze
33
+ end
34
+
35
+ def transaction_payload?(payload)
36
+ payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
37
+ end
38
+
39
+ def event_match?(payload)
40
+ matches?(schemas, payload["namespace"]) &&
41
+ matches?(tables, payload["entity"]) &&
42
+ matches?(operations, payload["operation"])
43
+ end
44
+
45
+ def matches?(allowed_values, actual_value)
46
+ allowed_values.empty? || allowed_values.include?(actual_value.to_s.downcase)
47
+ end
48
+ end
49
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
data/lib/mammoth.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "mammoth/dead_letter_store"
12
12
  require_relative "mammoth/delivered_envelope_store"
13
13
  require_relative "mammoth/event_serializer"
14
14
  require_relative "mammoth/transaction_envelope_serializer"
15
+ require_relative "mammoth/route_filter"
15
16
  require_relative "mammoth/webhook_sink"
16
17
  require_relative "mammoth/delivery_worker"
17
18
  require_relative "mammoth/fanout_delivery_worker"
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -161,6 +161,7 @@ files:
161
161
  - lib/mammoth/observability_server.rb
162
162
  - lib/mammoth/observability_snapshot.rb
163
163
  - lib/mammoth/replication_consumer.rb
164
+ - lib/mammoth/route_filter.rb
164
165
  - lib/mammoth/sources/postgres.rb
165
166
  - lib/mammoth/sql/__bootstrap__.sql
166
167
  - lib/mammoth/sqlite_store.rb