mammoth 0.1.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +46 -5
- data/config/mammoth.example.yml +249 -0
- data/config/mammoth.schema.json +186 -40
- data/lib/mammoth/application.rb +87 -8
- data/lib/mammoth/cli.rb +23 -2
- data/lib/mammoth/concurrent_delivery_runtime.rb +52 -0
- data/lib/mammoth/dead_letter_commands.rb +217 -0
- data/lib/mammoth/dead_letter_store.rb +28 -7
- data/lib/mammoth/delivered_envelope_store.rb +102 -0
- data/lib/mammoth/delivery_processor.rb +72 -0
- data/lib/mammoth/delivery_worker.rb +74 -12
- data/lib/mammoth/replication_consumer.rb +39 -4
- data/lib/mammoth/sources/postgres.rb +189 -9
- data/lib/mammoth/sql/__bootstrap__.sql +24 -0
- data/lib/mammoth/transaction_envelope_serializer.rb +91 -0
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +94 -16
- data/lib/mammoth.rb +5 -0
- metadata +20 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mammoth
|
|
4
|
+
# Namespace for Mammoth source adapters.
|
|
4
5
|
module Sources
|
|
5
6
|
# Concrete PostgreSQL CDC source for Mammoth.
|
|
6
7
|
#
|
|
@@ -12,6 +13,7 @@ module Mammoth
|
|
|
12
13
|
# This class may mention pgoutput implementation details because it is the
|
|
13
14
|
# concrete PostgreSQL source adapter used by Mammoth. The rest of Mammoth
|
|
14
15
|
# should remain source-agnostic and consume only the work yielded here.
|
|
16
|
+
# rubocop:disable Metrics/ClassLength
|
|
15
17
|
class Postgres
|
|
16
18
|
# @return [Mammoth::Configuration] loaded Mammoth configuration
|
|
17
19
|
attr_reader :config
|
|
@@ -23,6 +25,8 @@ module Mammoth
|
|
|
23
25
|
attr_reader :decoder
|
|
24
26
|
# @return [Object, nil] injected CDC source adapter
|
|
25
27
|
attr_reader :adapter
|
|
28
|
+
# @return [Mammoth::CheckpointStore, nil] checkpoint store used for restart resume
|
|
29
|
+
attr_reader :checkpoint_store
|
|
26
30
|
|
|
27
31
|
# Build a PostgreSQL CDC source.
|
|
28
32
|
#
|
|
@@ -31,12 +35,14 @@ module Mammoth
|
|
|
31
35
|
# @param parser [Object, nil] injected pgoutput parser or relation tracker
|
|
32
36
|
# @param decoder [Object, nil] injected pgoutput decoder
|
|
33
37
|
# @param adapter [Object, nil] injected source adapter
|
|
34
|
-
|
|
38
|
+
# @param checkpoint_store [Mammoth::CheckpointStore, nil] persisted checkpoints for restart resume
|
|
39
|
+
def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil)
|
|
35
40
|
@config = config
|
|
36
41
|
@runner = runner
|
|
37
42
|
@parser = parser
|
|
38
43
|
@decoder = decoder
|
|
39
44
|
@adapter = adapter
|
|
45
|
+
@checkpoint_store = checkpoint_store
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# Stream CDC::Core-shaped work from PostgreSQL logical replication.
|
|
@@ -67,9 +73,39 @@ module Mammoth
|
|
|
67
73
|
def process_payload(payload, metadata, &block)
|
|
68
74
|
parsed = parse_payload(payload)
|
|
69
75
|
decoded = decode_message(parsed, metadata)
|
|
70
|
-
|
|
76
|
+
process_decoded(decoded, metadata, &block)
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
# rubocop:disable Metrics/MethodLength
|
|
80
|
+
def process_decoded(decoded, metadata, &block)
|
|
81
|
+
return if decoded.nil?
|
|
82
|
+
|
|
83
|
+
if decoded.is_a?(Array)
|
|
84
|
+
decoded.each { |item| process_decoded(item, metadata, &block) }
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if begin_message?(decoded)
|
|
89
|
+
start_transaction_buffer(decoded)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if commit_message?(decoded)
|
|
94
|
+
emit_transaction_buffer(decoded, metadata, &block)
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
normalize_decoded(decoded).each do |work|
|
|
99
|
+
work = enrich_work_position(work, metadata, decoded)
|
|
100
|
+
if transaction_buffer_active?
|
|
101
|
+
Array(@transaction_events) << work
|
|
102
|
+
else
|
|
103
|
+
block.call(work)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
# rubocop:enable Metrics/MethodLength
|
|
108
|
+
|
|
73
109
|
def parse_payload(payload)
|
|
74
110
|
parser = effective_parser
|
|
75
111
|
return parser.process(payload) if parser.respond_to?(:process)
|
|
@@ -107,20 +143,129 @@ module Mammoth
|
|
|
107
143
|
result.is_a?(Array) ? result.compact : [result].compact
|
|
108
144
|
end
|
|
109
145
|
|
|
146
|
+
def begin_message?(decoded)
|
|
147
|
+
message_kind(decoded).include?("begin")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def commit_message?(decoded)
|
|
151
|
+
message_kind(decoded).include?("commit")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def message_kind(decoded)
|
|
155
|
+
(value_from(decoded, :message_type, :type, :kind) || decoded.class.name.to_s.split("::").last).to_s.downcase
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def start_transaction_buffer(decoded)
|
|
159
|
+
@transaction_events = []
|
|
160
|
+
@transaction_id = value_from(decoded, :transaction_id, :xid, :final_lsn)
|
|
161
|
+
@transaction_metadata = value_hash(decoded, :metadata) || {}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def emit_transaction_buffer(decoded, metadata, &block)
|
|
165
|
+
return unless transaction_buffer_active?
|
|
166
|
+
|
|
167
|
+
block.call(
|
|
168
|
+
TransactionEnvelope.new(
|
|
169
|
+
@transaction_events,
|
|
170
|
+
transaction_id_for(decoded),
|
|
171
|
+
commit_lsn_for(decoded, metadata),
|
|
172
|
+
value_from(decoded, :commit_time, :committed_at, :timestamp),
|
|
173
|
+
@transaction_metadata
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
ensure
|
|
177
|
+
clear_transaction_buffer
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def transaction_id_for(decoded)
|
|
181
|
+
value_from(decoded, :transaction_id, :xid) || @transaction_id || first_event_value(:transaction_id, :xid)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def commit_lsn_for(decoded, metadata)
|
|
185
|
+
value_from(decoded, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
|
|
186
|
+
value_from(metadata, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
|
|
187
|
+
first_event_value(:commit_lsn, :source_position, :lsn)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def transaction_buffer_active?
|
|
191
|
+
!@transaction_events.nil?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clear_transaction_buffer
|
|
195
|
+
@transaction_events = nil
|
|
196
|
+
@transaction_id = nil
|
|
197
|
+
@transaction_metadata = nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
201
|
+
def enrich_work_position(work, metadata, decoded)
|
|
202
|
+
position = value_from(work, :source_position, :commit_lsn) ||
|
|
203
|
+
value_from(metadata, :source_position, :commit_lsn, :lsn) ||
|
|
204
|
+
value_from(decoded, :source_position, :commit_lsn, :lsn)
|
|
205
|
+
return work unless position
|
|
206
|
+
|
|
207
|
+
work_hash = work.respond_to?(:to_h) ? work.to_h : work
|
|
208
|
+
return work unless work_hash.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
key_style = work_hash.key?("operation") ? :string : :symbol
|
|
211
|
+
source_position_key = key_style == :string ? "source_position" : :source_position
|
|
212
|
+
commit_lsn_key = key_style == :string ? "commit_lsn" : :commit_lsn
|
|
213
|
+
return work if work_hash[source_position_key] || work_hash[commit_lsn_key]
|
|
214
|
+
|
|
215
|
+
work_hash.merge(source_position_key => position, commit_lsn_key => position)
|
|
216
|
+
end
|
|
217
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
218
|
+
|
|
219
|
+
def first_event_value(*keys)
|
|
220
|
+
event = Array(@transaction_events).find do |event|
|
|
221
|
+
keys.any? { |key| value_from(event, key) }
|
|
222
|
+
end
|
|
223
|
+
value_from(event, *keys) if event
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def value_from(object, *keys)
|
|
227
|
+
return nil if object.nil?
|
|
228
|
+
|
|
229
|
+
keys.each do |key|
|
|
230
|
+
return object.public_send(key) if object.respond_to?(key)
|
|
231
|
+
|
|
232
|
+
hash = object.respond_to?(:to_h) ? object.to_h : object
|
|
233
|
+
next unless hash.respond_to?(:key?)
|
|
234
|
+
|
|
235
|
+
return hash[key] if hash.key?(key)
|
|
236
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def value_hash(object, key)
|
|
243
|
+
value = value_from(object, key)
|
|
244
|
+
value.is_a?(Hash) ? value : nil
|
|
245
|
+
end
|
|
246
|
+
|
|
110
247
|
def effective_runner
|
|
111
|
-
runner ||
|
|
248
|
+
runner || @effective_runner || begin
|
|
249
|
+
@effective_runner = build_runner
|
|
250
|
+
end
|
|
112
251
|
end
|
|
113
252
|
|
|
114
253
|
def effective_parser
|
|
115
|
-
parser ||
|
|
254
|
+
parser || @effective_parser || begin
|
|
255
|
+
@effective_parser = build_parser
|
|
256
|
+
end
|
|
116
257
|
end
|
|
117
258
|
|
|
118
259
|
def effective_decoder
|
|
119
|
-
decoder ||
|
|
260
|
+
decoder || @effective_decoder || begin
|
|
261
|
+
@effective_decoder = build_decoder
|
|
262
|
+
end
|
|
120
263
|
end
|
|
121
264
|
|
|
122
265
|
def effective_adapter
|
|
123
|
-
adapter ||
|
|
266
|
+
adapter || @effective_adapter || begin
|
|
267
|
+
@effective_adapter = build_adapter
|
|
268
|
+
end
|
|
124
269
|
end
|
|
125
270
|
|
|
126
271
|
def build_runner
|
|
@@ -152,15 +297,47 @@ module Mammoth
|
|
|
152
297
|
database_url: database_url,
|
|
153
298
|
slot_name: required_config("replication", "slot"),
|
|
154
299
|
publication_names: required_publications,
|
|
155
|
-
start_lsn:
|
|
156
|
-
auto_create_slot:
|
|
157
|
-
temporary_slot:
|
|
300
|
+
start_lsn: replication_start_lsn,
|
|
301
|
+
auto_create_slot: config.dig("replication", "auto_create_slot") == true,
|
|
302
|
+
temporary_slot: config.dig("replication", "temporary_slot") == true
|
|
158
303
|
}.tap do |options|
|
|
159
304
|
feedback_interval = config.dig("replication", "feedback_interval")
|
|
160
305
|
options[:feedback_interval] = feedback_interval unless feedback_interval.nil?
|
|
161
306
|
end
|
|
162
307
|
end
|
|
163
308
|
|
|
309
|
+
def replication_start_lsn
|
|
310
|
+
configured = config.dig("replication", "start_lsn")
|
|
311
|
+
return configured unless blank?(configured)
|
|
312
|
+
|
|
313
|
+
checkpoint_lsn
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def checkpoint_lsn
|
|
317
|
+
return nil unless checkpoint_store
|
|
318
|
+
|
|
319
|
+
row = checkpoint_store.fetch(source_name: source_name, slot_name: required_config("replication", "slot"))
|
|
320
|
+
normalize_lsn(row&.fetch("last_lsn", nil))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def source_name
|
|
324
|
+
required_config("mammoth", "name")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def normalize_lsn(value)
|
|
328
|
+
return nil if blank?(value)
|
|
329
|
+
|
|
330
|
+
lsn = value.to_s
|
|
331
|
+
return lsn if lsn.include?("/")
|
|
332
|
+
return "0/#{lsn.to_i.to_s(16).upcase}" if lsn.match?(/\A\d+\z/)
|
|
333
|
+
|
|
334
|
+
lsn
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def blank?(value)
|
|
338
|
+
value.nil? || value == ""
|
|
339
|
+
end
|
|
340
|
+
|
|
164
341
|
def required_publications
|
|
165
342
|
publications = required_config("replication", "publications")
|
|
166
343
|
unless publications.is_a?(Array) && publications.any? &&
|
|
@@ -198,6 +375,9 @@ module Mammoth
|
|
|
198
375
|
rescue LoadError => e
|
|
199
376
|
raise ReplicationError, "#{gem_name} is required for PostgreSQL CDC source integration: #{e.message}"
|
|
200
377
|
end
|
|
378
|
+
TransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
|
|
379
|
+
private_constant :TransactionEnvelope
|
|
201
380
|
end
|
|
381
|
+
# rubocop:enable Metrics/ClassLength
|
|
202
382
|
end
|
|
203
383
|
end
|
|
@@ -53,3 +53,27 @@ ON dead_letters(namespace, entity);
|
|
|
53
53
|
|
|
54
54
|
CREATE INDEX IF NOT EXISTS idx_dead_letters_failed_at
|
|
55
55
|
ON dead_letters(failed_at);
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS delivered_envelopes (
|
|
59
|
+
id INTEGER PRIMARY KEY,
|
|
60
|
+
idempotency_key TEXT NOT NULL,
|
|
61
|
+
source_name TEXT NOT NULL,
|
|
62
|
+
slot_name TEXT NOT NULL,
|
|
63
|
+
destination_name TEXT NOT NULL,
|
|
64
|
+
delivery_unit TEXT NOT NULL,
|
|
65
|
+
transaction_id TEXT,
|
|
66
|
+
source_position TEXT,
|
|
67
|
+
delivered_at TEXT NOT NULL,
|
|
68
|
+
|
|
69
|
+
UNIQUE (idempotency_key)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source
|
|
73
|
+
ON delivered_envelopes(source_name, slot_name);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_destination
|
|
76
|
+
ON delivered_envelopes(destination_name);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source_position
|
|
79
|
+
ON delivered_envelopes(source_position);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mammoth
|
|
8
|
+
# Serializes CDC transaction envelopes into webhook payloads.
|
|
9
|
+
#
|
|
10
|
+
# Mammoth uses transaction envelopes as the safest delivery and checkpointing
|
|
11
|
+
# boundary for concurrent delivery. A transaction payload preserves the commit
|
|
12
|
+
# position and groups the row-level changes that belong to the same database
|
|
13
|
+
# transaction.
|
|
14
|
+
class TransactionEnvelopeSerializer
|
|
15
|
+
# Default payload type for transaction webhook delivery.
|
|
16
|
+
PAYLOAD_TYPE = "transaction.committed"
|
|
17
|
+
|
|
18
|
+
# Serialize a CDC::Core::TransactionEnvelope-like object into a Hash.
|
|
19
|
+
#
|
|
20
|
+
# @param envelope [#events, #transaction_id] transaction envelope
|
|
21
|
+
# @return [Hash] webhook-ready transaction payload
|
|
22
|
+
def self.call(envelope)
|
|
23
|
+
new(envelope).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param envelope [#events, #transaction_id] transaction envelope
|
|
27
|
+
def initialize(envelope)
|
|
28
|
+
@envelope = envelope
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Return the webhook payload.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] transaction webhook payload
|
|
34
|
+
def call
|
|
35
|
+
event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
|
|
36
|
+
{
|
|
37
|
+
"event_id" => envelope_value("event_id") || SecureRandom.uuid,
|
|
38
|
+
"type" => PAYLOAD_TYPE,
|
|
39
|
+
"source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
|
|
40
|
+
"transaction_id" => envelope.transaction_id,
|
|
41
|
+
"source_position" => source_position(event_payloads),
|
|
42
|
+
"commit_lsn" => source_position(event_payloads),
|
|
43
|
+
"committed_at" => committed_at,
|
|
44
|
+
"event_count" => event_payloads.length,
|
|
45
|
+
"events" => event_payloads,
|
|
46
|
+
"metadata" => envelope_value("metadata") || {}
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return JSON representation of the transaction payload.
|
|
51
|
+
#
|
|
52
|
+
# @return [String] JSON representation
|
|
53
|
+
def to_json(*_args)
|
|
54
|
+
JSON.generate(call)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :envelope
|
|
60
|
+
|
|
61
|
+
def envelope_hash
|
|
62
|
+
@envelope_hash ||= envelope.respond_to?(:to_h) ? stringify_keys(envelope.to_h) : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stringify_keys(hash)
|
|
66
|
+
hash.to_h.transform_keys(&:to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def envelope_value(key)
|
|
70
|
+
return envelope.public_send(key) if envelope.respond_to?(key)
|
|
71
|
+
|
|
72
|
+
envelope_hash[key]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def source_position(event_payloads)
|
|
76
|
+
envelope_value("source_position") || envelope_value("commit_lsn") ||
|
|
77
|
+
first_event_value(event_payloads.reverse, "source_position")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def committed_at
|
|
81
|
+
value = envelope_value("committed_at") || envelope_value("commit_time")
|
|
82
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
83
|
+
|
|
84
|
+
value || Time.now.utc.iso8601
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def first_event_value(event_payloads, key)
|
|
88
|
+
event_payloads.find { |payload| payload[key] }&.fetch(key)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/mammoth/version.rb
CHANGED
data/lib/mammoth/webhook_sink.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
|
+
require "openssl"
|
|
5
6
|
require "socket"
|
|
7
|
+
require "time"
|
|
6
8
|
require "uri"
|
|
7
9
|
|
|
8
10
|
module Mammoth
|
|
@@ -11,27 +13,74 @@ module Mammoth
|
|
|
11
13
|
# HTTP status range treated as successful webhook delivery.
|
|
12
14
|
SUCCESS_RANGE = 200..299
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# Supported webhook signing algorithm.
|
|
17
|
+
SIGNING_ALGORITHM = "hmac_sha256"
|
|
18
|
+
# Prefix added to generated webhook signatures.
|
|
19
|
+
SIGNATURE_PREFIX = "sha256="
|
|
20
|
+
|
|
21
|
+
attr_reader :name, :url, :timeout_seconds, :headers, :signing
|
|
15
22
|
|
|
16
23
|
# @param name [String] destination name
|
|
17
24
|
# @param url [String] webhook endpoint URL
|
|
18
25
|
# @param timeout_seconds [Integer] HTTP open/read timeout in seconds
|
|
19
|
-
|
|
26
|
+
# @param headers [Hash] static HTTP headers applied to every request
|
|
27
|
+
# @param signing [Hash, nil] HMAC signing configuration
|
|
28
|
+
def initialize(name:, url:, timeout_seconds: 5, headers: {}, signing: nil)
|
|
20
29
|
@name = name
|
|
21
30
|
@url = URI(url)
|
|
22
31
|
@timeout_seconds = timeout_seconds
|
|
32
|
+
@headers = headers.transform_keys(&:to_s)
|
|
33
|
+
@signing = signing
|
|
23
34
|
end
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
class << self
|
|
37
|
+
# Build a sink from Mammoth configuration.
|
|
38
|
+
#
|
|
39
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
40
|
+
# @return [Mammoth::WebhookSink]
|
|
41
|
+
def from_config(config)
|
|
42
|
+
new(
|
|
43
|
+
name: config.dig("webhook", "name"),
|
|
44
|
+
url: config.dig("webhook", "url"),
|
|
45
|
+
timeout_seconds: config.dig("webhook", "timeout_seconds"),
|
|
46
|
+
headers: configured_headers(config),
|
|
47
|
+
signing: configured_signing(config)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def configured_headers(config)
|
|
54
|
+
static_headers = config.dig("webhook", "headers") || {}
|
|
55
|
+
env_headers = config.dig("webhook", "header_env") || {}
|
|
56
|
+
|
|
57
|
+
static_headers.merge(resolve_env_headers(env_headers))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolve_env_headers(env_headers)
|
|
61
|
+
env_headers.each_with_object(Hash.new) do |(header, env_name), resolved| # rubocop:disable Style/EmptyLiteral
|
|
62
|
+
resolved[header] = ENV.fetch(env_name) do
|
|
63
|
+
raise ConfigurationError, "webhook.header_env.#{header} references missing environment variable #{env_name}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def configured_signing(config)
|
|
69
|
+
signing = config.dig("webhook", "signing")
|
|
70
|
+
return unless signing
|
|
71
|
+
|
|
72
|
+
algorithm = signing.fetch("algorithm", SIGNING_ALGORITHM)
|
|
73
|
+
raise ConfigurationError, "webhook.signing.algorithm must be #{SIGNING_ALGORITHM}" unless algorithm == SIGNING_ALGORITHM
|
|
74
|
+
|
|
75
|
+
secret_env = signing.fetch("secret_env")
|
|
76
|
+
{
|
|
77
|
+
secret: ENV.fetch(secret_env) do
|
|
78
|
+
raise ConfigurationError, "webhook.signing.secret_env references missing environment variable #{secret_env}"
|
|
79
|
+
end,
|
|
80
|
+
signature_header: signing.fetch("signature_header", "X-Mammoth-Signature"),
|
|
81
|
+
timestamp_header: signing.fetch("timestamp_header", "X-Mammoth-Timestamp")
|
|
82
|
+
}
|
|
83
|
+
end
|
|
35
84
|
end
|
|
36
85
|
|
|
37
86
|
# Deliver an event to the webhook endpoint.
|
|
@@ -40,7 +89,21 @@ module Mammoth
|
|
|
40
89
|
# @return [Hash] delivery result
|
|
41
90
|
# @raise [Mammoth::DeliveryError] when delivery fails
|
|
42
91
|
def deliver(event)
|
|
43
|
-
|
|
92
|
+
deliver_payload(EventSerializer.call(event))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Deliver a transaction envelope to the webhook endpoint.
|
|
96
|
+
#
|
|
97
|
+
# @param envelope [#events, #transaction_id] CDC transaction envelope
|
|
98
|
+
# @return [Hash] delivery result
|
|
99
|
+
# @raise [Mammoth::DeliveryError] when delivery fails
|
|
100
|
+
def deliver_transaction(envelope)
|
|
101
|
+
deliver_payload(TransactionEnvelopeSerializer.call(envelope))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def deliver_payload(payload)
|
|
44
107
|
response = perform_request(payload)
|
|
45
108
|
return delivery_result(payload, response) if SUCCESS_RANGE.cover?(response.code.to_i)
|
|
46
109
|
|
|
@@ -49,8 +112,6 @@ module Mammoth
|
|
|
49
112
|
raise DeliveryError, "webhook #{name} delivery failed: #{e.message}"
|
|
50
113
|
end
|
|
51
114
|
|
|
52
|
-
private
|
|
53
|
-
|
|
54
115
|
def perform_request(payload)
|
|
55
116
|
Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https", open_timeout: timeout_seconds,
|
|
56
117
|
read_timeout: timeout_seconds) do |http|
|
|
@@ -59,19 +120,36 @@ module Mammoth
|
|
|
59
120
|
end
|
|
60
121
|
|
|
61
122
|
def build_request(payload)
|
|
123
|
+
body = JSON.generate(payload)
|
|
62
124
|
Net::HTTP::Post.new(url.request_uri).tap do |request|
|
|
63
125
|
request["Content-Type"] = "application/json"
|
|
64
|
-
|
|
126
|
+
headers.each { |header, value| request[header] = value }
|
|
127
|
+
apply_signature_headers(request, body)
|
|
128
|
+
request.body = body
|
|
65
129
|
end
|
|
66
130
|
end
|
|
67
131
|
|
|
68
132
|
def delivery_result(payload, response)
|
|
69
133
|
{
|
|
70
134
|
event_id: payload.fetch("event_id"),
|
|
135
|
+
payload_type: payload["type"] || "event",
|
|
71
136
|
destination: name,
|
|
72
137
|
status: "delivered",
|
|
73
138
|
http_status: response.code.to_i
|
|
74
139
|
}
|
|
75
140
|
end
|
|
141
|
+
|
|
142
|
+
def apply_signature_headers(request, body)
|
|
143
|
+
return unless signing
|
|
144
|
+
|
|
145
|
+
timestamp = Time.now.utc.iso8601
|
|
146
|
+
request[signing.fetch(:timestamp_header)] = timestamp
|
|
147
|
+
request[signing.fetch(:signature_header)] = signed_body(timestamp, body)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def signed_body(timestamp, body)
|
|
151
|
+
digest = OpenSSL::HMAC.hexdigest("SHA256", signing.fetch(:secret), "#{timestamp}.#{body}")
|
|
152
|
+
"#{SIGNATURE_PREFIX}#{digest}"
|
|
153
|
+
end
|
|
76
154
|
end
|
|
77
155
|
end
|
data/lib/mammoth.rb
CHANGED
|
@@ -7,13 +7,18 @@ require_relative "mammoth/status"
|
|
|
7
7
|
require_relative "mammoth/sqlite_store"
|
|
8
8
|
require_relative "mammoth/checkpoint_store"
|
|
9
9
|
require_relative "mammoth/dead_letter_store"
|
|
10
|
+
require_relative "mammoth/delivered_envelope_store"
|
|
10
11
|
require_relative "mammoth/event_serializer"
|
|
12
|
+
require_relative "mammoth/transaction_envelope_serializer"
|
|
11
13
|
require_relative "mammoth/webhook_sink"
|
|
12
14
|
require_relative "mammoth/delivery_worker"
|
|
15
|
+
require_relative "mammoth/delivery_processor"
|
|
16
|
+
require_relative "mammoth/concurrent_delivery_runtime"
|
|
13
17
|
require_relative "mammoth/sources/postgres"
|
|
14
18
|
require_relative "mammoth/cdc_source"
|
|
15
19
|
require_relative "mammoth/replication_consumer"
|
|
16
20
|
require_relative "mammoth/application"
|
|
21
|
+
require_relative "mammoth/dead_letter_commands"
|
|
17
22
|
require_relative "mammoth/cli"
|
|
18
23
|
|
|
19
24
|
# Mammoth is a self-hosted PostgreSQL event relay.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mammoth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: cdc-concurrent
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: cdc-core
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -134,8 +148,12 @@ files:
|
|
|
134
148
|
- lib/mammoth/cdc_source.rb
|
|
135
149
|
- lib/mammoth/checkpoint_store.rb
|
|
136
150
|
- lib/mammoth/cli.rb
|
|
151
|
+
- lib/mammoth/concurrent_delivery_runtime.rb
|
|
137
152
|
- lib/mammoth/configuration.rb
|
|
153
|
+
- lib/mammoth/dead_letter_commands.rb
|
|
138
154
|
- lib/mammoth/dead_letter_store.rb
|
|
155
|
+
- lib/mammoth/delivered_envelope_store.rb
|
|
156
|
+
- lib/mammoth/delivery_processor.rb
|
|
139
157
|
- lib/mammoth/delivery_worker.rb
|
|
140
158
|
- lib/mammoth/errors.rb
|
|
141
159
|
- lib/mammoth/event_serializer.rb
|
|
@@ -144,6 +162,7 @@ files:
|
|
|
144
162
|
- lib/mammoth/sql/__bootstrap__.sql
|
|
145
163
|
- lib/mammoth/sqlite_store.rb
|
|
146
164
|
- lib/mammoth/status.rb
|
|
165
|
+
- lib/mammoth/transaction_envelope_serializer.rb
|
|
147
166
|
- lib/mammoth/version.rb
|
|
148
167
|
- lib/mammoth/webhook_sink.rb
|
|
149
168
|
homepage: https://kanutocd.github.io/mammoth/
|