mammoth 0.8.0 → 0.9.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 +38 -0
- data/README.md +25 -4
- data/config/mammoth.schema.json +1 -1
- data/lib/mammoth/application.rb +20 -40
- data/lib/mammoth/cli.rb +1 -13
- data/lib/mammoth/commands/bootstrap_command.rb +11 -7
- data/lib/mammoth/commands/dead_letters_command.rb +5 -3
- data/lib/mammoth/commands/deliver_sample_command.rb +1 -1
- data/lib/mammoth/commands/status_command.rb +5 -5
- data/lib/mammoth/concurrent_delivery_runtime.rb +15 -3
- data/lib/mammoth/dead_letter_commands.rb +16 -25
- data/lib/mammoth/dead_letter_store.rb +1 -1
- data/lib/mammoth/delivery_processor.rb +46 -7
- data/lib/mammoth/delivery_worker.rb +10 -8
- data/lib/mammoth/dispatch_metrics.rb +55 -0
- data/lib/mammoth/event_serializer.rb +23 -12
- data/lib/mammoth/fanout_delivery_worker.rb +4 -4
- data/lib/mammoth/metrics_observer.rb +52 -0
- data/lib/mammoth/observability_metrics.rb +69 -0
- data/lib/mammoth/observability_server.rb +6 -6
- data/lib/mammoth/observability_snapshot.rb +59 -60
- data/lib/mammoth/operational_state/adapter.rb +16 -0
- data/lib/mammoth/operational_state/registry.rb +8 -0
- data/lib/mammoth/operational_state/sqlite_adapter.rb +18 -2
- data/lib/mammoth/persisted_payload_deserializer.rb +85 -0
- data/lib/mammoth/replication_consumer.rb +15 -47
- data/lib/mammoth/runtimes/batching_runtime.rb +59 -0
- data/lib/mammoth/runtimes/concurrent_adapter.rb +4 -2
- data/lib/mammoth/runtimes/inline_adapter.rb +19 -5
- data/lib/mammoth/runtimes/registry.rb +3 -2
- data/lib/mammoth/sources/postgres.rb +10 -1
- data/lib/mammoth/status.rb +22 -18
- data/lib/mammoth/transaction_envelope_serializer.rb +13 -15
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth/webhook_sink.rb +2 -2
- data/lib/mammoth.rb +8 -1
- metadata +6 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Mammoth
|
|
6
|
+
# Reconstructs exact CDC core work items from persisted webhook payloads.
|
|
7
|
+
#
|
|
8
|
+
# Dead letters and sample files contain Mammoth's JSON delivery projection,
|
|
9
|
+
# not live CDC objects. This class is the explicit boundary that converts
|
|
10
|
+
# those persisted representations back into core vocabulary.
|
|
11
|
+
class PersistedPayloadDeserializer
|
|
12
|
+
class << self
|
|
13
|
+
# Deserialize one persisted event payload.
|
|
14
|
+
#
|
|
15
|
+
# @param payload [Hash] Mammoth event payload
|
|
16
|
+
# @return [CDC::Core::ChangeEvent] reconstructed core event
|
|
17
|
+
def event(payload)
|
|
18
|
+
attributes = stringify_keys(payload)
|
|
19
|
+
data = attributes["data"]
|
|
20
|
+
metadata = enriched_metadata(attributes)
|
|
21
|
+
|
|
22
|
+
CDC::Core::ChangeEvent.new(
|
|
23
|
+
operation: attributes.fetch("operation"),
|
|
24
|
+
schema: attributes["namespace"] || attributes.fetch("schema"),
|
|
25
|
+
table: attributes["entity"] || attributes.fetch("table"),
|
|
26
|
+
old_values: attributes["old_values"] || delete_values(attributes, data),
|
|
27
|
+
new_values: attributes["new_values"] || changed_values(attributes, data),
|
|
28
|
+
primary_key: attributes["identity"] || attributes["primary_key"],
|
|
29
|
+
transaction_id: attributes["transaction_id"],
|
|
30
|
+
commit_lsn: attributes["commit_lsn"] || attributes["source_position"],
|
|
31
|
+
sequence_number: attributes["sequence_number"],
|
|
32
|
+
occurred_at: parse_time(attributes["occurred_at"]),
|
|
33
|
+
metadata: metadata
|
|
34
|
+
)
|
|
35
|
+
rescue KeyError, ArgumentError, TypeError => e
|
|
36
|
+
raise ConfigurationError, "invalid persisted CDC event: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Deserialize one persisted transaction payload.
|
|
40
|
+
#
|
|
41
|
+
# @param payload [Hash] Mammoth transaction webhook payload
|
|
42
|
+
# @return [CDC::Core::TransactionEnvelope] reconstructed core transaction
|
|
43
|
+
def transaction(payload)
|
|
44
|
+
attributes = stringify_keys(payload)
|
|
45
|
+
CDC::Core::TransactionEnvelope.new(
|
|
46
|
+
transaction_id: attributes.fetch("transaction_id"),
|
|
47
|
+
events: attributes.fetch("events").map { |event_payload| event(event_payload) },
|
|
48
|
+
commit_lsn: attributes["commit_lsn"] || attributes["source_position"],
|
|
49
|
+
committed_at: parse_time(attributes["committed_at"]),
|
|
50
|
+
metadata: enriched_metadata(attributes)
|
|
51
|
+
)
|
|
52
|
+
rescue KeyError, ArgumentError, TypeError => e
|
|
53
|
+
raise ConfigurationError, "invalid persisted CDC transaction: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def stringify_keys(payload)
|
|
59
|
+
payload.to_h.transform_keys(&:to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def enriched_metadata(attributes)
|
|
63
|
+
metadata = stringify_keys(attributes["metadata"] || {})
|
|
64
|
+
%w[event_id source source_position changes type].each do |key|
|
|
65
|
+
metadata[key] = attributes[key] if attributes.key?(key)
|
|
66
|
+
end
|
|
67
|
+
metadata
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def delete_values(attributes, data)
|
|
71
|
+
data if attributes["operation"].to_s == "delete"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def changed_values(attributes, data)
|
|
75
|
+
data unless attributes["operation"].to_s == "delete"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_time(value)
|
|
79
|
+
return value if value.nil? || value.is_a?(Time)
|
|
80
|
+
|
|
81
|
+
Time.iso8601(value.to_s)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -20,7 +20,7 @@ module Mammoth
|
|
|
20
20
|
|
|
21
21
|
# Consume normalized CDC work from the configured source.
|
|
22
22
|
#
|
|
23
|
-
# @yieldparam event [
|
|
23
|
+
# @yieldparam event [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
|
|
24
24
|
# @return [Integer] number of consumed events
|
|
25
25
|
def start
|
|
26
26
|
return enum_for(:start) unless block_given?
|
|
@@ -49,65 +49,33 @@ module Mammoth
|
|
|
49
49
|
|
|
50
50
|
def flatten_cdc_work(work)
|
|
51
51
|
return [] if work.nil?
|
|
52
|
-
return validate_transaction_envelope(work) if transaction_envelope?(work) && transaction_delivery?
|
|
53
|
-
return validate_events(work.events) if transaction_envelope?(work)
|
|
54
52
|
return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
|
|
55
|
-
return
|
|
53
|
+
return transaction_work(work) if work.is_a?(CDC::Core::TransactionEnvelope)
|
|
54
|
+
return event_work(work) if work.is_a?(CDC::Core::ChangeEvent)
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
raise ReplicationError, "CDC source yielded non-core work: #{work.class}"
|
|
58
57
|
end
|
|
59
58
|
|
|
60
59
|
def transaction_delivery?
|
|
61
60
|
delivery_unit == :transaction
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
[envelope]
|
|
63
|
+
def transaction_work(envelope)
|
|
64
|
+
transaction_delivery? ? [envelope] : envelope.events
|
|
67
65
|
end
|
|
68
66
|
|
|
69
|
-
def
|
|
70
|
-
|
|
67
|
+
def event_work(event)
|
|
68
|
+
transaction_delivery? ? [transaction_envelope(event)] : [event]
|
|
71
69
|
end
|
|
72
70
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
event_hash["transaction_id"] || event_hash[:transaction_id] ||
|
|
81
|
-
event_hash["xid"] || event_hash[:xid] || event_hash["event_id"] || event_hash[:event_id],
|
|
82
|
-
event_hash["commit_lsn"] || event_hash[:commit_lsn] ||
|
|
83
|
-
event_hash["source_position"] || event_hash[:source_position],
|
|
84
|
-
event_hash["committed_at"] || event_hash[:committed_at] ||
|
|
85
|
-
event_hash["occurred_at"] || event_hash[:occurred_at],
|
|
86
|
-
event_hash["metadata"] || event_hash[:metadata] || {}
|
|
71
|
+
def transaction_envelope(event)
|
|
72
|
+
CDC::Core::TransactionEnvelope.new(
|
|
73
|
+
transaction_id: event.transaction_id || event.metadata[:event_id] || event.commit_lsn,
|
|
74
|
+
events: [event],
|
|
75
|
+
commit_lsn: event.commit_lsn,
|
|
76
|
+
committed_at: event.occurred_at,
|
|
77
|
+
metadata: event.metadata
|
|
87
78
|
)
|
|
88
79
|
end
|
|
89
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
90
|
-
|
|
91
|
-
def validate_cdc_event!(event)
|
|
92
|
-
return event if event.respond_to?(:to_h) && cdc_event_hash?(event.to_h)
|
|
93
|
-
|
|
94
|
-
raise ReplicationError, "CDC source yielded non-CDC work: #{event.class}"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def cdc_event_hash?(event_hash)
|
|
98
|
-
return false unless event_hash.respond_to?(:key?)
|
|
99
|
-
|
|
100
|
-
has_operation = event_hash.key?("operation") || event_hash.key?(:operation)
|
|
101
|
-
has_position = event_hash.key?("source_position") || event_hash.key?(:source_position) ||
|
|
102
|
-
event_hash.key?("commit_lsn") || event_hash.key?(:commit_lsn)
|
|
103
|
-
has_operation && has_position
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def transaction_envelope?(work)
|
|
107
|
-
work.respond_to?(:events) && work.respond_to?(:transaction_id)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
SyntheticTransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
|
|
111
|
-
private_constant :SyntheticTransactionEnvelope
|
|
112
80
|
end
|
|
113
81
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
module Runtimes
|
|
5
|
+
# Adds configured batch submission to a selected delivery runtime.
|
|
6
|
+
#
|
|
7
|
+
# Application streams one work item at a time into this runtime boundary.
|
|
8
|
+
# BatchingRuntime owns accumulation and submits complete or final partial
|
|
9
|
+
# batches through the selected adapter runtime's #process_many contract.
|
|
10
|
+
class BatchingRuntime
|
|
11
|
+
attr_reader :runtime, :batch_size, :buffer
|
|
12
|
+
|
|
13
|
+
# @param runtime [#process_many] selected delivery runtime
|
|
14
|
+
# @param batch_size [Integer] maximum work items submitted together
|
|
15
|
+
def initialize(runtime:, batch_size:)
|
|
16
|
+
@runtime = runtime
|
|
17
|
+
@batch_size = batch_size
|
|
18
|
+
@buffer = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Buffer one work item and submit a full batch when ready.
|
|
22
|
+
#
|
|
23
|
+
# @param work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
|
|
24
|
+
# @return [Array] processor results when a batch is submitted, otherwise an empty array
|
|
25
|
+
def process(work)
|
|
26
|
+
buffer << work
|
|
27
|
+
return [] if buffer.size < batch_size
|
|
28
|
+
|
|
29
|
+
flush
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Submit work immediately without changing the buffered stream.
|
|
33
|
+
#
|
|
34
|
+
# @param items [Array] work items
|
|
35
|
+
# @return [Array] processor results
|
|
36
|
+
def process_many(items)
|
|
37
|
+
runtime.process_many(items)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Submit the final partial batch.
|
|
41
|
+
#
|
|
42
|
+
# @return [Array] processor results, or an empty array when no work is buffered
|
|
43
|
+
def flush
|
|
44
|
+
return [] if buffer.empty?
|
|
45
|
+
|
|
46
|
+
runtime.process_many(buffer.shift(buffer.size))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Flush pending work and shut down the selected runtime when supported.
|
|
50
|
+
#
|
|
51
|
+
# @return [nil]
|
|
52
|
+
def shutdown
|
|
53
|
+
flush
|
|
54
|
+
runtime.shutdown if runtime.respond_to?(:shutdown)
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -16,13 +16,15 @@ module Mammoth
|
|
|
16
16
|
# @param concurrency [Integer] worker count
|
|
17
17
|
# @param timeout [Numeric, nil] optional timeout
|
|
18
18
|
# @param preserve_order [Boolean] preserve ordering when supported
|
|
19
|
+
# @param observer [CDC::Core::Observer] dispatch lifecycle observer
|
|
19
20
|
# @return [Mammoth::ConcurrentDeliveryRuntime]
|
|
20
|
-
def build(processor:, concurrency:, timeout:, preserve_order:)
|
|
21
|
+
def build(processor:, concurrency:, timeout:, preserve_order:, observer: CDC::Core::Observer.new)
|
|
21
22
|
ConcurrentDeliveryRuntime.new(
|
|
22
23
|
processor: processor,
|
|
23
24
|
concurrency: concurrency,
|
|
24
25
|
timeout: timeout,
|
|
25
|
-
preserve_order: preserve_order
|
|
26
|
+
preserve_order: preserve_order,
|
|
27
|
+
observer: observer
|
|
26
28
|
)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -4,20 +4,22 @@ module Mammoth
|
|
|
4
4
|
module Runtimes
|
|
5
5
|
# Inline runtime adapter that processes work in the caller thread.
|
|
6
6
|
class InlineAdapter < Adapter
|
|
7
|
-
attr_reader :processor
|
|
7
|
+
attr_reader :processor, :observer
|
|
8
8
|
|
|
9
9
|
# @param processor [#process] delivery processor
|
|
10
|
-
|
|
10
|
+
# @param observer [CDC::Core::Observer] dispatch lifecycle observer
|
|
11
|
+
def initialize(processor:, observer: CDC::Core::Observer.new)
|
|
11
12
|
super()
|
|
12
13
|
@processor = processor
|
|
14
|
+
@observer = observer
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
# Build an inline runtime.
|
|
16
18
|
#
|
|
17
19
|
# @param processor [#process] delivery processor
|
|
18
20
|
# @return [Mammoth::Runtimes::InlineAdapter]
|
|
19
|
-
def self.build(processor:, **_options)
|
|
20
|
-
new(processor: processor)
|
|
21
|
+
def self.build(processor:, observer: CDC::Core::Observer.new, **_options)
|
|
22
|
+
new(processor: processor, observer: observer)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# @return [String] adapter type name
|
|
@@ -28,13 +30,25 @@ module Mammoth
|
|
|
28
30
|
# @param items [Array<Object>] work units
|
|
29
31
|
# @return [Array<Object>] processor results
|
|
30
32
|
def process_many(items)
|
|
31
|
-
items.map
|
|
33
|
+
items.map do |item|
|
|
34
|
+
observer.dispatch_started(item)
|
|
35
|
+
processor.process(item).tap { |result| observe_result(result) }
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
# @return [nil]
|
|
35
40
|
def shutdown
|
|
36
41
|
nil
|
|
37
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def observe_result(result)
|
|
47
|
+
return observer.dispatch_succeeded(result) if result.success?
|
|
48
|
+
return observer.dispatch_failed(result) if result.failure?
|
|
49
|
+
|
|
50
|
+
observer.dispatch_skipped(result)
|
|
51
|
+
end
|
|
38
52
|
end
|
|
39
53
|
end
|
|
40
54
|
end
|
|
@@ -27,8 +27,9 @@ module Mammoth
|
|
|
27
27
|
# @param name [String, Symbol] adapter name
|
|
28
28
|
# @param options [Hash] adapter-specific build options
|
|
29
29
|
# @return [Object] runtime adapter
|
|
30
|
-
def build(name, **options)
|
|
31
|
-
fetch(name).build(**options)
|
|
30
|
+
def build(name, batch_size: 1, **options)
|
|
31
|
+
runtime = fetch(name).build(**options)
|
|
32
|
+
BatchingRuntime.new(runtime:, batch_size:)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# @return [Array<String>] registered runtime adapter names
|
|
@@ -64,7 +64,9 @@ module Mammoth
|
|
|
64
64
|
raise ReplicationError, "pgoutput source adapter must respond to #each_normalized"
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
normalizer.each_normalized(decoded_stream
|
|
67
|
+
normalizer.each_normalized(decoded_stream) do |work|
|
|
68
|
+
block.call(validate_core_work!(work))
|
|
69
|
+
end
|
|
68
70
|
nil
|
|
69
71
|
rescue StandardError => e
|
|
70
72
|
raise e if e.is_a?(ReplicationError)
|
|
@@ -106,6 +108,13 @@ module Mammoth
|
|
|
106
108
|
normalizer.stream_event(decoded, source_position: source_position(metadata))
|
|
107
109
|
end
|
|
108
110
|
|
|
111
|
+
def validate_core_work!(work)
|
|
112
|
+
return work if work.is_a?(CDC::Core::ChangeEvent)
|
|
113
|
+
return work if work.is_a?(CDC::Core::TransactionEnvelope)
|
|
114
|
+
|
|
115
|
+
raise ReplicationError, "pgoutput source adapter yielded non-core work: #{work.class}"
|
|
116
|
+
end
|
|
117
|
+
|
|
109
118
|
def parse_payload(payload)
|
|
110
119
|
parser = effective_parser
|
|
111
120
|
return parser.process(payload) if parser.respond_to?(:process)
|
data/lib/mammoth/status.rb
CHANGED
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
module Mammoth
|
|
4
4
|
# Builds and prints a boring operational status snapshot.
|
|
5
5
|
class Status
|
|
6
|
-
attr_reader :config, :
|
|
6
|
+
attr_reader :config, :state_adapter, :output
|
|
7
7
|
|
|
8
|
-
# Print status for a configuration and optional
|
|
8
|
+
# Print status for a configuration and optional operational-state adapter.
|
|
9
9
|
#
|
|
10
10
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
11
|
-
# @param
|
|
11
|
+
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
12
12
|
# @return [void]
|
|
13
|
-
def self.call(config,
|
|
14
|
-
new(config,
|
|
13
|
+
def self.call(config, state_adapter: nil, output: $stdout)
|
|
14
|
+
new(config, state_adapter: state_adapter, output: output).call
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
18
|
-
# @param
|
|
18
|
+
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
19
19
|
# @param output [#puts] output stream
|
|
20
|
-
def initialize(config,
|
|
20
|
+
def initialize(config, state_adapter: nil, output: $stdout)
|
|
21
21
|
@config = config
|
|
22
|
-
@
|
|
22
|
+
@state_adapter = state_adapter
|
|
23
23
|
@output = output
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -28,13 +28,13 @@ module Mammoth
|
|
|
28
28
|
# @return [void]
|
|
29
29
|
def call
|
|
30
30
|
status_lines.each { |line| output.puts(line) }
|
|
31
|
-
print_store_state if
|
|
31
|
+
print_store_state if state_adapter
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
36
|
def status_lines
|
|
37
|
-
identity_lines + replication_lines + runtime_lines + destination_lines
|
|
37
|
+
identity_lines + replication_lines + runtime_lines + destination_lines
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def identity_lines
|
|
@@ -70,10 +70,6 @@ module Mammoth
|
|
|
70
70
|
]
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def sqlite_path
|
|
74
|
-
config.dig("sqlite", "path")
|
|
75
|
-
end
|
|
76
|
-
|
|
77
73
|
def destination_names
|
|
78
74
|
destinations = config.data["destinations"]
|
|
79
75
|
return destinations.map { |destination| destination.fetch("name") } if destinations
|
|
@@ -90,10 +86,18 @@ module Mammoth
|
|
|
90
86
|
end
|
|
91
87
|
|
|
92
88
|
def print_store_state
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
output.puts "Operational state ready: #{state_adapter.ready?}"
|
|
90
|
+
state_adapter.summary.each do |key, value|
|
|
91
|
+
output.puts "#{status_label(key)}: #{format_status_value(value)}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def status_label(key)
|
|
96
|
+
key.to_s.split("_").map(&:capitalize).join(" ")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_status_value(value)
|
|
100
|
+
value.is_a?(Array) ? value.join(", ") : value
|
|
97
101
|
end
|
|
98
102
|
end
|
|
99
103
|
end
|
|
@@ -15,16 +15,20 @@ module Mammoth
|
|
|
15
15
|
# Default payload type for transaction webhook delivery.
|
|
16
16
|
PAYLOAD_TYPE = "transaction.committed"
|
|
17
17
|
|
|
18
|
-
# Serialize a CDC::Core::TransactionEnvelope
|
|
18
|
+
# Serialize a CDC::Core::TransactionEnvelope into a Hash.
|
|
19
19
|
#
|
|
20
|
-
# @param envelope [
|
|
20
|
+
# @param envelope [CDC::Core::TransactionEnvelope] transaction envelope
|
|
21
21
|
# @return [Hash] webhook-ready transaction payload
|
|
22
22
|
def self.call(envelope)
|
|
23
23
|
new(envelope).call
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# @param envelope [
|
|
26
|
+
# @param envelope [CDC::Core::TransactionEnvelope] transaction envelope
|
|
27
27
|
def initialize(envelope)
|
|
28
|
+
unless envelope.is_a?(CDC::Core::TransactionEnvelope)
|
|
29
|
+
raise ArgumentError, "envelope must be a CDC::Core::TransactionEnvelope"
|
|
30
|
+
end
|
|
31
|
+
|
|
28
32
|
@envelope = envelope
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -34,7 +38,7 @@ module Mammoth
|
|
|
34
38
|
def call
|
|
35
39
|
event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
|
|
36
40
|
{
|
|
37
|
-
"event_id" =>
|
|
41
|
+
"event_id" => envelope_metadata["event_id"] || SecureRandom.uuid,
|
|
38
42
|
"type" => PAYLOAD_TYPE,
|
|
39
43
|
"source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
|
|
40
44
|
"transaction_id" => envelope.transaction_id,
|
|
@@ -43,7 +47,7 @@ module Mammoth
|
|
|
43
47
|
"committed_at" => committed_at,
|
|
44
48
|
"event_count" => event_payloads.length,
|
|
45
49
|
"events" => event_payloads,
|
|
46
|
-
"metadata" =>
|
|
50
|
+
"metadata" => envelope_metadata
|
|
47
51
|
}
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -58,27 +62,21 @@ module Mammoth
|
|
|
58
62
|
|
|
59
63
|
attr_reader :envelope
|
|
60
64
|
|
|
61
|
-
def envelope_hash
|
|
62
|
-
@envelope_hash ||= envelope.respond_to?(:to_h) ? stringify_keys(envelope.to_h) : {}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
65
|
def stringify_keys(hash)
|
|
66
66
|
hash.to_h.transform_keys(&:to_s)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
envelope_hash[key]
|
|
69
|
+
def envelope_metadata
|
|
70
|
+
@envelope_metadata ||= stringify_keys(envelope.metadata)
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
def source_position(event_payloads)
|
|
76
|
-
|
|
74
|
+
envelope.commit_lsn ||
|
|
77
75
|
first_event_value(event_payloads.reverse, "source_position")
|
|
78
76
|
end
|
|
79
77
|
|
|
80
78
|
def committed_at
|
|
81
|
-
value =
|
|
79
|
+
value = envelope.committed_at
|
|
82
80
|
return value.iso8601 if value.respond_to?(:iso8601)
|
|
83
81
|
|
|
84
82
|
value || Time.now.utc.iso8601
|
data/lib/mammoth/version.rb
CHANGED
data/lib/mammoth/webhook_sink.rb
CHANGED
|
@@ -94,7 +94,7 @@ module Mammoth
|
|
|
94
94
|
|
|
95
95
|
# Deliver an event to the webhook endpoint.
|
|
96
96
|
#
|
|
97
|
-
# @param event [
|
|
97
|
+
# @param event [CDC::Core::ChangeEvent] normalized event
|
|
98
98
|
# @return [Hash] delivery result
|
|
99
99
|
# @raise [Mammoth::DeliveryError] when delivery fails
|
|
100
100
|
def deliver(event)
|
|
@@ -103,7 +103,7 @@ module Mammoth
|
|
|
103
103
|
|
|
104
104
|
# Deliver a transaction envelope to the webhook endpoint.
|
|
105
105
|
#
|
|
106
|
-
# @param envelope [
|
|
106
|
+
# @param envelope [CDC::Core::TransactionEnvelope] CDC transaction envelope
|
|
107
107
|
# @return [Hash] delivery result
|
|
108
108
|
# @raise [Mammoth::DeliveryError] when delivery fails
|
|
109
109
|
def deliver_transaction(envelope)
|
data/lib/mammoth.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cdc/core"
|
|
4
|
+
|
|
3
5
|
require_relative "mammoth/version"
|
|
4
6
|
require_relative "mammoth/errors"
|
|
5
7
|
require_relative "mammoth/registry"
|
|
@@ -14,6 +16,9 @@ require_relative "mammoth/commands/status_command"
|
|
|
14
16
|
require_relative "mammoth/commands/start_command"
|
|
15
17
|
require_relative "mammoth/commands/deliver_sample_command"
|
|
16
18
|
require_relative "mammoth/commands/dead_letters_command"
|
|
19
|
+
require_relative "mammoth/dispatch_metrics"
|
|
20
|
+
require_relative "mammoth/metrics_observer"
|
|
21
|
+
require_relative "mammoth/observability_metrics"
|
|
17
22
|
require_relative "mammoth/observability_snapshot"
|
|
18
23
|
require_relative "mammoth/observability_server"
|
|
19
24
|
require_relative "mammoth/sqlite_store"
|
|
@@ -22,6 +27,7 @@ require_relative "mammoth/dead_letter_store"
|
|
|
22
27
|
require_relative "mammoth/delivered_envelope_store"
|
|
23
28
|
require_relative "mammoth/event_serializer"
|
|
24
29
|
require_relative "mammoth/transaction_envelope_serializer"
|
|
30
|
+
require_relative "mammoth/persisted_payload_deserializer"
|
|
25
31
|
require_relative "mammoth/route_filter"
|
|
26
32
|
require_relative "mammoth/webhook_sink"
|
|
27
33
|
require_relative "mammoth/operational_state/adapter"
|
|
@@ -37,6 +43,7 @@ require_relative "mammoth/concurrent_delivery_runtime"
|
|
|
37
43
|
require_relative "mammoth/runtimes/adapter"
|
|
38
44
|
require_relative "mammoth/runtimes/inline_adapter"
|
|
39
45
|
require_relative "mammoth/runtimes/concurrent_adapter"
|
|
46
|
+
require_relative "mammoth/runtimes/batching_runtime"
|
|
40
47
|
require_relative "mammoth/runtimes/registry"
|
|
41
48
|
require_relative "mammoth/sources/postgres"
|
|
42
49
|
require_relative "mammoth/cdc_source"
|
|
@@ -47,7 +54,7 @@ require_relative "mammoth/cli"
|
|
|
47
54
|
|
|
48
55
|
# Mammoth is a self-hosted PostgreSQL event relay.
|
|
49
56
|
#
|
|
50
|
-
# Mammoth 0.
|
|
57
|
+
# Mammoth 0.8.x is a single-node PostgreSQL CDC relay with webhook fanout,
|
|
51
58
|
# replayable operational SQLite state, health/metrics endpoints, and explicit
|
|
52
59
|
# extension contracts for state, destination, runtime, lifecycle, configuration,
|
|
53
60
|
# and local command integrations.
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -185,20 +185,25 @@ files:
|
|
|
185
185
|
- lib/mammoth/destinations/adapter.rb
|
|
186
186
|
- lib/mammoth/destinations/registry.rb
|
|
187
187
|
- lib/mammoth/destinations/webhook_adapter.rb
|
|
188
|
+
- lib/mammoth/dispatch_metrics.rb
|
|
188
189
|
- lib/mammoth/errors.rb
|
|
189
190
|
- lib/mammoth/event_serializer.rb
|
|
190
191
|
- lib/mammoth/fanout_delivery_worker.rb
|
|
191
192
|
- lib/mammoth/lifecycle_hooks.rb
|
|
193
|
+
- lib/mammoth/metrics_observer.rb
|
|
192
194
|
- lib/mammoth/node_identity.rb
|
|
195
|
+
- lib/mammoth/observability_metrics.rb
|
|
193
196
|
- lib/mammoth/observability_server.rb
|
|
194
197
|
- lib/mammoth/observability_snapshot.rb
|
|
195
198
|
- lib/mammoth/operational_state/adapter.rb
|
|
196
199
|
- lib/mammoth/operational_state/registry.rb
|
|
197
200
|
- lib/mammoth/operational_state/sqlite_adapter.rb
|
|
201
|
+
- lib/mammoth/persisted_payload_deserializer.rb
|
|
198
202
|
- lib/mammoth/registry.rb
|
|
199
203
|
- lib/mammoth/replication_consumer.rb
|
|
200
204
|
- lib/mammoth/route_filter.rb
|
|
201
205
|
- lib/mammoth/runtimes/adapter.rb
|
|
206
|
+
- lib/mammoth/runtimes/batching_runtime.rb
|
|
202
207
|
- lib/mammoth/runtimes/concurrent_adapter.rb
|
|
203
208
|
- lib/mammoth/runtimes/inline_adapter.rb
|
|
204
209
|
- lib/mammoth/runtimes/registry.rb
|