mammoth 0.7.2 → 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 +55 -0
- data/README.md +38 -7
- 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 +39 -129
- 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 +14 -3
|
@@ -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
|
|
@@ -8,7 +8,7 @@ module Mammoth
|
|
|
8
8
|
# Postgres realizes the CDC Ecosystem libraries for Mammoth's product
|
|
9
9
|
# boundary. It composes pgoutput-client, pgoutput-parser,
|
|
10
10
|
# pgoutput-decoder, and pgoutput-source-adapter into a single source that
|
|
11
|
-
# yields CDC::Core
|
|
11
|
+
# yields CDC::Core work to the delivery runtime.
|
|
12
12
|
#
|
|
13
13
|
# This class may mention pgoutput implementation details because it is the
|
|
14
14
|
# concrete PostgreSQL source adapter used by Mammoth. The rest of Mammoth
|
|
@@ -45,21 +45,27 @@ module Mammoth
|
|
|
45
45
|
@checkpoint_store = checkpoint_store
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Stream CDC::Core
|
|
48
|
+
# Stream CDC::Core work from PostgreSQL logical replication.
|
|
49
49
|
#
|
|
50
50
|
# Calling this method starts the injected or configured pgoutput-client
|
|
51
51
|
# runner. The runner owns the PostgreSQL replication connection and slot
|
|
52
|
-
# lifecycle
|
|
53
|
-
#
|
|
52
|
+
# lifecycle, while pgoutput-source-adapter owns transaction buffering and
|
|
53
|
+
# normalization into CDC::Core work items. This class only composes those
|
|
54
|
+
# layers and forwards transport source positions to the adapter.
|
|
54
55
|
#
|
|
55
|
-
# @yieldparam work [
|
|
56
|
+
# @yieldparam work [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope]
|
|
56
57
|
# @return [Enumerator, nil]
|
|
57
58
|
# @raise [Mammoth::ReplicationError] when the source cannot stream CDC work
|
|
58
59
|
def each(&block)
|
|
59
60
|
return enum_for(:each) unless block_given?
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
normalizer = effective_adapter
|
|
63
|
+
unless normalizer.respond_to?(:each_normalized)
|
|
64
|
+
raise ReplicationError, "pgoutput source adapter must respond to #each_normalized"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
normalizer.each_normalized(decoded_stream) do |work|
|
|
68
|
+
block.call(validate_core_work!(work))
|
|
63
69
|
end
|
|
64
70
|
nil
|
|
65
71
|
rescue StandardError => e
|
|
@@ -70,41 +76,44 @@ module Mammoth
|
|
|
70
76
|
|
|
71
77
|
private
|
|
72
78
|
|
|
79
|
+
def decoded_stream
|
|
80
|
+
Enumerator.new do |stream|
|
|
81
|
+
effective_runner.start do |payload, metadata = nil|
|
|
82
|
+
process_payload(payload, metadata) { |decoded| stream << stream_event(decoded, metadata) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
73
87
|
def process_payload(payload, metadata, &block)
|
|
74
88
|
parsed = parse_payload(payload)
|
|
75
89
|
decoded = decode_message(parsed, metadata)
|
|
76
|
-
|
|
90
|
+
each_decoded(decoded, &block)
|
|
77
91
|
end
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
def process_decoded(decoded, metadata, &block)
|
|
93
|
+
def each_decoded(decoded, &block)
|
|
81
94
|
return if decoded.nil?
|
|
82
95
|
|
|
83
96
|
if decoded.is_a?(Array)
|
|
84
|
-
decoded.each { |item|
|
|
97
|
+
decoded.each { |item| each_decoded(item, &block) }
|
|
85
98
|
return
|
|
86
99
|
end
|
|
87
100
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
end
|
|
101
|
+
block.call(decoded)
|
|
102
|
+
end
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
end
|
|
104
|
+
def stream_event(decoded, metadata)
|
|
105
|
+
normalizer = effective_adapter
|
|
106
|
+
return decoded unless normalizer.respond_to?(:stream_event)
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
normalizer.stream_event(decoded, source_position: source_position(metadata))
|
|
109
|
+
end
|
|
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}"
|
|
106
116
|
end
|
|
107
|
-
# rubocop:enable Metrics/MethodLength
|
|
108
117
|
|
|
109
118
|
def parse_payload(payload)
|
|
110
119
|
parser = effective_parser
|
|
@@ -127,102 +136,6 @@ module Mammoth
|
|
|
127
136
|
raise ReplicationError, "pgoutput decoder must respond to #decode or #call"
|
|
128
137
|
end
|
|
129
138
|
|
|
130
|
-
def normalize_decoded(decoded)
|
|
131
|
-
return [] if decoded.nil?
|
|
132
|
-
return decoded.flat_map { |item| normalize_decoded(item) } if decoded.is_a?(Array)
|
|
133
|
-
|
|
134
|
-
adapter = effective_adapter
|
|
135
|
-
result = if adapter.respond_to?(:normalize)
|
|
136
|
-
adapter.normalize(decoded)
|
|
137
|
-
elsif adapter.respond_to?(:call)
|
|
138
|
-
adapter.call(decoded)
|
|
139
|
-
else
|
|
140
|
-
raise ReplicationError, "pgoutput source adapter must respond to #normalize or #call"
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
result.is_a?(Array) ? result.compact : [result].compact
|
|
144
|
-
end
|
|
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
139
|
def value_from(object, *keys)
|
|
227
140
|
return nil if object.nil?
|
|
228
141
|
|
|
@@ -239,9 +152,8 @@ module Mammoth
|
|
|
239
152
|
nil
|
|
240
153
|
end
|
|
241
154
|
|
|
242
|
-
def
|
|
243
|
-
|
|
244
|
-
value.is_a?(Hash) ? value : nil
|
|
155
|
+
def source_position(metadata)
|
|
156
|
+
value_from(metadata, :source_position, :commit_lsn, :lsn, :wal_end_lsn, :end_lsn, :final_lsn)
|
|
245
157
|
end
|
|
246
158
|
|
|
247
159
|
def effective_runner
|
|
@@ -375,8 +287,6 @@ module Mammoth
|
|
|
375
287
|
rescue LoadError => e
|
|
376
288
|
raise ReplicationError, "#{gem_name} is required for PostgreSQL CDC source integration: #{e.message}"
|
|
377
289
|
end
|
|
378
|
-
TransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
|
|
379
|
-
private_constant :TransactionEnvelope
|
|
380
290
|
end
|
|
381
291
|
# rubocop:enable Metrics/ClassLength
|
|
382
292
|
end
|
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
|