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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Base error for all Mammoth failures.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when configuration cannot be loaded or validated.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when local SQLite operational state cannot be initialized.
|
|
11
|
+
class StoreError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when webhook delivery fails.
|
|
14
|
+
class DeliveryError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when replication consumption is not available or fails.
|
|
17
|
+
class ReplicationError < Error; end
|
|
18
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mammoth
|
|
8
|
+
# Serializes CDC-core change events into webhook payloads.
|
|
9
|
+
#
|
|
10
|
+
# The serializer projects Mammoth's sink payload from CDC vocabulary rather
|
|
11
|
+
# than pgoutput protocol vocabulary. That keeps webhook delivery independent
|
|
12
|
+
# from PostgreSQL-specific message shapes while preserving source metadata such
|
|
13
|
+
# as commit LSN and transaction identity when available.
|
|
14
|
+
class EventSerializer
|
|
15
|
+
DEFAULT_SOURCE = "postgresql"
|
|
16
|
+
|
|
17
|
+
# Serialize an event-like object into a webhook-ready Hash.
|
|
18
|
+
#
|
|
19
|
+
# @param event [Hash, #to_h] normalized CDC event
|
|
20
|
+
# @return [Hash] webhook payload
|
|
21
|
+
def self.call(event)
|
|
22
|
+
new(event).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param event [Hash, #to_h] normalized CDC event
|
|
26
|
+
def initialize(event)
|
|
27
|
+
@event = event.respond_to?(:to_h) ? event.to_h : event
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Return the webhook payload.
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash] webhook payload
|
|
33
|
+
def call
|
|
34
|
+
event_hash = stringify_keys(@event)
|
|
35
|
+
{
|
|
36
|
+
"event_id" => event_hash["event_id"] || SecureRandom.uuid,
|
|
37
|
+
"source" => event_hash["source"] || DEFAULT_SOURCE,
|
|
38
|
+
"operation" => normalize_operation(event_hash.fetch("operation")),
|
|
39
|
+
"namespace" => event_hash["namespace"] || event_hash["schema"],
|
|
40
|
+
"entity" => event_hash["entity"] || event_hash["table"],
|
|
41
|
+
"identity" => event_hash["identity"] || event_hash["primary_key"],
|
|
42
|
+
"source_position" => event_hash["source_position"] || event_hash["commit_lsn"],
|
|
43
|
+
"transaction_id" => event_hash["transaction_id"],
|
|
44
|
+
"occurred_at" => occurred_at(event_hash),
|
|
45
|
+
"data" => event_data(event_hash),
|
|
46
|
+
"changes" => event_hash["changes"] || [],
|
|
47
|
+
"metadata" => event_hash["metadata"] || {}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Return JSON representation of the webhook payload.
|
|
52
|
+
#
|
|
53
|
+
# @return [String] JSON representation of the payload
|
|
54
|
+
def to_json(*_args)
|
|
55
|
+
JSON.generate(call)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def stringify_keys(hash)
|
|
61
|
+
hash.to_h.transform_keys(&:to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_operation(operation)
|
|
65
|
+
operation.to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def occurred_at(event_hash)
|
|
69
|
+
value = event_hash["occurred_at"]
|
|
70
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
71
|
+
|
|
72
|
+
value || Time.now.utc.iso8601
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def event_data(event_hash)
|
|
76
|
+
event_hash["data"] || event_hash["new_values"] || event_hash["old_values"] || {}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Streams PostgreSQL logical replication through the CDC Ecosystem boundary.
|
|
5
|
+
#
|
|
6
|
+
# PgoutputSource is Mammoth's upstream integration point. It composes the
|
|
7
|
+
# standalone pgoutput transport, parser, decoder, and source-adapter gems so
|
|
8
|
+
# the rest of Mammoth only receives CDC-core domain objects. Transport
|
|
9
|
+
# resiliency remains owned by pgoutput-client; Mammoth owns delivery.
|
|
10
|
+
class PgoutputSource
|
|
11
|
+
# @return [Mammoth::Configuration] loaded Mammoth configuration
|
|
12
|
+
attr_reader :config
|
|
13
|
+
# @return [Object, nil] pgoutput-client compatible runner
|
|
14
|
+
attr_reader :runner
|
|
15
|
+
# @return [Object, nil] pgoutput-parser compatible parser
|
|
16
|
+
attr_reader :parser
|
|
17
|
+
# @return [Object, nil] pgoutput-decoder compatible decoder
|
|
18
|
+
attr_reader :decoder
|
|
19
|
+
# @return [Object, nil] CDC source adapter
|
|
20
|
+
attr_reader :source_adapter
|
|
21
|
+
|
|
22
|
+
# Build the pgoutput integration source.
|
|
23
|
+
#
|
|
24
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
25
|
+
# @param runner [Object, nil] injectable pgoutput-client runner
|
|
26
|
+
# @param parser [Object, nil] injectable pgoutput parser
|
|
27
|
+
# @param decoder [Object, nil] injectable pgoutput decoder
|
|
28
|
+
# @param source_adapter [Object, nil] injectable CDC source adapter
|
|
29
|
+
def initialize(config, runner: nil, parser: nil, decoder: nil, source_adapter: nil)
|
|
30
|
+
@config = config
|
|
31
|
+
@runner = runner
|
|
32
|
+
@parser = parser
|
|
33
|
+
@decoder = decoder
|
|
34
|
+
@source_adapter = source_adapter
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Stream CDC-core objects from PostgreSQL.
|
|
38
|
+
#
|
|
39
|
+
# @yieldparam work [Object] CDC::Core::ChangeEvent or TransactionEnvelope
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [Mammoth::ReplicationError] when required CDC components are unavailable
|
|
42
|
+
def each
|
|
43
|
+
return enum_for(:each) unless block_given?
|
|
44
|
+
|
|
45
|
+
effective_runner.start do |payload, metadata|
|
|
46
|
+
normalized_items(payload, metadata).each { |item| yield item }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalized_items(payload, metadata)
|
|
53
|
+
decoded = effective_decoder ? invoke_component(effective_decoder, parsed_payload(payload), metadata) : parsed_payload(payload)
|
|
54
|
+
normalized = invoke_source_adapter(decoded, metadata)
|
|
55
|
+
Array(normalized).flatten
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parsed_payload(payload)
|
|
59
|
+
return payload unless effective_parser
|
|
60
|
+
|
|
61
|
+
invoke_component(effective_parser, payload)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def invoke_source_adapter(decoded, metadata)
|
|
65
|
+
adapter = effective_source_adapter
|
|
66
|
+
if adapter.respond_to?(:normalize)
|
|
67
|
+
adapter.normalize(decoded)
|
|
68
|
+
elsif adapter.respond_to?(:call)
|
|
69
|
+
adapter.call(decoded, metadata)
|
|
70
|
+
else
|
|
71
|
+
raise ReplicationError, "pgoutput source adapter must respond to #normalize or #call"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def invoke_component(component, *args)
|
|
76
|
+
if component.respond_to?(:call)
|
|
77
|
+
component.call(*args)
|
|
78
|
+
elsif component.respond_to?(:parse)
|
|
79
|
+
component.parse(*args)
|
|
80
|
+
elsif component.respond_to?(:decode)
|
|
81
|
+
component.decode(*args)
|
|
82
|
+
else
|
|
83
|
+
raise ReplicationError, "#{component.class} must respond to #call, #parse, or #decode"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def effective_runner
|
|
88
|
+
@runner ||= build_runner
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def effective_parser
|
|
92
|
+
@parser ||= build_parser
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def effective_decoder
|
|
96
|
+
@decoder ||= build_decoder
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def effective_source_adapter
|
|
100
|
+
@source_adapter ||= build_source_adapter
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_runner
|
|
104
|
+
require_optional!("pgoutput_client", "pgoutput-client")
|
|
105
|
+
Pgoutput::Client::Runner.new(
|
|
106
|
+
database_url: database_url,
|
|
107
|
+
slot_name: config.dig("replication", "slot"),
|
|
108
|
+
publication_names: [config.dig("replication", "publication")],
|
|
109
|
+
start_lsn: config.dig("replication", "start_lsn"),
|
|
110
|
+
auto_create_slot: config.dig("replication", "auto_create_slot") || false
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_parser
|
|
115
|
+
require_any!(["pgoutput_parser", "pgoutput/parser"], "pgoutput-parser")
|
|
116
|
+
constant_or_nil("Pgoutput::Parser") || constant_or_nil("Pgoutput::Parser::Parser")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_decoder
|
|
120
|
+
require_any!(["pgoutput_decoder", "pgoutput/decoder"], "pgoutput-decoder")
|
|
121
|
+
constant_or_nil("Pgoutput::Decoder") || constant_or_nil("Pgoutput::Decoder::ValueDecoder")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_source_adapter
|
|
125
|
+
require_optional!("cdc_core", "cdc-core")
|
|
126
|
+
require_any!(["pgoutput_source_adapter", "pgoutput/source_adapter/cdc"], "pgoutput-source-adapter")
|
|
127
|
+
|
|
128
|
+
adapter_class = constant_or_nil("Pgoutput::SourceAdapter::Cdc")
|
|
129
|
+
raise ReplicationError, "Pgoutput::SourceAdapter::Cdc is unavailable" unless adapter_class
|
|
130
|
+
|
|
131
|
+
adapter_class.new
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def database_url
|
|
135
|
+
password = ENV.fetch(config.dig("postgres", "password_env"), "")
|
|
136
|
+
user = config.dig("postgres", "username")
|
|
137
|
+
host = config.dig("postgres", "host")
|
|
138
|
+
port = config.dig("postgres", "port")
|
|
139
|
+
database = config.dig("postgres", "database")
|
|
140
|
+
"postgres://#{user}:#{password}@#{host}:#{port}/#{database}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def require_optional!(feature, gem_name)
|
|
144
|
+
require feature
|
|
145
|
+
rescue LoadError => e
|
|
146
|
+
raise ReplicationError, "#{gem_name} is required for live pgoutput replication: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def require_any!(features, gem_name)
|
|
150
|
+
errors = []
|
|
151
|
+
features.each do |feature|
|
|
152
|
+
require feature
|
|
153
|
+
return true
|
|
154
|
+
rescue LoadError => e
|
|
155
|
+
errors << e.message
|
|
156
|
+
end
|
|
157
|
+
raise ReplicationError, "#{gem_name} is required for live pgoutput replication: #{errors.join("; ")}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def constant_or_nil(name)
|
|
161
|
+
name.split("::").reduce(Object) { |scope, const_name| scope.const_get(const_name, false) }
|
|
162
|
+
rescue NameError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Consumes CDC-core work items from Mammoth's configured replication source.
|
|
5
|
+
#
|
|
6
|
+
# ReplicationConsumer is the boundary between upstream CDC ingestion and
|
|
7
|
+
# sink delivery. Live PostgreSQL ingestion is delegated to {PgoutputSource};
|
|
8
|
+
# injected sources remain available for unit tests, demos, and e2e fixtures.
|
|
9
|
+
class ReplicationConsumer
|
|
10
|
+
attr_reader :config, :source, :adapter
|
|
11
|
+
|
|
12
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
13
|
+
# @param source [#each, nil] injectable CDC work stream
|
|
14
|
+
# @param adapter [#call, nil] optional adapter for injected raw events
|
|
15
|
+
def initialize(config, source: nil, adapter: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@source = source
|
|
18
|
+
@adapter = adapter
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Return the configured replication slot.
|
|
22
|
+
#
|
|
23
|
+
# @return [String]
|
|
24
|
+
def slot
|
|
25
|
+
config.dig("replication", "slot")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return the configured publication.
|
|
29
|
+
#
|
|
30
|
+
# @return [String]
|
|
31
|
+
def publication
|
|
32
|
+
config.dig("replication", "publication")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Consume normalized CDC work from the configured source.
|
|
36
|
+
#
|
|
37
|
+
# @yieldparam event [Object] CDC::Core::ChangeEvent-compatible event
|
|
38
|
+
# @return [Integer] number of consumed events
|
|
39
|
+
def start
|
|
40
|
+
return enum_for(:start) unless block_given?
|
|
41
|
+
|
|
42
|
+
count = 0
|
|
43
|
+
each_event do |event|
|
|
44
|
+
yield event
|
|
45
|
+
count += 1
|
|
46
|
+
end
|
|
47
|
+
count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def each_event
|
|
53
|
+
effective_source.each do |raw_work|
|
|
54
|
+
normalize(raw_work).each { |event| yield event }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def effective_source
|
|
59
|
+
source || PgoutputSource.new(config)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize(raw_work)
|
|
63
|
+
adapted = adapter ? adapter.call(raw_work) : raw_work
|
|
64
|
+
flatten_cdc_work(adapted)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flatten_cdc_work(work)
|
|
68
|
+
if transaction_envelope?(work)
|
|
69
|
+
work.events
|
|
70
|
+
else
|
|
71
|
+
Array(work).flat_map { |item| transaction_envelope?(item) ? item.events : item }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def transaction_envelope?(work)
|
|
76
|
+
work.respond_to?(:events) && work.respond_to?(:transaction_id)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
2
|
+
version TEXT PRIMARY KEY,
|
|
3
|
+
applied_at TEXT NOT NULL
|
|
4
|
+
);
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
7
|
+
id INTEGER PRIMARY KEY,
|
|
8
|
+
source_name TEXT NOT NULL,
|
|
9
|
+
slot_name TEXT NOT NULL,
|
|
10
|
+
publication_name TEXT NOT NULL,
|
|
11
|
+
last_lsn TEXT,
|
|
12
|
+
updated_at TEXT NOT NULL,
|
|
13
|
+
|
|
14
|
+
UNIQUE (source_name, slot_name)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS dead_letters (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
event_id TEXT NOT NULL,
|
|
20
|
+
source_name TEXT NOT NULL,
|
|
21
|
+
destination_name TEXT NOT NULL,
|
|
22
|
+
operation TEXT NOT NULL,
|
|
23
|
+
|
|
24
|
+
namespace TEXT,
|
|
25
|
+
entity TEXT,
|
|
26
|
+
source_position TEXT,
|
|
27
|
+
|
|
28
|
+
payload_json TEXT NOT NULL,
|
|
29
|
+
|
|
30
|
+
error_class TEXT,
|
|
31
|
+
error_message TEXT,
|
|
32
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
|
|
34
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
35
|
+
failed_at TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL,
|
|
37
|
+
|
|
38
|
+
CHECK (status IN ('pending', 'resolved', 'ignored')),
|
|
39
|
+
CHECK (retry_count >= 0)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_status
|
|
43
|
+
ON dead_letters(status);
|
|
44
|
+
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_destination
|
|
46
|
+
ON dead_letters(destination_name);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_source_position
|
|
49
|
+
ON dead_letters(source_position);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_entity
|
|
52
|
+
ON dead_letters(namespace, entity);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_dead_letters_failed_at
|
|
55
|
+
ON dead_letters(failed_at);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "sqlite3"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mammoth
|
|
8
|
+
# Owns Mammoth's local SQLite operational database.
|
|
9
|
+
#
|
|
10
|
+
# The SQLite database is Mammoth's operational memory. It stores only boring,
|
|
11
|
+
# inspectable state required for reliability: schema versions, checkpoints,
|
|
12
|
+
# and dead letters.
|
|
13
|
+
class SQLiteStore
|
|
14
|
+
DEFAULT_DB_PATH = File.expand_path("../../.sqlite3/mammoth.db", __dir__)
|
|
15
|
+
MIGRATION_DIR = File.expand_path("sql", __dir__)
|
|
16
|
+
BOOTSTRAP_FILE = "__bootstrap__.sql"
|
|
17
|
+
BOOTSTRAP_VERSION = "__bootstrap__"
|
|
18
|
+
MIGRATIONS_TABLE = "schema_migrations"
|
|
19
|
+
|
|
20
|
+
attr_reader :path
|
|
21
|
+
|
|
22
|
+
# Open and return a connected store.
|
|
23
|
+
#
|
|
24
|
+
# @param path [String] SQLite database path
|
|
25
|
+
# @return [Mammoth::SQLiteStore]
|
|
26
|
+
def self.connect(path = DEFAULT_DB_PATH)
|
|
27
|
+
new(path).connect
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param path [String] SQLite database path
|
|
31
|
+
def initialize(path = DEFAULT_DB_PATH)
|
|
32
|
+
@path = path
|
|
33
|
+
@database = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Open the SQLite database and configure conservative operational pragmas.
|
|
37
|
+
#
|
|
38
|
+
# @return [Mammoth::SQLiteStore] self
|
|
39
|
+
def connect
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
41
|
+
@database = SQLite3::Database.new(path)
|
|
42
|
+
@database.results_as_hash = true
|
|
43
|
+
execute_pragma("journal_mode", "WAL")
|
|
44
|
+
execute_pragma("foreign_keys", "ON")
|
|
45
|
+
execute_pragma("busy_timeout", 5000)
|
|
46
|
+
self
|
|
47
|
+
rescue SQLite3::Exception => e
|
|
48
|
+
raise StoreError, "failed to open SQLite database #{path}: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [SQLite3::Database] connected database
|
|
52
|
+
def database
|
|
53
|
+
@database || connect.database
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Apply the initial schema if it has not yet been applied.
|
|
57
|
+
#
|
|
58
|
+
# @return [Mammoth::SQLiteStore] self
|
|
59
|
+
def bootstrap!
|
|
60
|
+
return self if bootstrapped?
|
|
61
|
+
|
|
62
|
+
apply_migration!(BOOTSTRAP_FILE, BOOTSTRAP_VERSION)
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Apply a migration file unless its version already exists.
|
|
67
|
+
#
|
|
68
|
+
# @param sql_file [String] SQL file name under lib/mammoth/sql
|
|
69
|
+
# @return [Mammoth::SQLiteStore] self
|
|
70
|
+
def migrate!(sql_file)
|
|
71
|
+
bootstrap!
|
|
72
|
+
version_name = File.basename(sql_file, ".sql")
|
|
73
|
+
return self if version_exists?(version_name)
|
|
74
|
+
|
|
75
|
+
apply_migration!(sql_file, version_name)
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean] true when the initial schema is already applied
|
|
80
|
+
def bootstrapped?
|
|
81
|
+
table_exists?(MIGRATIONS_TABLE) && version_exists?(BOOTSTRAP_VERSION)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param table_name [String] table name
|
|
85
|
+
# @return [Boolean] true when a table exists
|
|
86
|
+
def table_exists?(table_name)
|
|
87
|
+
!database.execute(
|
|
88
|
+
"SELECT 1 FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1",
|
|
89
|
+
["table", table_name]
|
|
90
|
+
).empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param version_name [String] migration version
|
|
94
|
+
# @return [Boolean] true when a migration version exists
|
|
95
|
+
def version_exists?(version_name)
|
|
96
|
+
return false unless table_exists?(MIGRATIONS_TABLE)
|
|
97
|
+
|
|
98
|
+
!database.execute(
|
|
99
|
+
"SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1",
|
|
100
|
+
[version_name]
|
|
101
|
+
).empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Return table names in the operational database.
|
|
105
|
+
#
|
|
106
|
+
# @return [Array<String>] table names
|
|
107
|
+
def tables
|
|
108
|
+
database.execute(
|
|
109
|
+
"SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"
|
|
110
|
+
).map { |row| row.fetch("name") }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def apply_migration!(sql_file, version_name)
|
|
116
|
+
sql_path = File.expand_path(sql_file, MIGRATION_DIR)
|
|
117
|
+
raise StoreError, "migration file not found: #{sql_path}" unless File.file?(sql_path)
|
|
118
|
+
|
|
119
|
+
database.transaction do
|
|
120
|
+
database.execute_batch(File.read(sql_path))
|
|
121
|
+
database.execute(
|
|
122
|
+
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
|
123
|
+
[version_name, Time.now.utc.iso8601]
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
rescue SQLite3::Exception => e
|
|
127
|
+
raise StoreError, "failed to apply migration #{sql_file}: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def execute_pragma(name, value)
|
|
131
|
+
database.execute("PRAGMA #{name} = #{value}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Builds and prints a boring operational status snapshot.
|
|
5
|
+
class Status
|
|
6
|
+
attr_reader :config, :sqlite_store
|
|
7
|
+
|
|
8
|
+
# Print status for a configuration and optional SQLite store.
|
|
9
|
+
#
|
|
10
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
11
|
+
# @param sqlite_store [Mammoth::SQLiteStore, nil] operational store
|
|
12
|
+
# @return [void]
|
|
13
|
+
def self.call(config, sqlite_store: nil)
|
|
14
|
+
new(config, sqlite_store: sqlite_store).call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
18
|
+
# @param sqlite_store [Mammoth::SQLiteStore, nil] operational store
|
|
19
|
+
def initialize(config, sqlite_store: nil)
|
|
20
|
+
@config = config
|
|
21
|
+
@sqlite_store = sqlite_store
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Print the status snapshot.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def call
|
|
28
|
+
puts "Mammoth: #{config.dig("mammoth", "name")}"
|
|
29
|
+
puts "Runtime: not started"
|
|
30
|
+
puts "SQLite: #{sqlite_path}"
|
|
31
|
+
puts "Webhook: #{config.dig("webhook", "name")}"
|
|
32
|
+
print_store_state if sqlite_store
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def sqlite_path
|
|
38
|
+
config.dig("sqlite", "path")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def print_store_state
|
|
42
|
+
store = sqlite_store.bootstrap!
|
|
43
|
+
puts "Tables: #{store.tables.join(", ")}"
|
|
44
|
+
puts "Checkpoints: #{CheckpointStore.new(store).count}"
|
|
45
|
+
puts "Dead Letters: #{DeadLetterStore.new(store).count}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mammoth
|
|
8
|
+
# Delivers normalized Mammoth events to a webhook endpoint.
|
|
9
|
+
class WebhookSink
|
|
10
|
+
SUCCESS_RANGE = 200..299
|
|
11
|
+
|
|
12
|
+
attr_reader :name, :url, :timeout_seconds
|
|
13
|
+
|
|
14
|
+
# @param name [String] destination name
|
|
15
|
+
# @param url [String] webhook endpoint URL
|
|
16
|
+
# @param timeout_seconds [Integer] HTTP open/read timeout in seconds
|
|
17
|
+
def initialize(name:, url:, timeout_seconds: 5)
|
|
18
|
+
@name = name
|
|
19
|
+
@url = URI(url)
|
|
20
|
+
@timeout_seconds = timeout_seconds
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a sink from Mammoth configuration.
|
|
24
|
+
#
|
|
25
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
26
|
+
# @return [Mammoth::WebhookSink]
|
|
27
|
+
def self.from_config(config)
|
|
28
|
+
new(
|
|
29
|
+
name: config.dig("webhook", "name"),
|
|
30
|
+
url: config.dig("webhook", "url"),
|
|
31
|
+
timeout_seconds: config.dig("webhook", "timeout_seconds")
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deliver an event to the webhook endpoint.
|
|
36
|
+
#
|
|
37
|
+
# @param event [Hash, #to_h] normalized event
|
|
38
|
+
# @return [Hash] delivery result
|
|
39
|
+
# @raise [Mammoth::DeliveryError] when delivery fails
|
|
40
|
+
def deliver(event)
|
|
41
|
+
payload = EventSerializer.call(event)
|
|
42
|
+
response = perform_request(payload)
|
|
43
|
+
return delivery_result(payload, response) if SUCCESS_RANGE.cover?(response.code.to_i)
|
|
44
|
+
|
|
45
|
+
raise DeliveryError, "webhook #{name} returned HTTP #{response.code}"
|
|
46
|
+
rescue Timeout::Error, SystemCallError, SocketError, JSON::GeneratorError => e
|
|
47
|
+
raise DeliveryError, "webhook #{name} delivery failed: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def perform_request(payload)
|
|
53
|
+
Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https", open_timeout: timeout_seconds,
|
|
54
|
+
read_timeout: timeout_seconds) do |http|
|
|
55
|
+
http.request(build_request(payload))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_request(payload)
|
|
60
|
+
Net::HTTP::Post.new(url.request_uri).tap do |request|
|
|
61
|
+
request["Content-Type"] = "application/json"
|
|
62
|
+
request.body = JSON.generate(payload)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delivery_result(payload, response)
|
|
67
|
+
{
|
|
68
|
+
event_id: payload.fetch("event_id"),
|
|
69
|
+
destination: name,
|
|
70
|
+
status: "delivered",
|
|
71
|
+
http_status: response.code.to_i
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/mammoth.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mammoth/version"
|
|
4
|
+
require_relative "mammoth/errors"
|
|
5
|
+
require_relative "mammoth/configuration"
|
|
6
|
+
require_relative "mammoth/status"
|
|
7
|
+
require_relative "mammoth/sqlite_store"
|
|
8
|
+
require_relative "mammoth/checkpoint_store"
|
|
9
|
+
require_relative "mammoth/dead_letter_store"
|
|
10
|
+
require_relative "mammoth/event_serializer"
|
|
11
|
+
require_relative "mammoth/webhook_sink"
|
|
12
|
+
require_relative "mammoth/delivery_worker"
|
|
13
|
+
require_relative "mammoth/pgoutput_source"
|
|
14
|
+
require_relative "mammoth/replication_consumer"
|
|
15
|
+
require_relative "mammoth/application"
|
|
16
|
+
require_relative "mammoth/cli"
|
|
17
|
+
|
|
18
|
+
# Mammoth is a self-hosted PostgreSQL event relay.
|
|
19
|
+
#
|
|
20
|
+
# Mammoth v0.1.0 focuses on a deliberately small, boring product slice:
|
|
21
|
+
# PostgreSQL change events are normalized, persisted through local operational
|
|
22
|
+
# state, and delivered to webhook destinations.
|
|
23
|
+
module Mammoth
|
|
24
|
+
end
|