mammoth 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8226f7d0510693efd8d3c88623f346bcd24e88f80935c54581994d23b5a64767
4
+ data.tar.gz: 69a7da12ff8b589851ea0a51d3a68e7c25a2521c49eaaf222afef0a020a5d524
5
+ SHA512:
6
+ metadata.gz: 4ecaf03600872e85d979e4c546e2e5f102dea80331ba216d18dd4d8df3c1c09d4c17148634f09b17f755cff86051e2205e290ffe1e4be1a760d9e5851237b179
7
+ data.tar.gz: 4b8ade4da78b6a4db908fb909c7a11b6dad9572a0d8d4bf3fdb55a55d6b491adb61785b89888deea4c8c9e140cb9c48d78847a5190f4601a03228de4bd6ba577
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - Unreleased
4
+
5
+ - Rename product and gem from Echo to Mammoth.
6
+ - Position Mammoth OSS as the reliable PostgreSQL change-event delivery appliance.
7
+ - Add pgoutput-client / parser / decoder / source-adapter integration boundary.
8
+ - Serialize CDC-core `ChangeEvent` shaped work into webhook payloads.
9
+ - Flatten CDC-core `TransactionEnvelope` shaped work before sink delivery.
10
+ - Add `mammoth start CONFIG` CLI command for live operation.
11
+ - Add public Helm chart under `charts/mammoth`.
12
+ - Add slim multi-stage Dockerfile for OSS image builds.
13
+ - Add e2e test task and script using real HTTP, SQLite, and filesystem paths.
14
+ - Switch OSS license metadata to MIT.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenneth C. Demanawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Mammoth
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/mammoth.svg)](https://badge.fury.io/rb/mammoth)
4
+ [![CI](https://github.com/kanutocd/mammoth/workflows/CI/badge.svg)](https://github.com/kanutocd/mammoth/actions)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.4-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ Mammoth is a self-hosted PostgreSQL event relay focused on reliable delivery
9
+ of database change events.
10
+
11
+ ```text
12
+ PostgreSQL
13
+
14
+ pgoutput-client / parser / decoder
15
+
16
+ Pgoutput::SourceAdapter::Cdc
17
+
18
+ CDC::Core::ChangeEvent
19
+
20
+ Mammoth
21
+
22
+ Webhook
23
+ ```
24
+
25
+ Mammoth is intentionally boring infrastructure. It uses YAML configuration,
26
+ JSON Schema validation, local SQLite operational state, and the CDC Ecosystem's
27
+ shared vocabulary so operators can inspect, recover, and reason about delivery.
28
+
29
+ ## OSS MVP
30
+
31
+ Mammoth OSS includes:
32
+
33
+ - CLI foundation
34
+ - YAML configuration loading
35
+ - JSON Schema-backed configuration validation
36
+ - SQLite operational memory bootstrap
37
+ - checkpoint persistence
38
+ - dead letter persistence
39
+ - webhook delivery sink
40
+ - delivery worker with retry, checkpoint, and DLQ handling
41
+ - CDC-core event serialization boundary
42
+ - pgoutput-client / parser / decoder / source-adapter integration boundary
43
+ - Docker image support
44
+ - public Helm chart support
45
+ - unit and e2e test tasks
46
+
47
+ ## Boundary
48
+
49
+ Mammoth begins at CDC-core work items and ends at webhook delivery.
50
+
51
+ Mammoth does not own pgoutput protocol parsing, value decoding, source
52
+ normalization, ordering policy, or runtime execution. Those belong to the
53
+ upstream CDC Ecosystem components.
54
+
55
+ ## Configuration
56
+
57
+ Mammoth configuration is YAML-backed and IDE-friendly.
58
+
59
+ ```yaml
60
+ # yaml-language-server: $schema=./mammoth.schema.json
61
+ ```
62
+
63
+ Validate configuration:
64
+
65
+ ```bash
66
+ bundle exec ./exe/mammoth validate config/mammoth.example.yml
67
+ ```
68
+
69
+ ## CLI
70
+
71
+ ```bash
72
+ bundle exec ./exe/mammoth version
73
+ bundle exec ./exe/mammoth validate config/mammoth.example.yml
74
+ bundle exec ./exe/mammoth bootstrap config/mammoth.example.yml
75
+ bundle exec ./exe/mammoth status config/mammoth.example.yml
76
+ bundle exec ./exe/mammoth start config/mammoth.example.yml
77
+ ```
78
+
79
+ Deliver a single normalized event JSON file through Mammoth's delivery path:
80
+
81
+ ```bash
82
+ bundle exec ./exe/mammoth deliver-sample \
83
+ examples/postgres_webhook/config/mammoth.yml \
84
+ examples/postgres_webhook/events/order_insert.json
85
+ ```
86
+
87
+ ## SQLite Operational State
88
+
89
+ Mammoth stores operational memory in SQLite:
90
+
91
+ - `schema_migrations`
92
+ - `checkpoints`
93
+ - `dead_letters`
94
+
95
+ ## E2E
96
+
97
+ ```bash
98
+ bundle exec rake test:e2e
99
+ # or
100
+ script/test-e2e
101
+ ```
102
+
103
+ The e2e task uses a real HTTP receiver, real SQLite database, and real
104
+ filesystem paths.
105
+
106
+ ## Kubernetes
107
+
108
+ The public Helm chart lives under:
109
+
110
+ ```text
111
+ charts/mammoth
112
+ ```
113
+
114
+ Install example:
115
+
116
+ ```bash
117
+ helm install mammoth charts/mammoth
118
+ ```
119
+
120
+ The chart uses one replica and `Recreate` strategy to respect PostgreSQL's
121
+ logical replication slot constraint: one slot, one active subscriber.
122
+
123
+ ## License
124
+
125
+ Mammoth OSS is licensed under the [MIT License](LICENSE.txt).
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Top-level Mammoth application composition root.
5
+ #
6
+ # Application wires Mammoth's current v0.1.0 runtime pieces: configuration,
7
+ # SQLite operational memory, replication boundary, delivery worker, checkpoint
8
+ # store, dead letter store, and webhook sink.
9
+ class Application
10
+ attr_reader :config, :sqlite_store, :consumer, :delivery_worker
11
+
12
+ # @param config [Mammoth::Configuration] loaded configuration
13
+ # @param source [#each, nil] injectable event source for tests and demos
14
+ # @param adapter [#call, nil] optional source adapter
15
+ # @param sink [#deliver, nil] optional destination sink
16
+ # @param sleeper [#call] retry sleep strategy
17
+ def initialize(config, source: nil, adapter: nil, sink: nil, sleeper: Kernel.method(:sleep))
18
+ @config = config
19
+ @sqlite_store = SQLiteStore.connect(config.dig("sqlite", "path")).bootstrap!
20
+ @consumer = ReplicationConsumer.new(config, source: source, adapter: adapter)
21
+ @delivery_worker = build_delivery_worker(sink: sink || WebhookSink.from_config(config), sleeper: sleeper)
22
+ end
23
+
24
+ # Start the application runtime and deliver consumed events.
25
+ #
26
+ # @return [Integer] number of processed events
27
+ def start
28
+ processed = 0
29
+ consumer.start do |event|
30
+ delivery_worker.deliver(event)
31
+ processed += 1
32
+ end
33
+ processed
34
+ end
35
+
36
+ private
37
+
38
+ def build_delivery_worker(sink:, sleeper:)
39
+ DeliveryWorker.from_config(
40
+ config,
41
+ sink: sink,
42
+ checkpoint_store: CheckpointStore.new(sqlite_store),
43
+ dead_letter_store: DeadLetterStore.new(sqlite_store),
44
+ sleeper: sleeper
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mammoth
6
+ # Persists source checkpoints in Mammoth's SQLite operational store.
7
+ class CheckpointStore
8
+ attr_reader :sqlite_store
9
+
10
+ # @param sqlite_store [Mammoth::SQLiteStore] bootstrapped SQLite store
11
+ def initialize(sqlite_store)
12
+ @sqlite_store = sqlite_store
13
+ end
14
+
15
+ # Insert or update the last successfully delivered source position.
16
+ #
17
+ # @param source_name [String] logical source name
18
+ # @param slot_name [String] PostgreSQL replication slot name
19
+ # @param publication_name [String] PostgreSQL publication name
20
+ # @param last_lsn [String, nil] last delivered LSN/source position
21
+ # @return [Hash] stored checkpoint row
22
+ def write(source_name:, slot_name:, publication_name:, last_lsn:)
23
+ now = Time.now.utc.iso8601
24
+ database.execute(
25
+ <<~SQL,
26
+ INSERT INTO checkpoints(source_name, slot_name, publication_name, last_lsn, updated_at)
27
+ VALUES (?, ?, ?, ?, ?)
28
+ ON CONFLICT(source_name, slot_name)
29
+ DO UPDATE SET
30
+ publication_name = excluded.publication_name,
31
+ last_lsn = excluded.last_lsn,
32
+ updated_at = excluded.updated_at
33
+ SQL
34
+ [source_name, slot_name, publication_name, last_lsn, now]
35
+ )
36
+ fetch(source_name: source_name, slot_name: slot_name)
37
+ end
38
+
39
+ # Fetch a checkpoint row.
40
+ #
41
+ # @param source_name [String] logical source name
42
+ # @param slot_name [String] PostgreSQL replication slot name
43
+ # @return [Hash, nil] checkpoint row or nil
44
+ def fetch(source_name:, slot_name:)
45
+ database.get_first_row(
46
+ "SELECT * FROM checkpoints WHERE source_name = ? AND slot_name = ? LIMIT 1",
47
+ [source_name, slot_name]
48
+ )
49
+ end
50
+
51
+ # Count checkpoint rows.
52
+ #
53
+ # @return [Integer] checkpoint count
54
+ def count
55
+ database.get_first_value("SELECT COUNT(*) FROM checkpoints")
56
+ end
57
+
58
+ private
59
+
60
+ def database
61
+ sqlite_store.bootstrap!.database
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mammoth
6
+ # Small command dispatcher for Mammoth's operator-facing CLI.
7
+ class CLI
8
+ USAGE = [
9
+ "Usage:",
10
+ " mammoth version",
11
+ " mammoth validate CONFIG",
12
+ " mammoth bootstrap CONFIG",
13
+ " mammoth status CONFIG",
14
+ " mammoth start CONFIG",
15
+ " mammoth deliver-sample CONFIG EVENT_JSON"
16
+ ].join("\n")
17
+
18
+ # Run the CLI.
19
+ #
20
+ # @param argv [Array<String>] command line arguments
21
+ # @return [Integer] process status code
22
+ def self.call(argv)
23
+ new(argv).call
24
+ end
25
+
26
+ attr_reader :argv
27
+
28
+ # @param argv [Array<String>] command line arguments
29
+ def initialize(argv)
30
+ @argv = argv
31
+ end
32
+
33
+ # Dispatch the requested command.
34
+ #
35
+ # @return [Integer] process status code
36
+ def call
37
+ case command
38
+ when "version" then version
39
+ when "validate" then validate
40
+ when "bootstrap" then bootstrap
41
+ when "status" then status
42
+ when "start" then start
43
+ when "deliver-sample" then deliver_sample
44
+ else
45
+ warn USAGE
46
+ 1
47
+ end
48
+ rescue Mammoth::Error => e
49
+ warn e.message
50
+ 1
51
+ end
52
+
53
+ private
54
+
55
+ def command
56
+ argv.fetch(0, nil)
57
+ end
58
+
59
+ def config_path
60
+ argv.fetch(1, nil)
61
+ end
62
+
63
+ def version
64
+ puts "Mammoth #{Mammoth::VERSION}"
65
+ 0
66
+ end
67
+
68
+ def validate
69
+ load_config
70
+ puts "Configuration OK: #{config_path}"
71
+ 0
72
+ end
73
+
74
+ def bootstrap
75
+ config = load_config
76
+ store = SQLiteStore.connect(config.dig("sqlite", "path")).bootstrap!
77
+ puts "SQLite database initialized"
78
+ puts "Path: #{store.path}"
79
+ puts "Tables: #{store.tables.join(", ")}"
80
+ 0
81
+ end
82
+
83
+ def status
84
+ config = load_config
85
+ store = SQLiteStore.connect(config.dig("sqlite", "path"))
86
+ Status.call(config, sqlite_store: store)
87
+ 0
88
+ end
89
+
90
+ def start
91
+ processed = Application.new(load_config).start
92
+ puts "Delivered events: #{processed}"
93
+ 0
94
+ end
95
+
96
+ def deliver_sample
97
+ config = load_config
98
+ event_path = argv.fetch(2, nil)
99
+ raise ConfigurationError, "event JSON path required\n#{USAGE}" unless event_path
100
+ raise ConfigurationError, "event JSON file not found: #{event_path}" unless File.file?(event_path)
101
+
102
+ event = JSON.parse(File.read(event_path))
103
+ processed = Application.new(config, source: [event]).start
104
+ puts "Delivered sample events: #{processed}"
105
+ 0
106
+ rescue JSON::ParserError => e
107
+ raise ConfigurationError, "invalid event JSON in #{event_path}: #{e.message}"
108
+ end
109
+
110
+ def load_config
111
+ raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
112
+
113
+ Configuration.load(config_path)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json-schema"
5
+ require "yaml"
6
+
7
+ module Mammoth
8
+ # Loads and validates Mammoth YAML configuration.
9
+ #
10
+ # Configuration is intentionally schema-backed so the same contract can power
11
+ # editor IntelliSense, preflight validation, and runtime startup checks.
12
+ class Configuration
13
+ DEFAULT_SCHEMA_PATH = File.expand_path("../../config/mammoth.schema.json", __dir__)
14
+
15
+ attr_reader :path, :data, :schema_path
16
+
17
+ # Load and validate a configuration file.
18
+ #
19
+ # @param path [String] YAML configuration path
20
+ # @param schema_path [String] JSON Schema path
21
+ # @return [Mammoth::Configuration] loaded configuration
22
+ # @raise [Mammoth::ConfigurationError] when the file is missing or invalid
23
+ def self.load(path, schema_path: DEFAULT_SCHEMA_PATH)
24
+ new(path, schema_path: schema_path).load
25
+ end
26
+
27
+ # @param path [String] YAML configuration path
28
+ # @param schema_path [String] JSON Schema path
29
+ def initialize(path, schema_path: DEFAULT_SCHEMA_PATH)
30
+ @path = path
31
+ @schema_path = schema_path
32
+ @data = nil
33
+ end
34
+
35
+ # Load and validate the configuration file.
36
+ #
37
+ # @return [Mammoth::Configuration] self
38
+ # @raise [Mammoth::ConfigurationError] when validation fails
39
+ def load
40
+ raise ConfigurationError, "configuration file not found: #{path}" unless File.file?(path)
41
+
42
+ @data = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
43
+ raise ConfigurationError, "configuration must be a YAML mapping" unless data.is_a?(Hash)
44
+
45
+ validate_schema!
46
+ self
47
+ rescue Psych::SyntaxError => e
48
+ raise ConfigurationError, "invalid YAML in #{path}: #{e.message}"
49
+ end
50
+
51
+ # Fetch a nested value from the loaded configuration.
52
+ #
53
+ # @param keys [Array<String>] nested hash keys
54
+ # @return [Object, nil] value or nil
55
+ def dig(*keys)
56
+ data&.dig(*keys)
57
+ end
58
+
59
+ private
60
+
61
+ def validate_schema!
62
+ raise ConfigurationError, "schema file not found: #{schema_path}" unless File.file?(schema_path)
63
+
64
+ schema = JSON.parse(File.read(schema_path))
65
+ JSON::Validator.fully_validate(schema, data, validate_schema: false).tap do |errors|
66
+ raise ConfigurationError, schema_error_message(errors) unless errors.empty?
67
+ end
68
+ rescue JSON::ParserError => e
69
+ raise ConfigurationError, "invalid JSON schema in #{schema_path}: #{e.message}"
70
+ end
71
+
72
+ def schema_error_message(errors)
73
+ (["configuration failed schema validation:"] + errors.map { |error| "- #{error}" }).join("\n")
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Mammoth
7
+ # Persists failed deliveries in Mammoth's SQLite dead letter queue.
8
+ class DeadLetterStore
9
+ attr_reader :sqlite_store
10
+
11
+ # @param sqlite_store [Mammoth::SQLiteStore] bootstrapped SQLite store
12
+ def initialize(sqlite_store)
13
+ @sqlite_store = sqlite_store
14
+ end
15
+
16
+ # Store a failed delivery.
17
+ #
18
+ # @param event [Hash] normalized event payload
19
+ # @param destination_name [String] destination name
20
+ # @param error [Exception, nil] delivery failure
21
+ # @param retry_count [Integer] number of delivery attempts
22
+ # @return [Integer] inserted dead letter id
23
+ def write(event:, destination_name:, error: nil, retry_count: 0) # rubocop:disable Metrics/MethodLength
24
+ now = Time.now.utc.iso8601
25
+ payload = EventSerializer.call(event)
26
+ database.execute(
27
+ <<~SQL,
28
+ INSERT INTO dead_letters(
29
+ event_id,
30
+ source_name,
31
+ destination_name,
32
+ operation,
33
+ namespace,
34
+ entity,
35
+ source_position,
36
+ payload_json,
37
+ error_class,
38
+ error_message,
39
+ retry_count,
40
+ status,
41
+ failed_at,
42
+ updated_at
43
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
44
+ SQL
45
+ [
46
+ payload.fetch("event_id"),
47
+ payload.fetch("source"),
48
+ destination_name,
49
+ payload.fetch("operation"),
50
+ payload["namespace"],
51
+ payload["entity"],
52
+ payload["source_position"],
53
+ JSON.generate(payload),
54
+ error&.class&.name,
55
+ error&.message,
56
+ retry_count,
57
+ now,
58
+ now
59
+ ]
60
+ )
61
+ database.last_insert_row_id
62
+ end
63
+
64
+ # Fetch pending dead letters.
65
+ #
66
+ # @param limit [Integer] maximum number of rows
67
+ # @return [Array<Hash>] pending dead letter rows
68
+ def pending(limit: 100)
69
+ database.execute(
70
+ "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
71
+ ["pending", limit]
72
+ )
73
+ end
74
+
75
+ # Count dead letters by status.
76
+ #
77
+ # @param status [String, nil] optional status
78
+ # @return [Integer] dead letter count
79
+ def count(status: nil)
80
+ if status
81
+ database.get_first_value("SELECT COUNT(*) FROM dead_letters WHERE status = ?", [status])
82
+ else
83
+ database.get_first_value("SELECT COUNT(*) FROM dead_letters")
84
+ end
85
+ end
86
+
87
+ # Mark a dead letter as resolved.
88
+ #
89
+ # @param id [Integer] dead letter id
90
+ # @return [void]
91
+ def resolve(id)
92
+ update_status(id, "resolved")
93
+ end
94
+
95
+ # Mark a dead letter as ignored.
96
+ #
97
+ # @param id [Integer] dead letter id
98
+ # @return [void]
99
+ def ignore(id)
100
+ update_status(id, "ignored")
101
+ end
102
+
103
+ private
104
+
105
+ def database
106
+ sqlite_store.bootstrap!.database
107
+ end
108
+
109
+ def update_status(id, status)
110
+ database.execute(
111
+ "UPDATE dead_letters SET status = ?, updated_at = ? WHERE id = ?",
112
+ [status, Time.now.utc.iso8601, id]
113
+ )
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Delivers normalized events with retry, checkpoint, and dead-letter handling.
5
+ #
6
+ # DeliveryWorker is Mammoth's first reliable delivery unit. It intentionally keeps
7
+ # the delivery contract small: attempt webhook delivery, advance the checkpoint
8
+ # after success, and persist the failed event to the dead letter queue after
9
+ # retry exhaustion.
10
+ class DeliveryWorker
11
+ DEFAULT_SOURCE = "postgresql"
12
+
13
+ attr_reader :sink, :checkpoint_store, :dead_letter_store, :retry_schedule, :max_attempts, :sleeper, :source_name,
14
+ :slot_name, :publication_name
15
+
16
+ # @param sink [#deliver] destination sink
17
+ # @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
18
+ # @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
19
+ # @param source_name [String] logical source name
20
+ # @param slot_name [String] replication slot name
21
+ # @param publication_name [String] publication name
22
+ # @param max_attempts [Integer] maximum delivery attempts
23
+ # @param retry_schedule [Array<Integer>] retry wait schedule in seconds
24
+ # @param sleeper [#call] sleep strategy, injectable for tests
25
+ def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
26
+ max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep))
27
+ @sink = sink
28
+ @checkpoint_store = checkpoint_store
29
+ @dead_letter_store = dead_letter_store
30
+ @source_name = source_name
31
+ @slot_name = slot_name
32
+ @publication_name = publication_name
33
+ @max_attempts = max_attempts
34
+ @retry_schedule = retry_schedule
35
+ @sleeper = sleeper
36
+ end
37
+
38
+ # Build a delivery worker from Mammoth configuration and stores.
39
+ #
40
+ # @param config [Mammoth::Configuration] loaded configuration
41
+ # @param sink [#deliver] destination sink
42
+ # @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
43
+ # @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
44
+ # @param sleeper [#call] sleep strategy
45
+ # @return [Mammoth::DeliveryWorker]
46
+ def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep))
47
+ new(
48
+ sink: sink,
49
+ checkpoint_store: checkpoint_store,
50
+ dead_letter_store: dead_letter_store,
51
+ source_name: config.dig("mammoth", "name"),
52
+ slot_name: config.dig("replication", "slot"),
53
+ publication_name: config.dig("replication", "publication"),
54
+ max_attempts: config.dig("retry", "max_attempts"),
55
+ retry_schedule: config.dig("retry", "schedule_seconds"),
56
+ sleeper: sleeper
57
+ )
58
+ end
59
+
60
+ # Deliver an event with retry, checkpoint, and DLQ handling.
61
+ #
62
+ # @param event [Hash, #to_h] normalized event
63
+ # @return [Hash] delivery summary
64
+ def deliver(event)
65
+ attempts = 0
66
+
67
+ begin
68
+ attempts += 1
69
+ result = sink.deliver(event)
70
+ checkpoint(event)
71
+ result.merge(attempts: attempts)
72
+ rescue DeliveryError => e
73
+ return dead_letter(event, e, attempts) if attempts >= max_attempts
74
+
75
+ wait_before_retry(attempts)
76
+ retry
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def checkpoint(event)
83
+ payload = EventSerializer.call(event)
84
+ checkpoint_store.write(
85
+ source_name: source_name,
86
+ slot_name: slot_name,
87
+ publication_name: publication_name,
88
+ last_lsn: payload["source_position"]
89
+ )
90
+ end
91
+
92
+ def dead_letter(event, error, attempts)
93
+ id = dead_letter_store.write(
94
+ event: event,
95
+ destination_name: sink.name,
96
+ error: error,
97
+ retry_count: attempts
98
+ )
99
+ { status: "dead_lettered", dead_letter_id: id, attempts: attempts }
100
+ end
101
+
102
+ def wait_before_retry(attempts)
103
+ wait_seconds = retry_schedule.fetch(attempts - 1, retry_schedule.last)
104
+ sleeper.call(wait_seconds)
105
+ end
106
+ end
107
+ end