mammoth 0.5.1 → 0.7.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 +29 -1
- data/README.md +33 -1
- data/config/mammoth.example.yml +38 -0
- data/config/mammoth.schema.json +115 -1
- data/lib/mammoth/application.rb +43 -22
- data/lib/mammoth/capabilities.rb +59 -0
- data/lib/mammoth/cli.rb +5 -4
- data/lib/mammoth/commands/status_command.rb +28 -0
- data/lib/mammoth/dead_letter_commands.rb +92 -13
- data/lib/mammoth/dead_letter_store.rb +48 -17
- data/lib/mammoth/delivered_envelope_store.rb +15 -2
- data/lib/mammoth/delivery_worker.rb +35 -6
- data/lib/mammoth/destinations/adapter.rb +20 -0
- data/lib/mammoth/destinations/registry.rb +46 -0
- data/lib/mammoth/destinations/webhook_adapter.rb +29 -0
- data/lib/mammoth/node_identity.rb +54 -0
- data/lib/mammoth/observability_server.rb +2 -0
- data/lib/mammoth/observability_snapshot.rb +41 -7
- data/lib/mammoth/operational_state/adapter.rb +37 -0
- data/lib/mammoth/operational_state/registry.rb +46 -0
- data/lib/mammoth/operational_state/sqlite_adapter.rb +44 -0
- data/lib/mammoth/registry.rb +46 -0
- data/lib/mammoth/route_filter.rb +49 -0
- data/lib/mammoth/runtimes/adapter.rb +23 -0
- data/lib/mammoth/runtimes/concurrent_adapter.rb +31 -0
- data/lib/mammoth/runtimes/inline_adapter.rb +40 -0
- data/lib/mammoth/runtimes/registry.rb +46 -0
- data/lib/mammoth/status.rb +55 -13
- data/lib/mammoth/version.rb +1 -1
- data/lib/mammoth.rb +19 -0
- metadata +16 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Mammoth
|
|
6
7
|
# Operator commands for inspecting and replaying dead letters.
|
|
@@ -55,7 +56,14 @@ module Mammoth
|
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def list
|
|
58
|
-
|
|
59
|
+
options = list_options
|
|
60
|
+
rows = dead_letter_store.rows(
|
|
61
|
+
status: options.fetch(:status),
|
|
62
|
+
destination: options.fetch(:destination),
|
|
63
|
+
failed_after: options.fetch(:failed_after),
|
|
64
|
+
failed_before: options.fetch(:failed_before),
|
|
65
|
+
limit: options.fetch(:limit)
|
|
66
|
+
)
|
|
59
67
|
puts list_header
|
|
60
68
|
rows.each { |row| puts list_row(row) }
|
|
61
69
|
0
|
|
@@ -73,7 +81,7 @@ module Mammoth
|
|
|
73
81
|
|
|
74
82
|
rows.each do |row|
|
|
75
83
|
result = replay_row(row)
|
|
76
|
-
dead_letter_store.resolve(row.fetch("id"))
|
|
84
|
+
dead_letter_store.resolve(row.fetch("id")) if replay_resolved?(result)
|
|
77
85
|
puts replay_message(row, result)
|
|
78
86
|
end
|
|
79
87
|
0
|
|
@@ -106,7 +114,7 @@ module Mammoth
|
|
|
106
114
|
#
|
|
107
115
|
# @return [Hash] list options
|
|
108
116
|
def list_options
|
|
109
|
-
options = { status: "pending", limit: 100 }
|
|
117
|
+
options = { status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
|
|
110
118
|
index = 0
|
|
111
119
|
args = argv.drop(3)
|
|
112
120
|
|
|
@@ -122,17 +130,42 @@ module Mammoth
|
|
|
122
130
|
def parse_list_option(args, index, options)
|
|
123
131
|
case args.fetch(index)
|
|
124
132
|
when "--status"
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
assign_option(args, index, options, :status)
|
|
134
|
+
when "--destination"
|
|
135
|
+
assign_option(args, index, options, :destination)
|
|
136
|
+
when "--failed-after"
|
|
137
|
+
assign_time_option(args, index, options, :failed_after)
|
|
138
|
+
when "--failed-before"
|
|
139
|
+
assign_time_option(args, index, options, :failed_before)
|
|
127
140
|
when "--limit"
|
|
128
|
-
|
|
129
|
-
index + 2
|
|
141
|
+
assign_limit_option(args, index, options)
|
|
130
142
|
else
|
|
131
143
|
raise ConfigurationError, "unknown option #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
|
|
132
144
|
|
|
133
145
|
raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}"
|
|
134
146
|
end
|
|
135
|
-
rescue
|
|
147
|
+
rescue IndexError
|
|
148
|
+
raise ConfigurationError, "missing value for dead letter option"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def assign_option(args, index, options, key)
|
|
152
|
+
options[key] = args.fetch(index + 1)
|
|
153
|
+
index + 2
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def assign_time_option(args, index, options, key)
|
|
157
|
+
options[key] = parse_time_option(args.fetch(index + 1), args.fetch(index))
|
|
158
|
+
index + 2
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def assign_limit_option(args, index, options)
|
|
162
|
+
options[:limit] = parse_limit_option(args.fetch(index + 1))
|
|
163
|
+
index + 2
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_limit_option(value)
|
|
167
|
+
Integer(value)
|
|
168
|
+
rescue ArgumentError
|
|
136
169
|
raise ConfigurationError, "dead letter limit must be an integer"
|
|
137
170
|
end
|
|
138
171
|
|
|
@@ -140,20 +173,62 @@ module Mammoth
|
|
|
140
173
|
#
|
|
141
174
|
# @return [Array<Hash>] replay rows
|
|
142
175
|
def replay_rows
|
|
143
|
-
|
|
144
|
-
|
|
176
|
+
options = replay_options
|
|
177
|
+
ids = options.fetch(:ids)
|
|
178
|
+
if ids.empty?
|
|
179
|
+
return dead_letter_store.rows(
|
|
180
|
+
status: options.fetch(:status),
|
|
181
|
+
destination: options.fetch(:destination),
|
|
182
|
+
failed_after: options.fetch(:failed_after),
|
|
183
|
+
failed_before: options.fetch(:failed_before),
|
|
184
|
+
limit: options.fetch(:limit)
|
|
185
|
+
)
|
|
186
|
+
end
|
|
145
187
|
|
|
146
188
|
ids.map do |raw_id|
|
|
147
|
-
id =
|
|
189
|
+
id = raw_id
|
|
148
190
|
row = dead_letter_store.fetch(id)
|
|
149
191
|
raise ConfigurationError, "dead letter not found: #{id}" unless row
|
|
150
192
|
|
|
151
193
|
row
|
|
152
|
-
rescue ArgumentError
|
|
153
|
-
raise ConfigurationError, "dead letter id must be an integer"
|
|
154
194
|
end
|
|
155
195
|
end
|
|
156
196
|
|
|
197
|
+
def parse_time_option(value, option_name)
|
|
198
|
+
Time.iso8601(value).utc.iso8601
|
|
199
|
+
rescue ArgumentError
|
|
200
|
+
raise ConfigurationError, "#{option_name} must be an ISO-8601 timestamp"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def replay_options
|
|
204
|
+
ids = [] # : Array[Integer]
|
|
205
|
+
options = { ids: ids, status: "pending", destination: nil, failed_after: nil, failed_before: nil, limit: 100 }
|
|
206
|
+
index = 0
|
|
207
|
+
args = argv.drop(3)
|
|
208
|
+
|
|
209
|
+
# rubocop:disable Style/WhileUntilModifier
|
|
210
|
+
while index < args.length
|
|
211
|
+
index = parse_replay_option(args, index, options)
|
|
212
|
+
end
|
|
213
|
+
# rubocop:enable Style/WhileUntilModifier
|
|
214
|
+
|
|
215
|
+
options
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def parse_replay_option(args, index, options)
|
|
219
|
+
case args.fetch(index)
|
|
220
|
+
when "--status", "--destination", "--failed-after", "--failed-before", "--limit"
|
|
221
|
+
parse_list_option(args, index, options)
|
|
222
|
+
else
|
|
223
|
+
raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
|
|
224
|
+
|
|
225
|
+
options[:ids] << Integer(args.fetch(index))
|
|
226
|
+
index + 1
|
|
227
|
+
end
|
|
228
|
+
rescue ArgumentError
|
|
229
|
+
raise ConfigurationError, "dead letter id must be an integer"
|
|
230
|
+
end
|
|
231
|
+
|
|
157
232
|
def fetch_dead_letter!(id)
|
|
158
233
|
row = dead_letter_store.fetch(id)
|
|
159
234
|
raise ConfigurationError, "dead letter not found: #{id}" unless row
|
|
@@ -182,6 +257,10 @@ module Mammoth
|
|
|
182
257
|
worker.deliver_transaction(envelope)
|
|
183
258
|
end
|
|
184
259
|
|
|
260
|
+
def replay_resolved?(result)
|
|
261
|
+
result.fetch(:status) == "delivered" || result.fetch(:duplicate, false)
|
|
262
|
+
end
|
|
263
|
+
|
|
185
264
|
def transaction_payload?(payload)
|
|
186
265
|
payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
|
|
187
266
|
end
|
|
@@ -65,8 +65,9 @@ module Mammoth
|
|
|
65
65
|
#
|
|
66
66
|
# @param limit [Integer] maximum number of rows
|
|
67
67
|
# @return [Array<Hash>] pending dead letter rows
|
|
68
|
-
def pending(limit: 100)
|
|
69
|
-
rows(status: "pending", limit: limit
|
|
68
|
+
def pending(limit: 100, destination: nil, failed_after: nil, failed_before: nil)
|
|
69
|
+
rows(status: "pending", limit: limit, destination: destination, failed_after: failed_after,
|
|
70
|
+
failed_before: failed_before)
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# Fetch dead letters, optionally filtered by status.
|
|
@@ -74,15 +75,12 @@ module Mammoth
|
|
|
74
75
|
# @param status [String, nil] optional dead-letter status filter
|
|
75
76
|
# @param limit [Integer] maximum number of rows
|
|
76
77
|
# @return [Array<Hash>] dead letter rows
|
|
77
|
-
def rows(status: nil, limit: 100)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
database.execute("SELECT * FROM dead_letters ORDER BY failed_at ASC LIMIT ?", [limit])
|
|
78
|
+
def rows(status: nil, limit: 100, destination: nil, failed_after: nil, failed_before: nil)
|
|
79
|
+
where, values = row_filters(status:, destination:, failed_after:, failed_before:)
|
|
80
|
+
database.execute(
|
|
81
|
+
"SELECT * FROM dead_letters#{where} ORDER BY failed_at ASC LIMIT ?",
|
|
82
|
+
values + [limit]
|
|
83
|
+
)
|
|
86
84
|
end
|
|
87
85
|
|
|
88
86
|
# Fetch one dead letter by id.
|
|
@@ -97,12 +95,21 @@ module Mammoth
|
|
|
97
95
|
#
|
|
98
96
|
# @param status [String, nil] optional status
|
|
99
97
|
# @return [Integer] dead letter count
|
|
100
|
-
def count(status: nil)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
def count(status: nil, destination: nil)
|
|
99
|
+
where, values = row_filters(status:, destination:)
|
|
100
|
+
database.get_first_value("SELECT COUNT(*) FROM dead_letters#{where}", values)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Count dead letters grouped by destination.
|
|
104
|
+
#
|
|
105
|
+
# @param status [String, nil] optional status filter
|
|
106
|
+
# @return [Array<Hash>] rows with destination_name and count
|
|
107
|
+
def counts_by_destination(status: nil)
|
|
108
|
+
where, values = row_filters(status:)
|
|
109
|
+
database.execute(
|
|
110
|
+
"SELECT destination_name, COUNT(*) AS count FROM dead_letters#{where} GROUP BY destination_name",
|
|
111
|
+
values
|
|
112
|
+
)
|
|
106
113
|
end
|
|
107
114
|
|
|
108
115
|
# Mark a dead letter as resolved.
|
|
@@ -133,5 +140,29 @@ module Mammoth
|
|
|
133
140
|
[status, Time.now.utc.iso8601, id]
|
|
134
141
|
)
|
|
135
142
|
end
|
|
143
|
+
|
|
144
|
+
def row_filters(status: nil, destination: nil, failed_after: nil, failed_before: nil)
|
|
145
|
+
predicates = [] # : Array[String]
|
|
146
|
+
values = [] # : Array[untyped]
|
|
147
|
+
|
|
148
|
+
unless status.nil? || status == "all"
|
|
149
|
+
predicates << "status = ?"
|
|
150
|
+
values << status
|
|
151
|
+
end
|
|
152
|
+
if destination
|
|
153
|
+
predicates << "destination_name = ?"
|
|
154
|
+
values << destination
|
|
155
|
+
end
|
|
156
|
+
if failed_after
|
|
157
|
+
predicates << "failed_at >= ?"
|
|
158
|
+
values << failed_after
|
|
159
|
+
end
|
|
160
|
+
if failed_before
|
|
161
|
+
predicates << "failed_at <= ?"
|
|
162
|
+
values << failed_before
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
[predicates.empty? ? "" : " WHERE #{predicates.join(" AND ")}", values]
|
|
166
|
+
end
|
|
136
167
|
end
|
|
137
168
|
end
|
|
@@ -85,8 +85,21 @@ module Mammoth
|
|
|
85
85
|
# Count delivered envelopes.
|
|
86
86
|
#
|
|
87
87
|
# @return [Integer] delivered envelope count
|
|
88
|
-
def count
|
|
89
|
-
|
|
88
|
+
def count(destination: nil)
|
|
89
|
+
if destination
|
|
90
|
+
database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes WHERE destination_name = ?", [destination])
|
|
91
|
+
else
|
|
92
|
+
database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Count delivered envelopes grouped by destination.
|
|
97
|
+
#
|
|
98
|
+
# @return [Array<Hash>] rows with destination_name and count
|
|
99
|
+
def counts_by_destination
|
|
100
|
+
database.execute(
|
|
101
|
+
"SELECT destination_name, COUNT(*) AS count FROM delivered_envelopes GROUP BY destination_name"
|
|
102
|
+
)
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
private
|
|
@@ -12,7 +12,7 @@ module Mammoth
|
|
|
12
12
|
DEFAULT_SOURCE = "postgresql"
|
|
13
13
|
|
|
14
14
|
attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
|
|
15
|
-
:sleeper, :source_name, :slot_name, :publication_name
|
|
15
|
+
:sleeper, :source_name, :slot_name, :publication_name, :route_filter, :enabled
|
|
16
16
|
|
|
17
17
|
# @param sink [#deliver] destination sink
|
|
18
18
|
# @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
|
|
@@ -24,8 +24,11 @@ module Mammoth
|
|
|
24
24
|
# @param max_attempts [Integer] maximum delivery attempts
|
|
25
25
|
# @param retry_schedule [Array<Integer>] retry wait schedule in seconds
|
|
26
26
|
# @param sleeper [#call] sleep strategy, injectable for tests
|
|
27
|
+
# @param route_filter [Mammoth::RouteFilter, nil] optional destination route matcher
|
|
28
|
+
# @param enabled [Boolean] whether this destination accepts new deliveries
|
|
27
29
|
def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
|
|
28
|
-
max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep)
|
|
30
|
+
max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep),
|
|
31
|
+
route_filter: nil, enabled: true)
|
|
29
32
|
@sink = sink
|
|
30
33
|
@checkpoint_store = checkpoint_store
|
|
31
34
|
@dead_letter_store = dead_letter_store
|
|
@@ -36,6 +39,8 @@ module Mammoth
|
|
|
36
39
|
@max_attempts = max_attempts
|
|
37
40
|
@retry_schedule = retry_schedule
|
|
38
41
|
@sleeper = sleeper
|
|
42
|
+
@route_filter = route_filter || RouteFilter.new
|
|
43
|
+
@enabled = enabled
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
# Build a delivery worker from Mammoth configuration and stores.
|
|
@@ -46,7 +51,8 @@ module Mammoth
|
|
|
46
51
|
# @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
|
|
47
52
|
# @param sleeper [#call] sleep strategy
|
|
48
53
|
# @return [Mammoth::DeliveryWorker]
|
|
49
|
-
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep)
|
|
54
|
+
def self.from_config(config, sink:, checkpoint_store:, dead_letter_store:, sleeper: Kernel.method(:sleep),
|
|
55
|
+
delivery_policy: {})
|
|
50
56
|
new(
|
|
51
57
|
sink: sink,
|
|
52
58
|
checkpoint_store: checkpoint_store,
|
|
@@ -54,9 +60,11 @@ module Mammoth
|
|
|
54
60
|
source_name: config.dig("mammoth", "name"),
|
|
55
61
|
slot_name: config.dig("replication", "slot"),
|
|
56
62
|
publication_name: Array(config.dig("replication", "publications")).join(","),
|
|
57
|
-
max_attempts: config.dig("retry", "max_attempts"),
|
|
58
|
-
retry_schedule: config.dig("retry", "schedule_seconds"),
|
|
59
|
-
sleeper: sleeper
|
|
63
|
+
max_attempts: delivery_policy.fetch("max_attempts", config.dig("retry", "max_attempts")),
|
|
64
|
+
retry_schedule: delivery_policy.fetch("schedule_seconds", config.dig("retry", "schedule_seconds")),
|
|
65
|
+
sleeper: sleeper,
|
|
66
|
+
route_filter: delivery_policy.fetch("route_filter", RouteFilter.new),
|
|
67
|
+
enabled: delivery_policy.fetch("enabled", true)
|
|
60
68
|
)
|
|
61
69
|
end
|
|
62
70
|
|
|
@@ -85,6 +93,9 @@ module Mammoth
|
|
|
85
93
|
delivery_unit = delivery_unit_for(delivery_method)
|
|
86
94
|
idempotency_key = idempotency_key_for(payload:, delivery_unit:)
|
|
87
95
|
|
|
96
|
+
skip_result = skip_result_for(payload, idempotency_key:)
|
|
97
|
+
return skip_result if skip_result
|
|
98
|
+
|
|
88
99
|
if delivered_envelope_store.delivered?(idempotency_key)
|
|
89
100
|
checkpoint_payload(payload)
|
|
90
101
|
return {
|
|
@@ -151,6 +162,24 @@ module Mammoth
|
|
|
151
162
|
sink.respond_to?(:name) ? sink.name : sink.class.name
|
|
152
163
|
end
|
|
153
164
|
|
|
165
|
+
def skip_result_for(payload, idempotency_key:)
|
|
166
|
+
return skipped(payload, idempotency_key:, reason: "disabled") unless enabled
|
|
167
|
+
|
|
168
|
+
skipped(payload, idempotency_key:, reason: "route_mismatch") unless route_filter.match_payload?(payload)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def skipped(payload, idempotency_key:, reason:)
|
|
172
|
+
checkpoint_payload(payload)
|
|
173
|
+
{
|
|
174
|
+
status: "skipped",
|
|
175
|
+
reason: reason,
|
|
176
|
+
duplicate: false,
|
|
177
|
+
idempotency_key: idempotency_key,
|
|
178
|
+
attempts: 0,
|
|
179
|
+
destination: destination_name
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
154
183
|
def dead_letter(event, error, attempts, serializer:)
|
|
155
184
|
id = dead_letter_store.write(
|
|
156
185
|
event: event,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Destination adapter contracts and registry.
|
|
5
|
+
module Destinations
|
|
6
|
+
# Base contract for destination adapters.
|
|
7
|
+
class Adapter
|
|
8
|
+
# @return [Hash] JSON-friendly adapter capabilities
|
|
9
|
+
def self.capabilities
|
|
10
|
+
{ type: adapter_type }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String] adapter type name
|
|
14
|
+
def self.adapter_type
|
|
15
|
+
type = name.split("::").last.to_s.delete_suffix("Adapter").downcase
|
|
16
|
+
type.empty? ? "adapter" : type
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
module Destinations
|
|
5
|
+
# Registry for destination adapters.
|
|
6
|
+
module Registry
|
|
7
|
+
class << self
|
|
8
|
+
# Register a destination adapter.
|
|
9
|
+
#
|
|
10
|
+
# @param name [String, Symbol] adapter name
|
|
11
|
+
# @param adapter [Object] adapter class
|
|
12
|
+
# @return [Object] registered adapter
|
|
13
|
+
def register(name, adapter)
|
|
14
|
+
registry.register(name, adapter)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Fetch a destination adapter.
|
|
18
|
+
#
|
|
19
|
+
# @param name [String, Symbol] adapter name
|
|
20
|
+
# @return [Object] adapter class
|
|
21
|
+
def fetch(name)
|
|
22
|
+
registry.fetch(name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build a destination sink from config.
|
|
26
|
+
#
|
|
27
|
+
# @param destination [Hash] destination config
|
|
28
|
+
# @param label [String] config label for errors
|
|
29
|
+
# @return [Object] delivery sink
|
|
30
|
+
def build(destination, label:)
|
|
31
|
+
fetch(destination.fetch("type", "webhook")).build(destination, label: label)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Array<String>] registered destination adapter names
|
|
35
|
+
def names
|
|
36
|
+
registry.names
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Mammoth::Registry] underlying registry
|
|
40
|
+
def registry
|
|
41
|
+
@registry ||= Mammoth::Registry.new("destination")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
module Destinations
|
|
5
|
+
# Built-in webhook destination adapter.
|
|
6
|
+
class WebhookAdapter < Adapter
|
|
7
|
+
class << self
|
|
8
|
+
# @return [String] adapter type name
|
|
9
|
+
def adapter_type
|
|
10
|
+
"webhook"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Build a webhook sink from destination configuration.
|
|
14
|
+
#
|
|
15
|
+
# @param destination [Hash] destination configuration
|
|
16
|
+
# @param label [String] config path label for errors
|
|
17
|
+
# @return [Mammoth::WebhookSink]
|
|
18
|
+
def build(destination, label:)
|
|
19
|
+
WebhookSink.from_destination_config(destination, label: label)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Hash] JSON-friendly capabilities
|
|
23
|
+
def capabilities
|
|
24
|
+
{ type: adapter_type, delivery_units: %w[event transaction], signing: true, header_env: true }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Mammoth
|
|
6
|
+
# Local Mammoth node identity used by status and future control-plane agents.
|
|
7
|
+
class NodeIdentity
|
|
8
|
+
attr_reader :node_id, :node_name, :fleet_id, :environment, :labels, :metadata
|
|
9
|
+
|
|
10
|
+
# @param node_id [String] stable node identifier
|
|
11
|
+
# @param node_name [String] human-readable node name
|
|
12
|
+
# @param fleet_id [String, nil] optional fleet identifier
|
|
13
|
+
# @param environment [String, nil] optional environment name
|
|
14
|
+
# @param labels [Hash] operator-defined labels
|
|
15
|
+
# @param metadata [Hash] operator-defined metadata
|
|
16
|
+
def initialize(node_id:, node_name:, fleet_id: nil, environment: nil, labels: {}, metadata: {})
|
|
17
|
+
@node_id = node_id
|
|
18
|
+
@node_name = node_name
|
|
19
|
+
@fleet_id = fleet_id
|
|
20
|
+
@environment = environment
|
|
21
|
+
@labels = labels || {}
|
|
22
|
+
@metadata = metadata || {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build identity from Mammoth configuration.
|
|
26
|
+
#
|
|
27
|
+
# @param config [Mammoth::Configuration] loaded configuration
|
|
28
|
+
# @return [Mammoth::NodeIdentity]
|
|
29
|
+
def self.from_config(config)
|
|
30
|
+
identity = config.data["node"] || {}
|
|
31
|
+
hostname = Socket.gethostname
|
|
32
|
+
new(
|
|
33
|
+
node_id: identity["node_id"] || hostname,
|
|
34
|
+
node_name: identity["node_name"] || config.dig("mammoth", "name") || hostname,
|
|
35
|
+
fleet_id: identity["fleet_id"],
|
|
36
|
+
environment: identity["environment"],
|
|
37
|
+
labels: identity["labels"] || {},
|
|
38
|
+
metadata: identity["metadata"] || {}
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash] JSON-friendly node identity
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
node_id: node_id,
|
|
46
|
+
node_name: node_name,
|
|
47
|
+
fleet_id: fleet_id,
|
|
48
|
+
environment: environment,
|
|
49
|
+
labels: labels,
|
|
50
|
+
metadata: metadata
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -10,7 +10,9 @@ module Mammoth
|
|
|
10
10
|
# may run it as a sidecar-like process or in a separate process that points at
|
|
11
11
|
# the same SQLite operational database.
|
|
12
12
|
class ObservabilityServer
|
|
13
|
+
# Default bind host for the observability server.
|
|
13
14
|
DEFAULT_HOST = "0.0.0.0"
|
|
15
|
+
# Default TCP port for the observability server.
|
|
14
16
|
DEFAULT_PORT = 9393
|
|
15
17
|
|
|
16
18
|
attr_reader :config, :host, :port, :sqlite_store, :logger, :server
|
|
@@ -71,7 +71,20 @@ module Mammoth
|
|
|
71
71
|
dead_letter_store = DeadLetterStore.new(store)
|
|
72
72
|
delivered_store = DeliveredEnvelopeStore.new(store)
|
|
73
73
|
|
|
74
|
-
lines = metric_headers +
|
|
74
|
+
lines = metric_headers + aggregate_metric_lines(
|
|
75
|
+
checkpoint_store: checkpoint_store,
|
|
76
|
+
dead_letter_store: dead_letter_store,
|
|
77
|
+
delivered_store: delivered_store
|
|
78
|
+
) + destination_metric_lines(dead_letter_store:, delivered_store:)
|
|
79
|
+
"#{lines.join("\n")}\n"
|
|
80
|
+
rescue Mammoth::Error, SQLite3::Exception
|
|
81
|
+
"#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def aggregate_metric_lines(checkpoint_store:, dead_letter_store:, delivered_store:)
|
|
87
|
+
[
|
|
75
88
|
metric_line("mammoth_up", 1),
|
|
76
89
|
metric_line("mammoth_checkpoints_total", checkpoint_store.count),
|
|
77
90
|
metric_line("mammoth_dead_letters_total", dead_letter_store.count),
|
|
@@ -80,12 +93,24 @@ module Mammoth
|
|
|
80
93
|
metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
|
|
81
94
|
metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
|
|
82
95
|
]
|
|
83
|
-
"#{lines.join("\n")}\n"
|
|
84
|
-
rescue Mammoth::Error, SQLite3::Exception
|
|
85
|
-
"#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
|
|
86
96
|
end
|
|
87
97
|
|
|
88
|
-
|
|
98
|
+
def destination_metric_lines(dead_letter_store:, delivered_store:)
|
|
99
|
+
destination_names.flat_map do |destination|
|
|
100
|
+
[
|
|
101
|
+
metric_line("mammoth_dead_letters_total", dead_letter_store.count(destination: destination),
|
|
102
|
+
destination: destination),
|
|
103
|
+
metric_line("mammoth_dead_letters_pending_total",
|
|
104
|
+
dead_letter_store.count(status: "pending", destination: destination), destination: destination),
|
|
105
|
+
metric_line("mammoth_dead_letters_resolved_total",
|
|
106
|
+
dead_letter_store.count(status: "resolved", destination: destination), destination: destination),
|
|
107
|
+
metric_line("mammoth_dead_letters_ignored_total",
|
|
108
|
+
dead_letter_store.count(status: "ignored", destination: destination), destination: destination),
|
|
109
|
+
metric_line("mammoth_delivered_envelopes_total", delivered_store.count(destination: destination),
|
|
110
|
+
destination: destination)
|
|
111
|
+
]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
89
114
|
|
|
90
115
|
def operational_store
|
|
91
116
|
sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
|
|
@@ -95,6 +120,13 @@ module Mammoth
|
|
|
95
120
|
config.dig("mammoth", "name")
|
|
96
121
|
end
|
|
97
122
|
|
|
123
|
+
def destination_names
|
|
124
|
+
destinations = config.data["destinations"]
|
|
125
|
+
return destinations.map { |destination| destination.fetch("name") } if destinations
|
|
126
|
+
|
|
127
|
+
[config.dig("webhook", "name")]
|
|
128
|
+
end
|
|
129
|
+
|
|
98
130
|
def checked_at
|
|
99
131
|
clock.call.utc.iso8601
|
|
100
132
|
end
|
|
@@ -118,8 +150,10 @@ module Mammoth
|
|
|
118
150
|
]
|
|
119
151
|
end
|
|
120
152
|
|
|
121
|
-
def metric_line(name, value)
|
|
122
|
-
|
|
153
|
+
def metric_line(name, value, destination: nil)
|
|
154
|
+
labels = { "mammoth_name" => mammoth_name }
|
|
155
|
+
labels["destination"] = destination if destination
|
|
156
|
+
%(#{name}{#{labels.map { |label, label_value| %(#{label}="#{escape_label(label_value)}") }.join(",")}} #{Integer(value)})
|
|
123
157
|
end
|
|
124
158
|
|
|
125
159
|
def escape_label(value)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mammoth
|
|
4
|
+
# Operational state adapter contracts and registry.
|
|
5
|
+
module OperationalState
|
|
6
|
+
# Base contract for operational state adapters.
|
|
7
|
+
class Adapter
|
|
8
|
+
# @return [void]
|
|
9
|
+
def initialize; end
|
|
10
|
+
|
|
11
|
+
# @return [Mammoth::CheckpointStore]
|
|
12
|
+
def checkpoint_store
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #checkpoint_store"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Mammoth::DeadLetterStore]
|
|
17
|
+
def dead_letter_store
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #dead_letter_store"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Mammoth::DeliveredEnvelopeStore]
|
|
22
|
+
def delivered_envelope_store
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #delivered_envelope_store"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] JSON-friendly state summary
|
|
27
|
+
def summary
|
|
28
|
+
{
|
|
29
|
+
adapter: "unknown",
|
|
30
|
+
checkpoints: checkpoint_store.count,
|
|
31
|
+
dead_letters: dead_letter_store.count,
|
|
32
|
+
delivered_envelopes: delivered_envelope_store.count
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|