mammoth 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19aba46c6ef2ae1099287e37281015b747dde1b32c94cbd1c8ec8a23d6b1a19b
4
- data.tar.gz: 21430175d42c71e83eaede3f70390a87d760a9ac2d8b9b4dedcfb446feefaaa0
3
+ metadata.gz: 8fdbb7afb90f584e11024c75d7300f54978f5e5b1a3628597bb460f96a306c22
4
+ data.tar.gz: 9ced2250d7e2f95558a8bcce51809aa4d670d49953d8d970e168a0c48cf90215
5
5
  SHA512:
6
- metadata.gz: b29ea1b4ace38ffa6eb155251dbcd0056e400849aa6a37093d4ecec09cc314089c36523b22b3bbbf898b0247fb6918e3320ef0f14b24d280fed91df2a2287f9a
7
- data.tar.gz: b2e6f58f77884641970e838a5aaa8c2c1ef0a2531b868a53c3ae8ee450387128b04b39c2661e5fac507c82d05b11739a913285c8f9c2052db137810881073a3e
6
+ metadata.gz: cd6b5b3246cf70adb9beb0cc9243e6a35788d20f3442ab406bca66a4cb08bd4a7d80639386c4e42291b518190a0198f960ed9e16cbd165f335a7f3fee10317b7
7
+ data.tar.gz: 87aa4ee77c38aa69d1a505bf7eb2b838de86504316840dfc26b4e875870333f64da906c85aa180a205e5e3bff4f439e36d2f82ffa79d650117d9f5ccc4c29b69
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0
6
+
7
+ ### Added
8
+
9
+ - Added optional observability support with `/healthz`, `/readyz`, and `/metrics` endpoints, plus CLI startup, configuration, and documentation coverage.
10
+
11
+ ## 0.4.0
12
+
13
+ ### Added
14
+
15
+ - Added dead-letter inspection and replay commands for operational recovery.
16
+ - Added dead-letter support for list, show, and replay, with replay routing both event and transaction dead letters back through the existing delivery path and resolving successful rows
17
+
18
+
5
19
  ## 0.2.0
6
20
 
7
21
  ### Added
data/README.md CHANGED
@@ -47,11 +47,13 @@ https://kanutocd.github.io/mammoth/Mammoth.html
47
47
  - dead letter persistence
48
48
  - webhook delivery sink
49
49
  - delivery worker with retry, checkpoint, and DLQ handling
50
+ - dead-letter inspection and replay commands
50
51
  - CDC-core event serialization boundary
51
52
  - CDC Ecosystem source-adapter integration boundary
52
53
  - Docker image support
53
54
  - public Helm chart support
54
55
  - unit and e2e test tasks
56
+ - Health and metrics endpoints
55
57
 
56
58
  ## Boundary
57
59
 
@@ -83,6 +85,7 @@ bundle exec ./exe/mammoth validate config/mammoth.example.yml
83
85
  bundle exec ./exe/mammoth bootstrap config/mammoth.example.yml
84
86
  bundle exec ./exe/mammoth status config/mammoth.example.yml
85
87
  bundle exec ./exe/mammoth start config/mammoth.example.yml
88
+ bundle exec ./exe/mammoth observability config/mammoth.example.yml
86
89
  ```
87
90
 
88
91
  Deliver a single normalized event JSON file through Mammoth's delivery path:
@@ -274,6 +274,13 @@ sqlite:
274
274
  # In Kubernetes, back this path with a PersistentVolumeClaim.
275
275
  path: data/mammoth.db
276
276
 
277
+ observability:
278
+ # Bind host for the optional health, readiness, and metrics server.
279
+ host: 0.0.0.0
280
+
281
+ # HTTP port for /healthz, /readyz, and /metrics.
282
+ port: 9393
283
+
277
284
  logging:
278
285
  # Log verbosity.
279
286
  #
@@ -319,6 +319,24 @@
319
319
  }
320
320
  },
321
321
  "description": "Downstream delivery runtime settings. Runtime concurrency affects webhook/destination delivery only, not PostgreSQL replication stream count."
322
+ },
323
+ "observability": {
324
+ "type": "object",
325
+ "additionalProperties": false,
326
+ "properties": {
327
+ "host": {
328
+ "type": "string",
329
+ "minLength": 1,
330
+ "description": "Bind host for the optional Mammoth observability HTTP server exposing /healthz, /readyz, and /metrics."
331
+ },
332
+ "port": {
333
+ "type": "integer",
334
+ "minimum": 1,
335
+ "maximum": 65535,
336
+ "description": "TCP port for the optional Mammoth observability HTTP server."
337
+ }
338
+ },
339
+ "description": "Optional health, readiness, and metrics endpoint settings."
322
340
  }
323
341
  },
324
342
  "description": "Mammoth relay configuration. Mammoth consumes PostgreSQL logical replication changes and delivers them to downstream destinations while preserving checkpoint, retry, and dead-letter semantics."
data/lib/mammoth/cli.rb CHANGED
@@ -5,6 +5,16 @@ require "json"
5
5
  module Mammoth
6
6
  # Small command dispatcher for Mammoth's operator-facing CLI.
7
7
  class CLI
8
+ # Internal replay envelope used for transaction dead-letter recovery.
9
+ DEAD_LETTER_TRANSACTION_ENVELOPE = Data.define(
10
+ :events,
11
+ :transaction_id,
12
+ :source_position,
13
+ :commit_lsn,
14
+ :committed_at,
15
+ :metadata
16
+ )
17
+
8
18
  # Human-readable command usage printed for invalid or incomplete invocations.
9
19
  USAGE = [
10
20
  "Usage:",
@@ -13,7 +23,11 @@ module Mammoth
13
23
  " mammoth bootstrap CONFIG",
14
24
  " mammoth status CONFIG",
15
25
  " mammoth start CONFIG",
16
- " mammoth deliver-sample CONFIG EVENT_JSON"
26
+ " mammoth deliver-sample CONFIG EVENT_JSON",
27
+ " mammoth dead-letters list CONFIG [--status STATUS] [--limit N]",
28
+ " mammoth dead-letters show CONFIG ID",
29
+ " mammoth dead-letters replay CONFIG [ID ...]",
30
+ " mammoth observability CONFIG"
17
31
  ].join("\n")
18
32
 
19
33
  # Run the CLI.
@@ -42,6 +56,8 @@ module Mammoth
42
56
  when "status" then status
43
57
  when "start" then start
44
58
  when "deliver-sample" then deliver_sample
59
+ when "dead-letters" then dead_letters
60
+ when "observability" then observability
45
61
  else
46
62
  warn USAGE
47
63
  1
@@ -109,6 +125,21 @@ module Mammoth
109
125
  raise ConfigurationError, "invalid event JSON in #{event_path}: #{e.message}"
110
126
  end
111
127
 
128
+ # Dispatch the nested dead-letter command group.
129
+ #
130
+ # @return [Integer] process status code
131
+ def dead_letters
132
+ DeadLetterCommands.call(argv)
133
+ end
134
+
135
+ def observability
136
+ config = load_config
137
+ server = ObservabilityServer.new(config)
138
+ puts "Mammoth observability listening on #{server.host}:#{server.port}"
139
+ server.start
140
+ 0
141
+ end
142
+
112
143
  def load_config
113
144
  raise ConfigurationError, "configuration path required\n#{USAGE}" unless config_path
114
145
 
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mammoth
6
+ # Operator commands for inspecting and replaying dead letters.
7
+ # rubocop:disable Metrics/ClassLength
8
+ class DeadLetterCommands
9
+ # Internal replay envelope used for transaction dead-letter recovery.
10
+ DEAD_LETTER_TRANSACTION_ENVELOPE = Data.define(
11
+ :events,
12
+ :transaction_id,
13
+ :source_position,
14
+ :commit_lsn,
15
+ :committed_at,
16
+ :metadata
17
+ )
18
+
19
+ attr_reader :argv
20
+
21
+ # @param argv [Array<String>] command line arguments
22
+ def self.call(argv)
23
+ new(argv).call
24
+ end
25
+
26
+ # @param argv [Array<String>] command line arguments
27
+ def initialize(argv)
28
+ @argv = argv
29
+ end
30
+
31
+ # Dispatch the nested dead-letter subcommand.
32
+ #
33
+ # @return [Integer] process status code
34
+ def call
35
+ case command
36
+ when "list" then list
37
+ when "show" then show
38
+ when "replay" then replay
39
+ else
40
+ raise ConfigurationError, "dead-letters subcommand required\n#{CLI::USAGE}"
41
+ end
42
+ rescue Mammoth::Error => e
43
+ warn e.message
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ def command
50
+ argv.fetch(1, nil)
51
+ end
52
+
53
+ def config_path
54
+ argv.fetch(2, nil)
55
+ end
56
+
57
+ def list
58
+ rows = dead_letter_store.rows(status: list_options.fetch(:status), limit: list_options.fetch(:limit))
59
+ puts list_header
60
+ rows.each { |row| puts list_row(row) }
61
+ 0
62
+ end
63
+
64
+ def show
65
+ row = fetch_dead_letter!(dead_letter_id)
66
+ puts JSON.pretty_generate(show_payload(row))
67
+ 0
68
+ end
69
+
70
+ def replay
71
+ rows = replay_rows
72
+ raise ConfigurationError, "no dead letters found to replay" if rows.empty?
73
+
74
+ rows.each do |row|
75
+ result = replay_row(row)
76
+ dead_letter_store.resolve(row.fetch("id")) unless result.fetch(:status) == "dead_lettered"
77
+ puts replay_message(row, result)
78
+ end
79
+ 0
80
+ end
81
+
82
+ def load_config
83
+ raise ConfigurationError, "configuration path required\n#{CLI::USAGE}" unless config_path
84
+
85
+ Configuration.load(config_path)
86
+ end
87
+
88
+ def dead_letter_store
89
+ @dead_letter_store ||= DeadLetterStore.new(SQLiteStore.connect(load_config.dig("sqlite", "path")).bootstrap!)
90
+ end
91
+
92
+ def worker
93
+ @worker ||= Application.new(load_config).delivery_worker
94
+ end
95
+
96
+ def dead_letter_id
97
+ raw_id = argv.fetch(3, nil)
98
+ raise ConfigurationError, "dead letter id required\n#{CLI::USAGE}" unless raw_id
99
+
100
+ Integer(raw_id)
101
+ rescue ArgumentError
102
+ raise ConfigurationError, "dead letter id must be an integer"
103
+ end
104
+
105
+ # Parse dead-letter list filters and pagination.
106
+ #
107
+ # @return [Hash] list options
108
+ def list_options
109
+ options = { status: "pending", limit: 100 }
110
+ index = 0
111
+ args = argv.drop(3)
112
+
113
+ # rubocop:disable Style/WhileUntilModifier
114
+ while index < args.length
115
+ index = parse_list_option(args, index, options)
116
+ end
117
+ # rubocop:enable Style/WhileUntilModifier
118
+
119
+ options
120
+ end
121
+
122
+ def parse_list_option(args, index, options)
123
+ case args.fetch(index)
124
+ when "--status"
125
+ options[:status] = args.fetch(index + 1)
126
+ index + 2
127
+ when "--limit"
128
+ options[:limit] = Integer(args.fetch(index + 1))
129
+ index + 2
130
+ else
131
+ raise ConfigurationError, "unknown option #{args[index]}\n#{CLI::USAGE}" if args[index].start_with?("--")
132
+
133
+ raise ConfigurationError, "unexpected argument #{args[index]}\n#{CLI::USAGE}"
134
+ end
135
+ rescue ArgumentError, IndexError
136
+ raise ConfigurationError, "dead letter limit must be an integer"
137
+ end
138
+
139
+ # Resolve the rows that should be replayed.
140
+ #
141
+ # @return [Array<Hash>] replay rows
142
+ def replay_rows
143
+ ids = argv.drop(3)
144
+ return dead_letter_store.pending if ids.empty?
145
+
146
+ ids.map do |raw_id|
147
+ id = Integer(raw_id)
148
+ row = dead_letter_store.fetch(id)
149
+ raise ConfigurationError, "dead letter not found: #{id}" unless row
150
+
151
+ row
152
+ rescue ArgumentError
153
+ raise ConfigurationError, "dead letter id must be an integer"
154
+ end
155
+ end
156
+
157
+ def fetch_dead_letter!(id)
158
+ row = dead_letter_store.fetch(id)
159
+ raise ConfigurationError, "dead letter not found: #{id}" unless row
160
+
161
+ row
162
+ end
163
+
164
+ def replay_row(row)
165
+ payload = JSON.parse(row.fetch("payload_json"))
166
+ transaction_payload?(payload) ? worker.deliver_transaction(transaction_envelope(payload)) : worker.deliver(payload)
167
+ end
168
+
169
+ def transaction_payload?(payload)
170
+ payload.fetch("type", nil) == TransactionEnvelopeSerializer::PAYLOAD_TYPE
171
+ end
172
+
173
+ def transaction_envelope(payload)
174
+ DEAD_LETTER_TRANSACTION_ENVELOPE.new(
175
+ payload.fetch("events"),
176
+ payload.fetch("transaction_id"),
177
+ payload["source_position"],
178
+ payload["commit_lsn"],
179
+ payload["committed_at"],
180
+ payload["metadata"] || {}
181
+ )
182
+ end
183
+
184
+ def show_payload(row)
185
+ row.merge("payload" => JSON.parse(row.fetch("payload_json")))
186
+ end
187
+
188
+ def list_header
189
+ format(
190
+ "%<id>-4s %<status>-10s %<destination>-18s %<retries>-8s %<failed_at>-19s %<event>s",
191
+ id: "ID",
192
+ status: "STATUS",
193
+ destination: "DESTINATION",
194
+ retries: "RETRIES",
195
+ failed_at: "FAILED_AT",
196
+ event: "EVENT"
197
+ )
198
+ end
199
+
200
+ def list_row(row)
201
+ format(
202
+ "%<id>-4s %<status>-10s %<destination>-18s %<retries>-8s %<failed_at>-19s %<event>s",
203
+ id: row.fetch("id"),
204
+ status: row.fetch("status"),
205
+ destination: row.fetch("destination_name"),
206
+ retries: row.fetch("retry_count"),
207
+ failed_at: row.fetch("failed_at"),
208
+ event: row.fetch("event_id")
209
+ )
210
+ end
211
+
212
+ def replay_message(row, result)
213
+ "Dead letter #{row.fetch("id")}: #{result.fetch(:status)}"
214
+ end
215
+ end
216
+ # rubocop:enable Metrics/ClassLength
217
+ end
@@ -66,10 +66,31 @@ module Mammoth
66
66
  # @param limit [Integer] maximum number of rows
67
67
  # @return [Array<Hash>] pending dead letter rows
68
68
  def pending(limit: 100)
69
- database.execute(
70
- "SELECT * FROM dead_letters WHERE status = ? ORDER BY failed_at ASC LIMIT ?",
71
- ["pending", limit]
72
- )
69
+ rows(status: "pending", limit: limit)
70
+ end
71
+
72
+ # Fetch dead letters, optionally filtered by status.
73
+ #
74
+ # @param status [String, nil] optional dead-letter status filter
75
+ # @param limit [Integer] maximum number of rows
76
+ # @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])
86
+ end
87
+
88
+ # Fetch one dead letter by id.
89
+ #
90
+ # @param id [Integer] dead letter id
91
+ # @return [Hash, nil] dead letter row
92
+ def fetch(id)
93
+ database.get_first_row("SELECT * FROM dead_letters WHERE id = ?", [id])
73
94
  end
74
95
 
75
96
  # Count dead letters by status.
@@ -9,6 +9,7 @@ module Mammoth
9
9
  # keeps a small delivery ledger so a transaction replayed by the upstream
10
10
  # replication source after restart does not have to be delivered downstream again.
11
11
  class DeliveredEnvelopeStore
12
+ # SQLite schema used to bootstrap the delivered-envelope ledger.
12
13
  SCHEMA = <<~SQL
13
14
  CREATE TABLE IF NOT EXISTS delivered_envelopes (
14
15
  id INTEGER PRIMARY KEY,
@@ -81,6 +82,9 @@ module Mammoth
81
82
  database.execute("SELECT * FROM delivered_envelopes ORDER BY id")
82
83
  end
83
84
 
85
+ # Count delivered envelopes.
86
+ #
87
+ # @return [Integer] delivered envelope count
84
88
  def count
85
89
  database.get_first_value("SELECT COUNT(*) FROM delivered_envelopes")
86
90
  end
@@ -56,6 +56,10 @@ module Mammoth
56
56
  process(work)
57
57
  end
58
58
 
59
+ # Process one work item using the configured delivery unit.
60
+ #
61
+ # @param work [Object] event or transaction envelope
62
+ # @return [Hash] delivery summary
59
63
  def process(work)
60
64
  case delivery_unit
61
65
  when :transaction
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "webrick"
5
+
6
+ module Mammoth
7
+ # Small HTTP server exposing Mammoth health, readiness, and metrics endpoints.
8
+ #
9
+ # The server is intentionally independent from the replication loop. Operators
10
+ # may run it as a sidecar-like process or in a separate process that points at
11
+ # the same SQLite operational database.
12
+ class ObservabilityServer
13
+ DEFAULT_HOST = "0.0.0.0"
14
+ DEFAULT_PORT = 9393
15
+
16
+ attr_reader :config, :host, :port, :sqlite_store, :logger, :server
17
+
18
+ # @param config [Mammoth::Configuration] loaded configuration
19
+ # @param host [String, nil] bind host override
20
+ # @param port [Integer, nil] bind port override
21
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
22
+ # @param logger [WEBrick::Log, nil] optional WEBrick logger
23
+ def initialize(config, host: nil, port: nil, sqlite_store: nil, logger: nil)
24
+ @config = config
25
+ @host = host || config.dig("observability", "host") || DEFAULT_HOST
26
+ @port = port || config.dig("observability", "port") || DEFAULT_PORT
27
+ @sqlite_store = sqlite_store
28
+ @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
29
+ @server = build_server
30
+ mount_endpoints
31
+ end
32
+
33
+ # Start the blocking HTTP server.
34
+ #
35
+ # @return [void]
36
+ def start
37
+ server.start
38
+ end
39
+
40
+ # Stop the HTTP server.
41
+ #
42
+ # @return [void]
43
+ def shutdown
44
+ server.shutdown
45
+ end
46
+
47
+ private
48
+
49
+ def build_server
50
+ WEBrick::HTTPServer.new(
51
+ BindAddress: host,
52
+ Port: port,
53
+ Logger: logger,
54
+ AccessLog: []
55
+ )
56
+ end
57
+
58
+ def mount_endpoints
59
+ server.mount_proc("/healthz") { |_request, response| write_json(response, snapshot.health, status: 200) }
60
+ server.mount_proc("/readyz") { |_request, response| write_readiness(response) }
61
+ server.mount_proc("/metrics") { |_request, response| write_metrics(response) }
62
+ end
63
+
64
+ def write_readiness(response)
65
+ payload = snapshot.readiness
66
+ write_json(response, payload, status: payload.fetch(:status) == "ready" ? 200 : 503)
67
+ end
68
+
69
+ def write_metrics(response)
70
+ response.status = 200
71
+ response["Content-Type"] = "text/plain; version=0.0.4; charset=utf-8"
72
+ response.body = snapshot.prometheus
73
+ end
74
+
75
+ def write_json(response, payload, status:)
76
+ response.status = status
77
+ response["Content-Type"] = "application/json"
78
+ response.body = JSON.generate(payload)
79
+ end
80
+
81
+ def snapshot
82
+ ObservabilitySnapshot.new(config, sqlite_store: sqlite_store)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Mammoth
6
+ # Builds health, readiness, and metrics snapshots from Mammoth's local
7
+ # operational state.
8
+ #
9
+ # ObservabilitySnapshot is intentionally read-only. It does not start the
10
+ # relay, mutate checkpoints, replay dead letters, or inspect PostgreSQL. The
11
+ # health and metrics endpoints use this object to expose Mammoth process and
12
+ # SQLite operational-state status in a predictable format.
13
+ class ObservabilitySnapshot
14
+ attr_reader :config, :sqlite_store, :clock
15
+
16
+ # @param config [Mammoth::Configuration] loaded configuration
17
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] optional operational store
18
+ # @param clock [#call] time source returning a Time-like object
19
+ def initialize(config, sqlite_store: nil, clock: -> { Time.now.utc })
20
+ @config = config
21
+ @sqlite_store = sqlite_store
22
+ @clock = clock
23
+ end
24
+
25
+ # Build a liveness response.
26
+ #
27
+ # @return [Hash] health payload
28
+ def health
29
+ {
30
+ status: "ok",
31
+ service: "mammoth",
32
+ name: mammoth_name,
33
+ version: Mammoth::VERSION,
34
+ checked_at: checked_at
35
+ }
36
+ end
37
+
38
+ # Build a readiness response.
39
+ #
40
+ # @return [Hash] readiness payload
41
+ def readiness
42
+ store = operational_store
43
+ store.bootstrap!
44
+
45
+ {
46
+ status: "ready",
47
+ service: "mammoth",
48
+ name: mammoth_name,
49
+ sqlite: "ok",
50
+ tables: store.tables,
51
+ checked_at: checked_at
52
+ }
53
+ rescue Mammoth::Error, SQLite3::Exception => e
54
+ {
55
+ status: "unready",
56
+ service: "mammoth",
57
+ name: mammoth_name,
58
+ sqlite: "error",
59
+ error_class: e.class.name,
60
+ error_message: e.message,
61
+ checked_at: checked_at
62
+ }
63
+ end
64
+
65
+ # Build a Prometheus text exposition document.
66
+ #
67
+ # @return [String] Prometheus metrics text
68
+ def prometheus
69
+ store = operational_store.bootstrap!
70
+ checkpoint_store = CheckpointStore.new(store)
71
+ dead_letter_store = DeadLetterStore.new(store)
72
+ delivered_store = DeliveredEnvelopeStore.new(store)
73
+
74
+ lines = metric_headers + [
75
+ metric_line("mammoth_up", 1),
76
+ metric_line("mammoth_checkpoints_total", checkpoint_store.count),
77
+ metric_line("mammoth_dead_letters_total", dead_letter_store.count),
78
+ metric_line("mammoth_dead_letters_pending_total", dead_letter_store.count(status: "pending")),
79
+ metric_line("mammoth_dead_letters_resolved_total", dead_letter_store.count(status: "resolved")),
80
+ metric_line("mammoth_dead_letters_ignored_total", dead_letter_store.count(status: "ignored")),
81
+ metric_line("mammoth_delivered_envelopes_total", delivered_store.count)
82
+ ]
83
+ "#{lines.join("\n")}\n"
84
+ rescue Mammoth::Error, SQLite3::Exception
85
+ "#{(metric_headers + [metric_line("mammoth_up", 0)]).join("\n")}\n"
86
+ end
87
+
88
+ private
89
+
90
+ def operational_store
91
+ sqlite_store || SQLiteStore.connect(config.dig("sqlite", "path"))
92
+ end
93
+
94
+ def mammoth_name
95
+ config.dig("mammoth", "name")
96
+ end
97
+
98
+ def checked_at
99
+ clock.call.utc.iso8601
100
+ end
101
+
102
+ def metric_headers
103
+ [
104
+ "# HELP mammoth_up 1 when Mammoth operational state can be inspected, 0 otherwise.",
105
+ "# TYPE mammoth_up gauge",
106
+ "# HELP mammoth_checkpoints_total Number of checkpoint rows stored by Mammoth.",
107
+ "# TYPE mammoth_checkpoints_total gauge",
108
+ "# HELP mammoth_dead_letters_total Number of dead-letter rows stored by Mammoth.",
109
+ "# TYPE mammoth_dead_letters_total gauge",
110
+ "# HELP mammoth_dead_letters_pending_total Number of pending dead-letter rows.",
111
+ "# TYPE mammoth_dead_letters_pending_total gauge",
112
+ "# HELP mammoth_dead_letters_resolved_total Number of resolved dead-letter rows.",
113
+ "# TYPE mammoth_dead_letters_resolved_total gauge",
114
+ "# HELP mammoth_dead_letters_ignored_total Number of ignored dead-letter rows.",
115
+ "# TYPE mammoth_dead_letters_ignored_total gauge",
116
+ "# HELP mammoth_delivered_envelopes_total Number of delivered-envelope ledger rows.",
117
+ "# TYPE mammoth_delivered_envelopes_total gauge"
118
+ ]
119
+ end
120
+
121
+ def metric_line(name, value)
122
+ %(#{name}{mammoth_name="#{escape_label(mammoth_name)}"} #{Integer(value)})
123
+ end
124
+
125
+ def escape_label(value)
126
+ value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
127
+ end
128
+ end
129
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mammoth
4
+ # Namespace for Mammoth source adapters.
4
5
  module Sources
5
6
  # Concrete PostgreSQL CDC source for Mammoth.
6
7
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -13,7 +13,9 @@ module Mammoth
13
13
  # HTTP status range treated as successful webhook delivery.
14
14
  SUCCESS_RANGE = 200..299
15
15
 
16
+ # Supported webhook signing algorithm.
16
17
  SIGNING_ALGORITHM = "hmac_sha256"
18
+ # Prefix added to generated webhook signatures.
17
19
  SIGNATURE_PREFIX = "sha256="
18
20
 
19
21
  attr_reader :name, :url, :timeout_seconds, :headers, :signing
data/lib/mammoth.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "mammoth/version"
4
4
  require_relative "mammoth/errors"
5
5
  require_relative "mammoth/configuration"
6
6
  require_relative "mammoth/status"
7
+ require_relative "mammoth/observability_snapshot"
8
+ require_relative "mammoth/observability_server"
7
9
  require_relative "mammoth/sqlite_store"
8
10
  require_relative "mammoth/checkpoint_store"
9
11
  require_relative "mammoth/dead_letter_store"
@@ -18,6 +20,7 @@ require_relative "mammoth/sources/postgres"
18
20
  require_relative "mammoth/cdc_source"
19
21
  require_relative "mammoth/replication_consumer"
20
22
  require_relative "mammoth/application"
23
+ require_relative "mammoth/dead_letter_commands"
21
24
  require_relative "mammoth/cli"
22
25
 
23
26
  # Mammoth is a self-hosted PostgreSQL event relay.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mammoth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -150,12 +150,15 @@ files:
150
150
  - lib/mammoth/cli.rb
151
151
  - lib/mammoth/concurrent_delivery_runtime.rb
152
152
  - lib/mammoth/configuration.rb
153
+ - lib/mammoth/dead_letter_commands.rb
153
154
  - lib/mammoth/dead_letter_store.rb
154
155
  - lib/mammoth/delivered_envelope_store.rb
155
156
  - lib/mammoth/delivery_processor.rb
156
157
  - lib/mammoth/delivery_worker.rb
157
158
  - lib/mammoth/errors.rb
158
159
  - lib/mammoth/event_serializer.rb
160
+ - lib/mammoth/observability_server.rb
161
+ - lib/mammoth/observability_snapshot.rb
159
162
  - lib/mammoth/replication_consumer.rb
160
163
  - lib/mammoth/sources/postgres.rb
161
164
  - lib/mammoth/sql/__bootstrap__.sql