mammoth 0.9.0 → 1.1.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 +89 -1
- data/README.md +87 -7
- data/config/mammoth.example.yml +19 -9
- data/config/mammoth.schema.json +2 -2
- data/lib/mammoth/application.rb +34 -3
- data/lib/mammoth/delivery_processor.rb +5 -2
- data/lib/mammoth/delivery_progress_coordinator.rb +153 -0
- data/lib/mammoth/delivery_worker.rb +9 -25
- data/lib/mammoth/event_serializer.rb +39 -7
- data/lib/mammoth/observability_metrics.rb +3 -1
- data/lib/mammoth/observability_server.rb +5 -3
- data/lib/mammoth/observability_snapshot.rb +63 -10
- data/lib/mammoth/postgres_observability_metrics.rb +80 -0
- data/lib/mammoth/replication_consumer.rb +28 -7
- data/lib/mammoth/sources/postgres.rb +252 -4
- data/lib/mammoth/sources/postgres_publication_inspector.rb +214 -0
- data/lib/mammoth/sources/postgres_slot_health.rb +90 -0
- data/lib/mammoth/transaction_envelope_serializer.rb +12 -2
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth.rb +5 -1
- metadata +33 -9
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mammoth
|
|
4
|
-
# Delivers normalized events with retry,
|
|
4
|
+
# Delivers normalized events with retry, ledger, and dead-letter handling.
|
|
5
5
|
#
|
|
6
6
|
# DeliveryWorker is Mammoth's first reliable delivery unit. It intentionally keeps
|
|
7
|
-
# the delivery contract small: attempt webhook delivery,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# the delivery contract small: attempt webhook delivery, record idempotent
|
|
8
|
+
# success, and persist exhausted failures to the dead letter queue. Contiguous
|
|
9
|
+
# checkpointing is owned by DeliveryProgressCoordinator.
|
|
10
10
|
class DeliveryWorker
|
|
11
11
|
# Default source name used when an event does not provide one.
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
@@ -15,7 +15,7 @@ module Mammoth
|
|
|
15
15
|
:sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
18
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
19
19
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
20
20
|
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
21
21
|
# @param source_name [String] logical source name
|
|
@@ -47,7 +47,7 @@ module Mammoth
|
|
|
47
47
|
#
|
|
48
48
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
49
49
|
# @param sink [#deliver] destination sink
|
|
50
|
-
# @param checkpoint_store [Mammoth::CheckpointStore]
|
|
50
|
+
# @param checkpoint_store [Mammoth::CheckpointStore] retained dependency; shared progress owns writes
|
|
51
51
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
52
52
|
# @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore] downstream delivery ledger
|
|
53
53
|
# @param sleeper [#call] sleep strategy
|
|
@@ -70,7 +70,7 @@ module Mammoth
|
|
|
70
70
|
)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Deliver a transaction envelope with retry,
|
|
73
|
+
# Deliver a transaction envelope with retry, ledger, and DLQ handling.
|
|
74
74
|
#
|
|
75
75
|
# @param envelope [CDC::Core::TransactionEnvelope] CDC transaction envelope
|
|
76
76
|
# @return [Hash] delivery summary
|
|
@@ -78,7 +78,7 @@ module Mammoth
|
|
|
78
78
|
deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# Deliver an event with retry,
|
|
81
|
+
# Deliver an event with retry, ledger, and DLQ handling.
|
|
82
82
|
#
|
|
83
83
|
# @param event [CDC::Core::ChangeEvent] normalized event
|
|
84
84
|
# @return [Hash] delivery summary
|
|
@@ -99,7 +99,6 @@ module Mammoth
|
|
|
99
99
|
return skip_result if skip_result
|
|
100
100
|
|
|
101
101
|
if delivered_envelope_store.delivered?(idempotency_key)
|
|
102
|
-
checkpoint_payload(payload)
|
|
103
102
|
return {
|
|
104
103
|
status: "skipped",
|
|
105
104
|
duplicate: true,
|
|
@@ -121,7 +120,6 @@ module Mammoth
|
|
|
121
120
|
transaction_id: payload["transaction_id"],
|
|
122
121
|
source_position: payload["source_position"]
|
|
123
122
|
)
|
|
124
|
-
checkpoint_payload(payload)
|
|
125
123
|
result.merge(attempts: attempts, idempotency_key: idempotency_key)
|
|
126
124
|
rescue DeliveryError => e
|
|
127
125
|
return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
|
|
@@ -132,19 +130,6 @@ module Mammoth
|
|
|
132
130
|
end
|
|
133
131
|
# rubocop:enable Metrics/MethodLength
|
|
134
132
|
|
|
135
|
-
def checkpoint(work, serializer:)
|
|
136
|
-
checkpoint_payload(serializer.call(work))
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def checkpoint_payload(payload)
|
|
140
|
-
checkpoint_store.write(
|
|
141
|
-
source_name: source_name,
|
|
142
|
-
slot_name: slot_name,
|
|
143
|
-
publication_name: publication_name,
|
|
144
|
-
last_lsn: payload["source_position"]
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
133
|
def idempotency_key_for(payload:, delivery_unit:)
|
|
149
134
|
[
|
|
150
135
|
source_name,
|
|
@@ -170,8 +155,7 @@ module Mammoth
|
|
|
170
155
|
skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
|
|
171
156
|
end
|
|
172
157
|
|
|
173
|
-
def skipped(
|
|
174
|
-
checkpoint_payload(payload)
|
|
158
|
+
def skipped(_payload, idempotency_key:, reason:)
|
|
175
159
|
{
|
|
176
160
|
status: "skipped",
|
|
177
161
|
reason: reason,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "
|
|
4
|
+
require "digest"
|
|
5
5
|
require "time"
|
|
6
6
|
|
|
7
7
|
module Mammoth
|
|
@@ -27,17 +27,17 @@ module Mammoth
|
|
|
27
27
|
def initialize(event)
|
|
28
28
|
raise ArgumentError, "event must be a CDC::Core::ChangeEvent" unless event.is_a?(CDC::Core::ChangeEvent)
|
|
29
29
|
|
|
30
|
-
@event = event
|
|
30
|
+
@event = event
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Return the webhook payload.
|
|
34
34
|
#
|
|
35
35
|
# @return [Hash] webhook payload
|
|
36
36
|
def call
|
|
37
|
-
event_hash = stringify_keys(@event)
|
|
37
|
+
event_hash = stringify_keys(@event.to_h)
|
|
38
38
|
metadata = stringify_keys(event_hash["metadata"] || {})
|
|
39
39
|
{
|
|
40
|
-
"event_id" => event_id(metadata),
|
|
40
|
+
"event_id" => event_id(event_hash, metadata),
|
|
41
41
|
"source" => source(metadata),
|
|
42
42
|
"operation" => normalize_operation(event_hash.fetch("operation")),
|
|
43
43
|
"namespace" => event_hash["schema"],
|
|
@@ -47,7 +47,7 @@ module Mammoth
|
|
|
47
47
|
"transaction_id" => event_hash["transaction_id"],
|
|
48
48
|
"occurred_at" => occurred_at(event_hash),
|
|
49
49
|
"data" => event_data(event_hash),
|
|
50
|
-
"changes" => metadata
|
|
50
|
+
"changes" => serialized_changes(event_hash, metadata),
|
|
51
51
|
"metadata" => metadata
|
|
52
52
|
}
|
|
53
53
|
end
|
|
@@ -69,8 +69,25 @@ module Mammoth
|
|
|
69
69
|
operation.to_s
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
def event_id(metadata)
|
|
73
|
-
metadata["event_id"] ||
|
|
72
|
+
def event_id(event_hash, metadata)
|
|
73
|
+
metadata["event_id"] || deterministic_event_id(event_hash, metadata)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deterministic_event_id(event_hash, metadata)
|
|
77
|
+
identity = {
|
|
78
|
+
source: source(metadata),
|
|
79
|
+
transaction_id: event_hash["transaction_id"],
|
|
80
|
+
source_position: event_hash["commit_lsn"],
|
|
81
|
+
sequence_number: event_hash["sequence_number"],
|
|
82
|
+
occurred_at: event_hash["occurred_at"],
|
|
83
|
+
namespace: event_hash["schema"],
|
|
84
|
+
entity: event_hash["table"],
|
|
85
|
+
operation: normalize_operation(event_hash.fetch("operation")),
|
|
86
|
+
primary_key: event_hash["primary_key"],
|
|
87
|
+
old_values: event_hash["old_values"],
|
|
88
|
+
new_values: event_hash["new_values"]
|
|
89
|
+
}
|
|
90
|
+
"evt_#{Digest::SHA256.hexdigest(JSON.generate(identity))}"
|
|
74
91
|
end
|
|
75
92
|
|
|
76
93
|
def source(metadata)
|
|
@@ -87,5 +104,20 @@ module Mammoth
|
|
|
87
104
|
def event_data(event_hash)
|
|
88
105
|
event_hash["data"] || event_hash["new_values"] || event_hash["old_values"] || {}
|
|
89
106
|
end
|
|
107
|
+
|
|
108
|
+
def serialized_changes(event_hash, metadata)
|
|
109
|
+
return metadata["changes"] || [] if metadata.key?("changes")
|
|
110
|
+
return [] unless changes_available?(event_hash)
|
|
111
|
+
|
|
112
|
+
@event.changes.map(&:to_h)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def changes_available?(event_hash)
|
|
116
|
+
return true unless normalize_operation(event_hash.fetch("operation")) == "update"
|
|
117
|
+
|
|
118
|
+
old_values = event_hash["old_values"]
|
|
119
|
+
new_values = event_hash["new_values"]
|
|
120
|
+
old_values.is_a?(Hash) && new_values.is_a?(Hash) && old_values.keys.sort == new_values.keys.sort
|
|
121
|
+
end
|
|
90
122
|
end
|
|
91
123
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Mammoth
|
|
4
4
|
# Prometheus formatting helpers for operational and dispatch metrics.
|
|
5
5
|
module ObservabilityMetrics
|
|
6
|
+
include PostgresObservabilityMetrics
|
|
7
|
+
|
|
6
8
|
# Maps canonical CDC core metric names to Mammoth's Prometheus counters.
|
|
7
9
|
DISPATCH_METRIC_NAMES = {
|
|
8
10
|
CDC::Core::Observer.started_metric_name => "mammoth_dispatch_started_total",
|
|
@@ -14,7 +16,7 @@ module Mammoth
|
|
|
14
16
|
private
|
|
15
17
|
|
|
16
18
|
def metric_headers
|
|
17
|
-
operational_metric_headers + dispatch_metric_headers
|
|
19
|
+
operational_metric_headers + dispatch_metric_headers + postgres_metric_headers
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def operational_metric_headers
|
|
@@ -15,18 +15,20 @@ module Mammoth
|
|
|
15
15
|
# Default TCP port for the observability server.
|
|
16
16
|
DEFAULT_PORT = 9393
|
|
17
17
|
|
|
18
|
-
attr_reader :config, :host, :port, :state_adapter, :logger, :server
|
|
18
|
+
attr_reader :config, :host, :port, :state_adapter, :slot_health_provider, :logger, :server
|
|
19
19
|
|
|
20
20
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
21
21
|
# @param host [String, nil] bind host override
|
|
22
22
|
# @param port [Integer, nil] bind port override
|
|
23
23
|
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
24
|
+
# @param slot_health_provider [#slot_health, nil] PostgreSQL slot health dependency
|
|
24
25
|
# @param logger [WEBrick::Log, nil] optional WEBrick logger
|
|
25
|
-
def initialize(config, host: nil, port: nil, state_adapter: nil, logger: nil)
|
|
26
|
+
def initialize(config, host: nil, port: nil, state_adapter: nil, slot_health_provider: nil, logger: nil)
|
|
26
27
|
@config = config
|
|
27
28
|
@host = host || config.dig("observability", "host") || DEFAULT_HOST
|
|
28
29
|
@port = port || config.dig("observability", "port") || DEFAULT_PORT
|
|
29
30
|
@state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
|
|
31
|
+
@slot_health_provider = slot_health_provider || Sources::Postgres.new(config)
|
|
30
32
|
@logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
|
|
31
33
|
@server = build_server
|
|
32
34
|
mount_endpoints
|
|
@@ -81,7 +83,7 @@ module Mammoth
|
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
def snapshot
|
|
84
|
-
ObservabilitySnapshot.new(config, state_adapter:
|
|
86
|
+
ObservabilitySnapshot.new(config, state_adapter:, slot_health_provider:)
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
89
|
end
|
|
@@ -3,27 +3,30 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Mammoth
|
|
6
|
-
# Builds health, readiness, and metrics snapshots from Mammoth's
|
|
7
|
-
#
|
|
6
|
+
# Builds health, readiness, and metrics snapshots from Mammoth's operational
|
|
7
|
+
# state and optional PostgreSQL slot-health provider.
|
|
8
8
|
#
|
|
9
9
|
# ObservabilitySnapshot is intentionally read-only. It does not start the
|
|
10
|
-
# relay, mutate checkpoints, replay dead letters, or
|
|
11
|
-
#
|
|
12
|
-
# operational-state
|
|
13
|
-
class ObservabilitySnapshot
|
|
10
|
+
# relay, mutate checkpoints, replay dead letters, or change PostgreSQL slot
|
|
11
|
+
# state. An injected provider may perform read-only slot inspection. The
|
|
12
|
+
# endpoints expose process, operational-state, and source status predictably.
|
|
13
|
+
class ObservabilitySnapshot # rubocop:disable Metrics/ClassLength
|
|
14
14
|
include ObservabilityMetrics
|
|
15
15
|
|
|
16
|
-
attr_reader :config, :state_adapter, :clock, :dispatch_metrics
|
|
16
|
+
attr_reader :config, :state_adapter, :clock, :dispatch_metrics, :slot_health_provider
|
|
17
17
|
|
|
18
18
|
# @param config [Mammoth::Configuration] loaded configuration
|
|
19
19
|
# @param state_adapter [Mammoth::OperationalState::Adapter, nil] operational state dependency
|
|
20
20
|
# @param clock [#call] time source returning a Time-like object
|
|
21
21
|
# @param dispatch_metrics [Mammoth::DispatchMetrics] dispatch counter registry
|
|
22
|
-
|
|
22
|
+
# @param slot_health_provider [#slot_health, nil] PostgreSQL slot health dependency
|
|
23
|
+
def initialize(config, state_adapter: nil, clock: -> { Time.now.utc }, dispatch_metrics: DispatchMetrics::INSTANCE,
|
|
24
|
+
slot_health_provider: nil)
|
|
23
25
|
@config = config
|
|
24
26
|
@state_adapter = state_adapter || OperationalState::Registry.build_configured(config)
|
|
25
27
|
@clock = clock
|
|
26
28
|
@dispatch_metrics = dispatch_metrics
|
|
29
|
+
@slot_health_provider = slot_health_provider
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# Build a liveness response.
|
|
@@ -45,7 +48,7 @@ module Mammoth
|
|
|
45
48
|
def readiness
|
|
46
49
|
return unready_payload unless state_adapter.ready?
|
|
47
50
|
|
|
48
|
-
{
|
|
51
|
+
payload = {
|
|
49
52
|
status: "ready",
|
|
50
53
|
service: "mammoth",
|
|
51
54
|
name: mammoth_name,
|
|
@@ -54,6 +57,14 @@ module Mammoth
|
|
|
54
57
|
summary: state_summary,
|
|
55
58
|
checked_at: checked_at
|
|
56
59
|
}
|
|
60
|
+
return payload unless slot_health_provider
|
|
61
|
+
|
|
62
|
+
health = postgres_slot_health
|
|
63
|
+
return postgres_unready_payload(health) unless health.ready?
|
|
64
|
+
|
|
65
|
+
payload.merge(postgres_slot: health.summary)
|
|
66
|
+
rescue ReplicationError => e
|
|
67
|
+
postgres_error_payload(e)
|
|
57
68
|
rescue Mammoth::Error => e
|
|
58
69
|
adapter_error_payload(e)
|
|
59
70
|
end
|
|
@@ -71,7 +82,7 @@ module Mammoth
|
|
|
71
82
|
) + destination_metric_lines(
|
|
72
83
|
dead_letter_store: state_adapter.dead_letter_store,
|
|
73
84
|
delivered_store: state_adapter.delivered_envelope_store
|
|
74
|
-
) + dispatch_metric_lines
|
|
85
|
+
) + dispatch_metric_lines + postgres_slot_metric_lines
|
|
75
86
|
"#{lines.join("\n")}\n"
|
|
76
87
|
rescue Mammoth::Error
|
|
77
88
|
down_metrics
|
|
@@ -92,6 +103,36 @@ module Mammoth
|
|
|
92
103
|
}
|
|
93
104
|
end
|
|
94
105
|
|
|
106
|
+
def postgres_unready_payload(health)
|
|
107
|
+
{
|
|
108
|
+
status: "unready",
|
|
109
|
+
service: "mammoth",
|
|
110
|
+
name: mammoth_name,
|
|
111
|
+
operational_state: "ok",
|
|
112
|
+
adapter: state_summary.fetch(:adapter),
|
|
113
|
+
summary: state_summary,
|
|
114
|
+
postgres_slot: health.summary,
|
|
115
|
+
checked_at: checked_at
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def postgres_error_payload(error)
|
|
120
|
+
{
|
|
121
|
+
status: "unready",
|
|
122
|
+
service: "mammoth",
|
|
123
|
+
name: mammoth_name,
|
|
124
|
+
operational_state: "ok",
|
|
125
|
+
adapter: configured_adapter_name,
|
|
126
|
+
postgres_slot: {
|
|
127
|
+
ready: false,
|
|
128
|
+
reason: "inspection failed",
|
|
129
|
+
error_class: error.class.name,
|
|
130
|
+
error_message: error.message
|
|
131
|
+
},
|
|
132
|
+
checked_at: checked_at
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
95
136
|
def aggregate_metric_lines(checkpoint_store:, dead_letter_store:, delivered_store:)
|
|
96
137
|
[
|
|
97
138
|
metric_line("mammoth_up", 1),
|
|
@@ -136,6 +177,18 @@ module Mammoth
|
|
|
136
177
|
"#{(metric_headers + [metric_line("mammoth_up", 0)] + dispatch_metric_lines).join("\n")}\n"
|
|
137
178
|
end
|
|
138
179
|
|
|
180
|
+
def postgres_slot_metric_lines
|
|
181
|
+
return [] unless slot_health_provider
|
|
182
|
+
|
|
183
|
+
postgres_metric_lines(postgres_slot_health)
|
|
184
|
+
rescue ReplicationError
|
|
185
|
+
postgres_inspection_error_metric_lines
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def postgres_slot_health
|
|
189
|
+
slot_health_provider.slot_health
|
|
190
|
+
end
|
|
191
|
+
|
|
139
192
|
def state_summary
|
|
140
193
|
@state_summary ||= state_adapter.summary
|
|
141
194
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# PostgreSQL slot-specific Prometheus formatting helpers.
|
|
5
|
+
module PostgresObservabilityMetrics
|
|
6
|
+
# Metric names and help text for PostgreSQL replication-slot gauges.
|
|
7
|
+
POSTGRES_METRICS = {
|
|
8
|
+
"mammoth_postgres_slot_inspection_up" => "1 when PostgreSQL slot inspection succeeds.",
|
|
9
|
+
"mammoth_postgres_slot_present" => "1 when the configured replication slot exists.",
|
|
10
|
+
"mammoth_postgres_slot_ready" => "1 when the configured slot is healthy and active.",
|
|
11
|
+
"mammoth_postgres_slot_active" => "1 when PostgreSQL reports the slot as active.",
|
|
12
|
+
"mammoth_postgres_slot_retained_wal_bytes" => "WAL retained from the slot restart LSN.",
|
|
13
|
+
"mammoth_postgres_slot_safe_wal_size_bytes" => "WAL bytes that may be written before the slot risks loss.",
|
|
14
|
+
"mammoth_postgres_slot_wal_status" => "Current PostgreSQL WAL retention status for the slot.",
|
|
15
|
+
"mammoth_postgres_slot_invalidated" => "1 when PostgreSQL reports invalidation or conflict.",
|
|
16
|
+
"mammoth_postgres_slot_inactive_since_timestamp_seconds" => "Unix timestamp when the slot became inactive.",
|
|
17
|
+
"mammoth_postgres_slot_restart_lsn_bytes" => "Numeric PostgreSQL restart LSN.",
|
|
18
|
+
"mammoth_postgres_slot_confirmed_flush_lsn_bytes" => "Numeric PostgreSQL confirmed flush LSN."
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def postgres_metric_headers
|
|
24
|
+
POSTGRES_METRICS.flat_map { |name, help| ["# HELP #{name} #{help}", "# TYPE #{name} gauge"] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def postgres_metric_lines(health)
|
|
28
|
+
labels = { slot_name: health.slot_name }
|
|
29
|
+
lines = postgres_presence_metric_lines(health, labels:)
|
|
30
|
+
return lines unless health.present
|
|
31
|
+
|
|
32
|
+
lines + postgres_status_metric_lines(health, labels:) + postgres_optional_metric_lines(health, labels:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def postgres_presence_metric_lines(health, labels:)
|
|
36
|
+
[
|
|
37
|
+
metric_line("mammoth_postgres_slot_inspection_up", 1, labels:),
|
|
38
|
+
metric_line("mammoth_postgres_slot_present", health.present ? 1 : 0, labels:)
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def postgres_status_metric_lines(health, labels:)
|
|
43
|
+
[
|
|
44
|
+
metric_line("mammoth_postgres_slot_ready", health.ready? ? 1 : 0, labels:),
|
|
45
|
+
metric_line("mammoth_postgres_slot_active", health.active ? 1 : 0, labels:),
|
|
46
|
+
metric_line("mammoth_postgres_slot_wal_status", 1,
|
|
47
|
+
labels: labels.merge(wal_status: health.wal_status || "unknown")),
|
|
48
|
+
metric_line("mammoth_postgres_slot_invalidated", invalidated?(health) ? 1 : 0, labels:)
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def postgres_optional_metric_lines(health, labels:)
|
|
53
|
+
{
|
|
54
|
+
"mammoth_postgres_slot_retained_wal_bytes" => health.retained_wal_bytes,
|
|
55
|
+
"mammoth_postgres_slot_safe_wal_size_bytes" => health.safe_wal_size,
|
|
56
|
+
"mammoth_postgres_slot_inactive_since_timestamp_seconds" => timestamp_seconds(health.inactive_since),
|
|
57
|
+
"mammoth_postgres_slot_restart_lsn_bytes" => health.restart_lsn_bytes,
|
|
58
|
+
"mammoth_postgres_slot_confirmed_flush_lsn_bytes" => health.confirmed_flush_lsn_bytes
|
|
59
|
+
}.filter_map { |name, value| metric_line(name, value, labels:) unless value.nil? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def postgres_inspection_error_metric_lines
|
|
63
|
+
labels = { slot_name: config.dig("replication", "slot") }
|
|
64
|
+
[metric_line("mammoth_postgres_slot_inspection_up", 0, labels:)]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def timestamp_seconds(value)
|
|
68
|
+
return nil if value.nil?
|
|
69
|
+
return value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
|
|
70
|
+
|
|
71
|
+
Time.parse(value.to_s).to_i
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def invalidated?(health)
|
|
77
|
+
health.conflicting || (!health.invalidation_reason.nil? && health.invalidation_reason != "")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -27,7 +27,7 @@ module Mammoth
|
|
|
27
27
|
|
|
28
28
|
count = 0
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
each_grouped_event do |event, _group_end|
|
|
31
31
|
yield event
|
|
32
32
|
count += 1
|
|
33
33
|
end
|
|
@@ -35,11 +35,27 @@ module Mammoth
|
|
|
35
35
|
count
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Consume work with an explicit source-group boundary for safe progress.
|
|
39
|
+
#
|
|
40
|
+
# @yieldparam event [CDC::Core::ChangeEvent, CDC::Core::TransactionEnvelope] core work item
|
|
41
|
+
# @yieldparam group_end [Boolean] true for the final delivery in a source transaction
|
|
42
|
+
# @return [Integer] number of consumed work items
|
|
43
|
+
def start_with_boundaries
|
|
44
|
+
return enum_for(:start_with_boundaries) unless block_given?
|
|
45
|
+
|
|
46
|
+
count = 0
|
|
47
|
+
each_grouped_event do |event, group_end|
|
|
48
|
+
yield event, group_end
|
|
49
|
+
count += 1
|
|
50
|
+
end
|
|
51
|
+
count
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
private
|
|
39
55
|
|
|
40
|
-
def
|
|
56
|
+
def each_grouped_event(&block)
|
|
41
57
|
effective_source.each do |work|
|
|
42
|
-
|
|
58
|
+
grouped_cdc_work(work).each { |event, group_end| block.call(event, group_end) }
|
|
43
59
|
end
|
|
44
60
|
end
|
|
45
61
|
|
|
@@ -47,15 +63,20 @@ module Mammoth
|
|
|
47
63
|
source || raise(ReplicationError, "replication source is not configured")
|
|
48
64
|
end
|
|
49
65
|
|
|
50
|
-
def
|
|
66
|
+
def grouped_cdc_work(work)
|
|
51
67
|
return [] if work.nil?
|
|
52
|
-
return work.flat_map { |item|
|
|
53
|
-
return
|
|
54
|
-
return event_work(work) if work.is_a?(CDC::Core::ChangeEvent)
|
|
68
|
+
return work.flat_map { |item| grouped_cdc_work(item) } if work.is_a?(Array)
|
|
69
|
+
return grouped_transaction_work(work) if work.is_a?(CDC::Core::TransactionEnvelope)
|
|
70
|
+
return event_work(work).map { |item| [item, true] } if work.is_a?(CDC::Core::ChangeEvent)
|
|
55
71
|
|
|
56
72
|
raise ReplicationError, "CDC source yielded non-core work: #{work.class}"
|
|
57
73
|
end
|
|
58
74
|
|
|
75
|
+
def grouped_transaction_work(envelope)
|
|
76
|
+
items = transaction_work(envelope)
|
|
77
|
+
items.each_with_index.map { |item, index| [item, index == items.length - 1] }
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
def transaction_delivery?
|
|
60
81
|
delivery_unit == :transaction
|
|
61
82
|
end
|