mammoth 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-04/schema#",
3
3
  "id": "https://kanutocd.github.io/mammoth/schema/mammoth.schema.json",
4
- "title": "Mammoth Configuration",
4
+ "title": "Mammoth Configuration",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
-
8
7
  "required": [
9
8
  "mammoth",
10
9
  "postgres",
@@ -14,20 +13,22 @@
14
13
  "sqlite",
15
14
  "logging"
16
15
  ],
17
-
18
16
  "properties": {
19
17
  "mammoth": {
20
18
  "type": "object",
21
19
  "additionalProperties": false,
22
- "required": ["name"],
20
+ "required": [
21
+ "name"
22
+ ],
23
23
  "properties": {
24
24
  "name": {
25
25
  "type": "string",
26
- "minLength": 1
26
+ "minLength": 1,
27
+ "description": "Human-readable Mammoth instance name used in logs, status output, metrics, and future dashboards. Keep stable per environment."
27
28
  }
28
- }
29
+ },
30
+ "description": "Mammoth instance identity and operator-facing metadata."
29
31
  },
30
-
31
32
  "postgres": {
32
33
  "type": "object",
33
34
  "additionalProperties": false,
@@ -41,28 +42,33 @@
41
42
  "properties": {
42
43
  "host": {
43
44
  "type": "string",
44
- "minLength": 1
45
+ "minLength": 1,
46
+ "description": "PostgreSQL host reachable from the Mammoth process or pod."
45
47
  },
46
48
  "port": {
47
49
  "type": "integer",
48
50
  "minimum": 1,
49
- "maximum": 65535
51
+ "maximum": 65535,
52
+ "description": "PostgreSQL TCP port."
50
53
  },
51
54
  "database": {
52
55
  "type": "string",
53
- "minLength": 1
56
+ "minLength": 1,
57
+ "description": "Source PostgreSQL database name."
54
58
  },
55
59
  "username": {
56
60
  "type": "string",
57
- "minLength": 1
61
+ "minLength": 1,
62
+ "description": "PostgreSQL user used by Mammoth. This user must be able to connect in replication mode and read publication metadata."
58
63
  },
59
64
  "password_env": {
60
65
  "type": "string",
61
- "minLength": 1
66
+ "minLength": 1,
67
+ "description": "Name of the environment variable containing the PostgreSQL password. The password itself should not be stored in YAML."
62
68
  }
63
- }
69
+ },
70
+ "description": "PostgreSQL connection settings for the source database."
64
71
  },
65
-
66
72
  "replication": {
67
73
  "type": "object",
68
74
  "additionalProperties": false,
@@ -73,7 +79,8 @@
73
79
  "properties": {
74
80
  "slot": {
75
81
  "type": "string",
76
- "minLength": 1
82
+ "minLength": 1,
83
+ "description": "PostgreSQL logical replication slot name. Permanent slots preserve WAL until Mammoth acknowledges progress. Changing this value creates a separate replication position."
77
84
  },
78
85
  "publications": {
79
86
  "type": "array",
@@ -81,25 +88,33 @@
81
88
  "items": {
82
89
  "type": "string",
83
90
  "minLength": 1
84
- }
91
+ },
92
+ "description": "PostgreSQL publications to subscribe to. Publications define which table changes PostgreSQL sends to Mammoth."
85
93
  },
86
94
  "start_lsn": {
87
- "type": ["string", "null"]
95
+ "type": [
96
+ "string",
97
+ "null"
98
+ ],
99
+ "description": "Optional Log Sequence Number for advanced recovery or controlled replay. Leave null or unset for normal checkpoint-based operation."
88
100
  },
89
101
  "auto_create_slot": {
90
- "type": "boolean"
102
+ "type": "boolean",
103
+ "description": "When true, Mammoth creates the replication slot if it does not exist. Convenient for development; DBA-managed slots are recommended for production."
91
104
  },
92
105
  "temporary_slot": {
93
- "type": "boolean"
106
+ "type": "boolean",
107
+ "description": "When true, use a temporary slot that disappears when the replication session ends. Durable deployments should use false."
94
108
  },
95
109
  "feedback_interval": {
96
110
  "type": "number",
97
111
  "minimum": 0,
98
- "exclusiveMinimum": true
112
+ "exclusiveMinimum": true,
113
+ "description": "Standby feedback interval in seconds. Lower values improve liveness/progress visibility; higher values reduce chatter."
99
114
  }
100
- }
115
+ },
116
+ "description": "Logical replication settings. Mammoth uses one replication slot and one WAL stream; downstream runtime concurrency does not create additional replication connections."
101
117
  },
102
-
103
118
  "webhook": {
104
119
  "type": "object",
105
120
  "additionalProperties": false,
@@ -111,19 +126,69 @@
111
126
  "properties": {
112
127
  "name": {
113
128
  "type": "string",
114
- "minLength": 1
129
+ "minLength": 1,
130
+ "description": "Destination name used in logs, dead-letter records, and future metrics."
115
131
  },
116
132
  "url": {
117
133
  "type": "string",
118
- "format": "uri"
134
+ "format": "uri",
135
+ "description": "Destination URL for delivered CDC payloads. Transaction delivery sends a TransactionEnvelope payload; event delivery sends a single event payload."
119
136
  },
120
137
  "timeout_seconds": {
121
138
  "type": "integer",
122
- "minimum": 1
139
+ "minimum": 1,
140
+ "description": "HTTP timeout in seconds for webhook delivery attempts."
141
+ },
142
+ "headers": {
143
+ "type": "object",
144
+ "additionalProperties": {
145
+ "type": "string"
146
+ },
147
+ "description": "Static HTTP headers added to every webhook request. Do not put secrets here; use header_env for secret-backed values."
148
+ },
149
+ "header_env": {
150
+ "type": "object",
151
+ "additionalProperties": {
152
+ "type": "string",
153
+ "minLength": 1
154
+ },
155
+ "description": "HTTP headers whose values are read from environment variables. Keys are header names; values are environment variable names."
156
+ },
157
+ "signing": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": [
161
+ "secret_env"
162
+ ],
163
+ "properties": {
164
+ "algorithm": {
165
+ "type": "string",
166
+ "enum": [
167
+ "hmac_sha256"
168
+ ],
169
+ "description": "Webhook signing algorithm. Mammoth signs '<timestamp>.<body>' using HMAC-SHA256."
170
+ },
171
+ "secret_env": {
172
+ "type": "string",
173
+ "minLength": 1,
174
+ "description": "Environment variable containing the webhook signing secret."
175
+ },
176
+ "signature_header": {
177
+ "type": "string",
178
+ "minLength": 1,
179
+ "description": "Header name used for the generated signature. Defaults to X-Mammoth-Signature."
180
+ },
181
+ "timestamp_header": {
182
+ "type": "string",
183
+ "minLength": 1,
184
+ "description": "Header name used for the signing timestamp. Defaults to X-Mammoth-Timestamp."
185
+ }
186
+ },
187
+ "description": "Optional HMAC-SHA256 request signing for webhook receivers."
123
188
  }
124
- }
189
+ },
190
+ "description": "HTTP webhook destination settings."
125
191
  },
126
-
127
192
  "retry": {
128
193
  "type": "object",
129
194
  "additionalProperties": false,
@@ -134,7 +199,8 @@
134
199
  "properties": {
135
200
  "max_attempts": {
136
201
  "type": "integer",
137
- "minimum": 1
202
+ "minimum": 1,
203
+ "description": "Maximum delivery attempts before moving the event or envelope to the dead-letter store."
138
204
  },
139
205
  "schedule_seconds": {
140
206
  "type": "array",
@@ -142,27 +208,33 @@
142
208
  "items": {
143
209
  "type": "integer",
144
210
  "minimum": 1
145
- }
211
+ },
212
+ "description": "Progressive retry delay schedule in seconds. If attempts exceed the schedule length, the final value may be reused."
146
213
  }
147
- }
214
+ },
215
+ "description": "Retry policy for failed delivery attempts before dead-lettering."
148
216
  },
149
-
150
217
  "sqlite": {
151
218
  "type": "object",
152
219
  "additionalProperties": false,
153
- "required": ["path"],
220
+ "required": [
221
+ "path"
222
+ ],
154
223
  "properties": {
155
224
  "path": {
156
225
  "type": "string",
157
- "minLength": 1
226
+ "minLength": 1,
227
+ "description": "Filesystem path to Mammoth SQLite operational database. In Kubernetes, back this path with persistent storage."
158
228
  }
159
- }
229
+ },
230
+ "description": "SQLite operational store settings for checkpoints, attempts, and dead-letter records."
160
231
  },
161
-
162
232
  "logging": {
163
233
  "type": "object",
164
234
  "additionalProperties": false,
165
- "required": ["level"],
235
+ "required": [
236
+ "level"
237
+ ],
166
238
  "properties": {
167
239
  "level": {
168
240
  "type": "string",
@@ -171,9 +243,83 @@
171
243
  "info",
172
244
  "warn",
173
245
  "error"
174
- ]
246
+ ],
247
+ "description": "Log verbosity. Use debug for diagnostics, info for normal operation, and warn/error for quieter production output."
248
+ }
249
+ },
250
+ "description": "Logging configuration."
251
+ },
252
+ "delivery": {
253
+ "type": "object",
254
+ "additionalProperties": false,
255
+ "properties": {
256
+ "unit": {
257
+ "type": "string",
258
+ "enum": [
259
+ "event",
260
+ "transaction"
261
+ ],
262
+ "description": "Delivery granularity. event delivers each change independently; transaction delivers a complete TransactionEnvelope and is the safer default for checkpoint correctness."
263
+ },
264
+ "ordering": {
265
+ "type": "object",
266
+ "additionalProperties": false,
267
+ "properties": {
268
+ "scope": {
269
+ "type": "string",
270
+ "enum": [
271
+ "global",
272
+ "transaction",
273
+ "relation",
274
+ "primary_key",
275
+ "none"
276
+ ],
277
+ "description": "Ordering scope. global preserves complete WAL order; transaction preserves commit order; relation preserves per-table order; primary_key preserves per-row order; none is best-effort only."
278
+ }
279
+ },
280
+ "description": "Ordering policy consumed by the delivery runtime."
281
+ }
282
+ },
283
+ "description": "Delivery semantics for CDC payloads."
284
+ },
285
+ "runtime": {
286
+ "type": "object",
287
+ "additionalProperties": false,
288
+ "properties": {
289
+ "adapter": {
290
+ "type": "string",
291
+ "enum": [
292
+ "inline",
293
+ "concurrent"
294
+ ],
295
+ "description": "Delivery runtime adapter. inline executes synchronously; concurrent uses CDC::Concurrent::ProcessorPool for I/O-heavy delivery work."
296
+ },
297
+ "concurrency": {
298
+ "type": "integer",
299
+ "minimum": 1,
300
+ "description": "Number of downstream delivery workers. Does not create extra PostgreSQL replication slots or connections."
301
+ },
302
+ "batch_size": {
303
+ "type": "integer",
304
+ "minimum": 1,
305
+ "description": "Number of work units submitted to the concurrent runtime together. Values greater than 1 allow ordering-insensitive deliveries to overlap more visibly."
306
+ },
307
+ "preserve_order": {
308
+ "type": "boolean",
309
+ "description": "When true, preserve configured delivery ordering while using the runtime. Disable only for idempotent ordering-insensitive destinations."
310
+ },
311
+ "timeout_seconds": {
312
+ "type": [
313
+ "number",
314
+ "null"
315
+ ],
316
+ "minimum": 0,
317
+ "exclusiveMinimum": true,
318
+ "description": "Optional runtime-level timeout in seconds. Leave null to rely on destination-specific timeouts."
175
319
  }
176
- }
320
+ },
321
+ "description": "Downstream delivery runtime settings. Runtime concurrency affects webhook/destination delivery only, not PostgreSQL replication stream count."
177
322
  }
178
- }
179
- }
323
+ },
324
+ "description": "Mammoth relay configuration. Mammoth consumes PostgreSQL logical replication changes and delivers them to downstream destinations while preserving checkpoint, retry, and dead-letter semantics."
325
+ }
@@ -10,7 +10,7 @@ module Mammoth
10
10
  # injected CDC work source rather than owning upstream CDC source-adapter
11
11
  # lifecycle decisions.
12
12
  class Application
13
- attr_reader :config, :sqlite_store, :consumer, :delivery_worker
13
+ attr_reader :config, :sqlite_store, :consumer, :delivery_worker, :checkpoint_store
14
14
 
15
15
  # @param config [Mammoth::Configuration] loaded configuration
16
16
  # @param source [#each, nil] injectable event source for tests and demos
@@ -19,32 +19,111 @@ module Mammoth
19
19
  def initialize(config, source: nil, sink: nil, sleeper: Kernel.method(:sleep))
20
20
  @config = config
21
21
  @sqlite_store = SQLiteStore.connect(config.dig("sqlite", "path")).bootstrap!
22
- @consumer = ReplicationConsumer.new(source: source)
22
+ @checkpoint_store = CheckpointStore.new(sqlite_store)
23
+ @consumer = ReplicationConsumer.new(source: source || build_source, delivery_unit: delivery_unit)
23
24
  @delivery_worker = build_delivery_worker(sink: sink || WebhookSink.from_config(config), sleeper: sleeper)
24
25
  end
25
26
 
26
- # Start the application runtime and deliver consumed events.
27
+ # Start the application runtime and deliver consumed CDC work.
27
28
  #
28
- # @return [Integer] number of processed events
29
+ # @return [Integer] number of processed work units
29
30
  def start
31
+ runtime = build_runtime
30
32
  processed = 0
31
- consumer.start do |event|
32
- delivery_worker.deliver(event)
33
- processed += 1
33
+ batch = [nil].compact
34
+
35
+ consumer.start do |work|
36
+ if runtime_batching?(runtime)
37
+ batch << work
38
+ next unless batch.size >= runtime_batch_size
39
+
40
+ processed += process_batch(runtime, batch)
41
+ batch = []
42
+ else
43
+ process_work(runtime, work)
44
+ processed += 1
45
+ end
34
46
  end
47
+
48
+ processed += process_batch(runtime, batch) if runtime_batching?(runtime) && batch.any?
35
49
  processed
50
+ ensure
51
+ runtime.shutdown if runtime.respond_to?(:shutdown)
36
52
  end
37
53
 
38
54
  private
39
55
 
56
+ def process_work(runtime, work)
57
+ if runtime
58
+ runtime.process_many([work])
59
+ elsif transaction_delivery?
60
+ delivery_worker.deliver_transaction(work)
61
+ else
62
+ delivery_worker.deliver(work)
63
+ end
64
+ end
65
+
66
+ def process_batch(runtime, batch)
67
+ runtime.process_many(batch)
68
+ batch.size
69
+ end
70
+
71
+ def runtime_batching?(runtime)
72
+ runtime && runtime_batch_size > 1
73
+ end
74
+
75
+ def build_runtime
76
+ return unless runtime_adapter == "concurrent"
77
+
78
+ ConcurrentDeliveryRuntime.new(
79
+ processor: DeliveryProcessor.new(delivery_worker:, delivery_unit: delivery_unit),
80
+ concurrency: runtime_concurrency,
81
+ timeout: runtime_timeout,
82
+ preserve_order: runtime_preserve_order?
83
+ )
84
+ end
85
+
86
+ def build_source
87
+ Sources::Postgres.new(config, checkpoint_store: checkpoint_store)
88
+ end
89
+
40
90
  def build_delivery_worker(sink:, sleeper:)
41
91
  DeliveryWorker.from_config(
42
92
  config,
43
93
  sink: sink,
44
- checkpoint_store: CheckpointStore.new(sqlite_store),
94
+ checkpoint_store: checkpoint_store,
45
95
  dead_letter_store: DeadLetterStore.new(sqlite_store),
46
96
  sleeper: sleeper
47
97
  )
48
98
  end
99
+
100
+ def transaction_delivery?
101
+ delivery_unit == :transaction
102
+ end
103
+
104
+ def delivery_unit
105
+ (config.dig("delivery", "unit") || "event").to_sym
106
+ end
107
+
108
+ def runtime_adapter
109
+ config.dig("runtime", "adapter") || "inline"
110
+ end
111
+
112
+ def runtime_concurrency
113
+ config.dig("runtime", "concurrency") || 1
114
+ end
115
+
116
+ def runtime_timeout
117
+ config.dig("runtime", "timeout_seconds")
118
+ end
119
+
120
+ def runtime_batch_size
121
+ config.dig("runtime", "batch_size") || 1
122
+ end
123
+
124
+ def runtime_preserve_order?
125
+ value = config.dig("runtime", "preserve_order")
126
+ value.nil? || value
127
+ end
49
128
  end
50
129
  end
data/lib/mammoth/cli.rb CHANGED
@@ -90,7 +90,7 @@ module Mammoth
90
90
 
91
91
  def start
92
92
  config = load_config
93
- processed = Application.new(config, source: Sources::Postgres.new(config)).start
93
+ processed = Application.new(config).start
94
94
  puts "Processed events: #{processed}"
95
95
  0
96
96
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Delivery runtime backed by CDC::Concurrent::ProcessorPool.
5
+ #
6
+ # Mammoth keeps a single upstream replication stream and delegates downstream
7
+ # I/O fan-out to cdc-concurrent. This class is intentionally small so the
8
+ # runtime boundary remains easy to test and replace.
9
+ class ConcurrentDeliveryRuntime
10
+ attr_reader :processor, :concurrency, :timeout, :preserve_order, :pool
11
+
12
+ # @param processor [#process] delivery processor
13
+ # @param concurrency [Integer] number of concurrent delivery workers
14
+ # @param timeout [Numeric, nil] optional per-item timeout
15
+ # @param preserve_order [Boolean] preserve output order when supported
16
+ def initialize(processor:, concurrency:, timeout:, preserve_order:)
17
+ @processor = processor
18
+ @concurrency = concurrency
19
+ @timeout = timeout
20
+ @preserve_order = preserve_order
21
+ @pool = build_pool
22
+ end
23
+
24
+ # Process many work items through cdc-concurrent.
25
+ #
26
+ # @param items [Array<Object>] CDC work units
27
+ # @return [Array<Object>] processor results
28
+ def process_many(items)
29
+ return [] if items.empty?
30
+
31
+ pool.process_many(items)
32
+ end
33
+
34
+ # Shutdown the underlying runtime when supported.
35
+ #
36
+ # @return [nil]
37
+ def shutdown
38
+ pool.shutdown if pool.respond_to?(:shutdown)
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def build_pool
45
+ require "cdc/concurrent"
46
+ CDC::Concurrent::ProcessorPool.new(processor:, concurrency:, timeout:, preserve_order:)
47
+ rescue LoadError => e
48
+ raise ConfigurationError,
49
+ "runtime.adapter=concurrent requires cdc-concurrent. Add `gem \"cdc-concurrent\"`. Original error: #{e.message}"
50
+ end
51
+ end
52
+ end
@@ -20,9 +20,9 @@ module Mammoth
20
20
  # @param error [Exception, nil] delivery failure
21
21
  # @param retry_count [Integer] number of delivery attempts
22
22
  # @return [Integer] inserted dead letter id
23
- def write(event:, destination_name:, error: nil, retry_count: 0) # rubocop:disable Metrics/MethodLength
23
+ def write(event:, destination_name:, error: nil, retry_count: 0, serializer: EventSerializer) # rubocop:disable Metrics/MethodLength
24
24
  now = Time.now.utc.iso8601
25
- payload = EventSerializer.call(event)
25
+ payload = serializer.call(event)
26
26
  database.execute(
27
27
  <<~SQL,
28
28
  INSERT INTO dead_letters(
@@ -46,7 +46,7 @@ module Mammoth
46
46
  payload.fetch("event_id"),
47
47
  payload.fetch("source"),
48
48
  destination_name,
49
- payload.fetch("operation"),
49
+ payload["operation"] || payload["type"],
50
50
  payload["namespace"],
51
51
  payload["entity"],
52
52
  payload["source_position"],
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mammoth
6
+ # SQLite-backed ledger of downstream deliveries.
7
+ #
8
+ # The PostgreSQL replication boundary is at-least-once. Mammoth therefore
9
+ # keeps a small delivery ledger so a transaction replayed by the upstream
10
+ # replication source after restart does not have to be delivered downstream again.
11
+ class DeliveredEnvelopeStore
12
+ SCHEMA = <<~SQL
13
+ CREATE TABLE IF NOT EXISTS delivered_envelopes (
14
+ id INTEGER PRIMARY KEY,
15
+ idempotency_key TEXT NOT NULL,
16
+ source_name TEXT NOT NULL,
17
+ slot_name TEXT NOT NULL,
18
+ destination_name TEXT NOT NULL,
19
+ delivery_unit TEXT NOT NULL,
20
+ transaction_id TEXT,
21
+ source_position TEXT,
22
+ delivered_at TEXT NOT NULL,
23
+
24
+ UNIQUE (idempotency_key)
25
+ );
26
+
27
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source
28
+ ON delivered_envelopes(source_name, slot_name);
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_destination
31
+ ON delivered_envelopes(destination_name);
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source_position
34
+ ON delivered_envelopes(source_position);
35
+ SQL
36
+
37
+ attr_reader :sqlite_store
38
+
39
+ def initialize(sqlite_store)
40
+ @sqlite_store = sqlite_store
41
+ ensure_schema!
42
+ end
43
+
44
+ def delivered?(idempotency_key)
45
+ !database.execute(
46
+ "SELECT 1 FROM delivered_envelopes WHERE idempotency_key = ? LIMIT 1",
47
+ [idempotency_key]
48
+ ).empty?
49
+ end
50
+
51
+ # rubocop:disable Metrics/MethodLength
52
+ def record!(idempotency_key:, source_name:, slot_name:, destination_name:, delivery_unit:, transaction_id:,
53
+ source_position:)
54
+ database.execute(
55
+ <<~SQL,
56
+ INSERT OR IGNORE INTO delivered_envelopes(
57
+ idempotency_key, source_name, slot_name, destination_name, delivery_unit,
58
+ transaction_id, source_position, delivered_at
59
+ )
60
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
61
+ SQL
62
+ [
63
+ idempotency_key,
64
+ source_name,
65
+ slot_name,
66
+ destination_name,
67
+ delivery_unit,
68
+ transaction_id,
69
+ source_position,
70
+ Time.now.utc.iso8601
71
+ ]
72
+ )
73
+ database.get_first_row(
74
+ "SELECT * FROM delivered_envelopes WHERE idempotency_key = ? LIMIT 1",
75
+ [idempotency_key]
76
+ )
77
+ end
78
+ # rubocop:enable Metrics/MethodLength
79
+
80
+ def all
81
+ database.execute("SELECT * FROM delivered_envelopes ORDER BY id")
82
+ end
83
+
84
+ def count
85
+ database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
86
+ end
87
+
88
+ private
89
+
90
+ def ensure_schema!
91
+ database.execute_batch(SCHEMA)
92
+ end
93
+
94
+ def database
95
+ sqlite_store.bootstrap!.database
96
+ end
97
+ end
98
+ end