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.
@@ -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
- rows = dead_letter_store.rows(status: list_options.fetch(:status), limit: list_options.fetch(:limit))
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")) unless result.fetch(:status) == "dead_lettered"
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
- options[:status] = args.fetch(index + 1)
126
- index + 2
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
- options[:limit] = Integer(args.fetch(index + 1))
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 ArgumentError, IndexError
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
- ids = argv.drop(3)
144
- return dead_letter_store.pending if ids.empty?
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 = Integer(raw_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
- if status && status != "all"
79
- return database.execute(
80
- "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
81
- [status, limit]
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
- if status
102
- database.get_first_value("SELECT COUNT(*) FROM dead_letters WHERE status = ?", [status])
103
- else
104
- database.get_first_value("SELECT COUNT(*) FROM dead_letters")
105
- end
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
- database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
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
- private
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
- %(#{name}{mammoth_name="#{escape_label(mammoth_name)}"} #{Integer(value)})
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