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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/lib/mammoth/application.rb +48 -0
- data/lib/mammoth/checkpoint_store.rb +64 -0
- data/lib/mammoth/cli.rb +116 -0
- data/lib/mammoth/configuration.rb +76 -0
- data/lib/mammoth/dead_letter_store.rb +116 -0
- data/lib/mammoth/delivery_worker.rb +107 -0
- data/lib/mammoth/errors.rb +18 -0
- data/lib/mammoth/event_serializer.rb +79 -0
- data/lib/mammoth/pgoutput_source.rb +166 -0
- data/lib/mammoth/replication_consumer.rb +79 -0
- data/lib/mammoth/sql/__bootstrap__.sql +55 -0
- data/lib/mammoth/sqlite_store.rb +134 -0
- data/lib/mammoth/status.rb +48 -0
- data/lib/mammoth/version.rb +5 -0
- data/lib/mammoth/webhook_sink.rb +75 -0
- data/lib/mammoth.rb +24 -0
- metadata +170 -0
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
|
+
[](https://badge.fury.io/rb/mammoth)
|
|
4
|
+
[](https://github.com/kanutocd/mammoth/actions)
|
|
5
|
+
[](https://www.ruby-lang.org/en/)
|
|
6
|
+
[](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
|
data/lib/mammoth/cli.rb
ADDED
|
@@ -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
|