mammoth 0.9.0 → 1.0.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.
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mammoth
6
+ module Sources
7
+ # Immutable replica-identity facts for one table in configured publications.
8
+ class PostgresPublicationTable
9
+ attr_reader :relation_id, :schema_name, :table_name, :publishes_updates, :publishes_deletes, :replica_identity,
10
+ :replica_identity_columns, :primary_key_usable, :replica_identity_index_usable
11
+
12
+ # @return [void]
13
+ def initialize(relation_id:, schema_name:, table_name:, publishes_updates:, publishes_deletes:,
14
+ replica_identity:, replica_identity_columns:, primary_key_usable:,
15
+ replica_identity_index_usable:)
16
+ @relation_id = relation_id
17
+ @schema_name = schema_name
18
+ @table_name = table_name
19
+ @publishes_updates = publishes_updates
20
+ @publishes_deletes = publishes_deletes
21
+ @replica_identity = replica_identity
22
+ @replica_identity_columns = replica_identity_columns.freeze
23
+ @primary_key_usable = primary_key_usable
24
+ @replica_identity_index_usable = replica_identity_index_usable
25
+ freeze
26
+ end
27
+
28
+ # Schema-qualified table name for diagnostics.
29
+ #
30
+ # @return [String]
31
+ def qualified_name
32
+ "#{schema_name}.#{table_name}"
33
+ end
34
+
35
+ # Publication actions that require old-row identity.
36
+ #
37
+ # @return [Array<String>]
38
+ def identity_actions
39
+ actions = [] # : Array[String]
40
+ actions.tap do
41
+ actions << "UPDATE" if publishes_updates
42
+ actions << "DELETE" if publishes_deletes
43
+ end
44
+ end
45
+
46
+ # Whether any configured publication requires replica identity.
47
+ #
48
+ # @return [Boolean]
49
+ def identity_required?
50
+ publishes_updates || publishes_deletes
51
+ end
52
+
53
+ # Whether PostgreSQL can identify old rows for the published actions.
54
+ #
55
+ # `d` uses a primary key, `i` uses a selected replica-identity index,
56
+ # and `f` logs the full old row. `n` provides no old-row identity.
57
+ #
58
+ # @return [Boolean]
59
+ def identity_usable?
60
+ return true unless identity_required?
61
+
62
+ case replica_identity
63
+ when "d" then primary_key_usable
64
+ when "i" then replica_identity_index_usable
65
+ when "f" then true
66
+ else false
67
+ end
68
+ end
69
+ end
70
+
71
+ # Read-only PostgreSQL publication and replica-identity catalog inspector.
72
+ class PostgresPublicationInspector
73
+ QUERY = <<~SQL
74
+ SELECT
75
+ relation.oid AS relation_id,
76
+ namespace.nspname AS schema_name,
77
+ relation.relname AS table_name,
78
+ bool_or(publication.pubupdate) AS publishes_updates,
79
+ bool_or(publication.pubdelete) AS publishes_deletes,
80
+ relation.relreplident AS replica_identity,
81
+ CASE relation.relreplident
82
+ WHEN 'd' THEN (
83
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY key.ordinality), '[]'::jsonb)
84
+ FROM pg_index AS identity_columns_index
85
+ JOIN LATERAL unnest(identity_columns_index.indkey)
86
+ WITH ORDINALITY AS key(attribute_number, ordinality) ON true
87
+ JOIN pg_attribute AS attribute
88
+ ON attribute.attrelid = relation.oid
89
+ AND attribute.attnum = key.attribute_number
90
+ WHERE identity_columns_index.indrelid = relation.oid
91
+ AND identity_columns_index.indisprimary
92
+ AND identity_columns_index.indisvalid
93
+ AND identity_columns_index.indisready
94
+ AND identity_columns_index.indislive
95
+ )
96
+ WHEN 'i' THEN (
97
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY key.ordinality), '[]'::jsonb)
98
+ FROM pg_index AS identity_columns_index
99
+ JOIN LATERAL unnest(identity_columns_index.indkey)
100
+ WITH ORDINALITY AS key(attribute_number, ordinality) ON true
101
+ JOIN pg_attribute AS attribute
102
+ ON attribute.attrelid = relation.oid
103
+ AND attribute.attnum = key.attribute_number
104
+ WHERE identity_columns_index.indrelid = relation.oid
105
+ AND identity_columns_index.indisreplident
106
+ AND identity_columns_index.indisvalid
107
+ AND identity_columns_index.indisready
108
+ AND identity_columns_index.indislive
109
+ )
110
+ WHEN 'f' THEN (
111
+ SELECT COALESCE(jsonb_agg(attribute.attname ORDER BY attribute.attnum), '[]'::jsonb)
112
+ FROM pg_attribute AS attribute
113
+ WHERE attribute.attrelid = relation.oid
114
+ AND attribute.attnum > 0
115
+ AND NOT attribute.attisdropped
116
+ )
117
+ ELSE '[]'::jsonb
118
+ END AS replica_identity_columns,
119
+ EXISTS (
120
+ SELECT 1
121
+ FROM pg_index AS primary_index
122
+ WHERE primary_index.indrelid = relation.oid
123
+ AND primary_index.indisprimary
124
+ AND primary_index.indisvalid
125
+ AND primary_index.indisready
126
+ AND primary_index.indislive
127
+ ) AS primary_key_usable,
128
+ EXISTS (
129
+ SELECT 1
130
+ FROM pg_index AS identity_index
131
+ WHERE identity_index.indrelid = relation.oid
132
+ AND identity_index.indisreplident
133
+ AND identity_index.indisvalid
134
+ AND identity_index.indisready
135
+ AND identity_index.indislive
136
+ ) AS replica_identity_index_usable
137
+ FROM pg_publication_tables AS publication_table
138
+ JOIN pg_publication AS publication
139
+ ON publication.pubname = publication_table.pubname
140
+ JOIN pg_namespace AS namespace
141
+ ON namespace.nspname = publication_table.schemaname
142
+ JOIN pg_class AS relation
143
+ ON relation.relnamespace = namespace.oid
144
+ AND relation.relname = publication_table.tablename
145
+ WHERE publication.pubname IN (
146
+ SELECT jsonb_array_elements_text($1::jsonb)
147
+ )
148
+ GROUP BY namespace.nspname, relation.relname, relation.oid, relation.relreplident
149
+ ORDER BY namespace.nspname, relation.relname
150
+ SQL
151
+
152
+ attr_reader :database_url
153
+
154
+ # @param database_url [String] PostgreSQL connection URL
155
+ # @param connection_factory [#call, nil] optional connection factory for tests
156
+ def initialize(database_url:, connection_factory: nil)
157
+ @database_url = database_url
158
+ @connection_factory = connection_factory
159
+ end
160
+
161
+ # Inspect tables included by the configured publications.
162
+ #
163
+ # @param publication_names [Array<String>] configured publication names
164
+ # @return [Array<PostgresPublicationTable>]
165
+ def inspect(publication_names)
166
+ connection = open_connection
167
+ rows = connection.exec_params(QUERY, [JSON.generate(publication_names)])
168
+ rows.map { |row| build_table(row) }
169
+ rescue pg_error_class => e
170
+ raise ReplicationError, "PostgreSQL publication inspection failed: #{e.message}"
171
+ ensure
172
+ connection&.close unless connection&.finished?
173
+ end
174
+
175
+ private
176
+
177
+ def open_connection
178
+ return @connection_factory.call(database_url) if @connection_factory
179
+
180
+ require "pg"
181
+ PG.connect(database_url)
182
+ end
183
+
184
+ def build_table(row)
185
+ PostgresPublicationTable.new(
186
+ relation_id: Integer(row.fetch("relation_id")),
187
+ schema_name: row.fetch("schema_name"),
188
+ table_name: row.fetch("table_name"),
189
+ publishes_updates: postgres_boolean(row.fetch("publishes_updates")),
190
+ publishes_deletes: postgres_boolean(row.fetch("publishes_deletes")),
191
+ replica_identity: row.fetch("replica_identity"),
192
+ replica_identity_columns: parse_columns(row.fetch("replica_identity_columns")),
193
+ primary_key_usable: postgres_boolean(row.fetch("primary_key_usable")),
194
+ replica_identity_index_usable: postgres_boolean(row.fetch("replica_identity_index_usable"))
195
+ )
196
+ end
197
+
198
+ def parse_columns(value)
199
+ columns = value.is_a?(String) ? JSON.parse(value) : value
200
+ column_list = Array(columns) # : Array[untyped]
201
+ column_list.map(&:to_s)
202
+ end
203
+
204
+ def postgres_boolean(value)
205
+ [true, "t"].include?(value)
206
+ end
207
+
208
+ def pg_error_class
209
+ require "pg"
210
+ PG::Error
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ module Sources
5
+ # Immutable operator-facing health snapshot for one PostgreSQL replication slot.
6
+ #
7
+ # This is a Mammoth-owned policy view. The transport layer supplies catalog
8
+ # facts; Mammoth decides whether they make the configured source ready for
9
+ # continuous delivery.
10
+ # Typed PostgreSQL slot health and readiness policy.
11
+ class PostgresSlotHealth
12
+ attr_reader :slot_name, :present, :active, :retained_wal_bytes, :wal_status, :safe_wal_size, :inactive_since,
13
+ :invalidation_reason, :restart_lsn, :restart_lsn_bytes, :confirmed_flush_lsn,
14
+ :confirmed_flush_lsn_bytes, :conflicting
15
+
16
+ # Build a PostgreSQL slot health snapshot from normalized catalog facts.
17
+ #
18
+ # @return [void]
19
+ def initialize(slot_name:, present:, active:, retained_wal_bytes:, wal_status:, safe_wal_size:, inactive_since:,
20
+ invalidation_reason:, restart_lsn:, restart_lsn_bytes:, confirmed_flush_lsn:,
21
+ confirmed_flush_lsn_bytes:, conflicting:)
22
+ @slot_name = slot_name
23
+ @present = present
24
+ @active = active
25
+ @retained_wal_bytes = retained_wal_bytes
26
+ @wal_status = wal_status
27
+ @safe_wal_size = safe_wal_size
28
+ @inactive_since = inactive_since
29
+ @invalidation_reason = invalidation_reason
30
+ @restart_lsn = restart_lsn
31
+ @restart_lsn_bytes = restart_lsn_bytes
32
+ @confirmed_flush_lsn = confirmed_flush_lsn
33
+ @confirmed_flush_lsn_bytes = confirmed_flush_lsn_bytes
34
+ @conflicting = conflicting
35
+ freeze
36
+ end
37
+
38
+ # Build a snapshot for a missing configured slot.
39
+ #
40
+ # @param slot_name [String] configured replication slot
41
+ # @return [PostgresSlotHealth]
42
+ def self.missing(slot_name)
43
+ new(
44
+ slot_name:, present: false, active: false, retained_wal_bytes: nil,
45
+ wal_status: nil, safe_wal_size: nil, inactive_since: nil,
46
+ invalidation_reason: nil, restart_lsn: nil, restart_lsn_bytes: nil,
47
+ confirmed_flush_lsn: nil, confirmed_flush_lsn_bytes: nil, conflicting: false
48
+ )
49
+ end
50
+
51
+ # Whether the slot is present, active, and retaining usable WAL.
52
+ #
53
+ # @return [Boolean]
54
+ def ready?
55
+ reason.nil?
56
+ end
57
+
58
+ # Operator-facing reason the slot is not ready.
59
+ #
60
+ # @return [String, nil]
61
+ def reason
62
+ return "slot is missing" unless present
63
+ return "slot is inactive" unless active
64
+ return "wal_status=#{wal_status}" if %w[lost unreserved].include?(wal_status)
65
+ return "slot is invalidated: #{invalidation_reason}" unless blank?(invalidation_reason)
66
+ return "slot is conflicting" if conflicting
67
+ return "slot has no restart LSN" if blank?(restart_lsn)
68
+
69
+ nil
70
+ end
71
+
72
+ # Stable readiness payload without transport-library types.
73
+ #
74
+ # @return [Hash]
75
+ def summary
76
+ {
77
+ slot_name:, present:, active:, retained_wal_bytes:, wal_status:, safe_wal_size:, inactive_since:,
78
+ invalidation_reason:, restart_lsn:, restart_lsn_bytes:, confirmed_flush_lsn:,
79
+ confirmed_flush_lsn_bytes:, conflicting:, ready: ready?, reason:
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def blank?(value)
86
+ value.nil? || value == ""
87
+ end
88
+ end
89
+ end
90
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.9.0"
5
+ VERSION = "1.0.0"
6
6
  end
data/lib/mammoth.rb CHANGED
@@ -18,6 +18,9 @@ require_relative "mammoth/commands/deliver_sample_command"
18
18
  require_relative "mammoth/commands/dead_letters_command"
19
19
  require_relative "mammoth/dispatch_metrics"
20
20
  require_relative "mammoth/metrics_observer"
21
+ require_relative "mammoth/sources/postgres_slot_health"
22
+ require_relative "mammoth/sources/postgres_publication_inspector"
23
+ require_relative "mammoth/postgres_observability_metrics"
21
24
  require_relative "mammoth/observability_metrics"
22
25
  require_relative "mammoth/observability_snapshot"
23
26
  require_relative "mammoth/observability_server"
@@ -25,6 +28,7 @@ require_relative "mammoth/sqlite_store"
25
28
  require_relative "mammoth/checkpoint_store"
26
29
  require_relative "mammoth/dead_letter_store"
27
30
  require_relative "mammoth/delivered_envelope_store"
31
+ require_relative "mammoth/delivery_progress_coordinator"
28
32
  require_relative "mammoth/event_serializer"
29
33
  require_relative "mammoth/transaction_envelope_serializer"
30
34
  require_relative "mammoth/persisted_payload_deserializer"
@@ -54,7 +58,7 @@ require_relative "mammoth/cli"
54
58
 
55
59
  # Mammoth is a self-hosted PostgreSQL event relay.
56
60
  #
57
- # Mammoth 0.8.x is a single-node PostgreSQL CDC relay with webhook fanout,
61
+ # Mammoth 1.x is a single-node PostgreSQL CDC relay with webhook fanout,
58
62
  # replayable operational SQLite state, health/metrics endpoints, and explicit
59
63
  # extension contracts for state, destination, runtime, lifecycle, configuration,
60
64
  # and local command integrations.
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.9.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -51,34 +51,54 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '6.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pg
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.6'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: pgoutput-client
56
70
  requirement: !ruby/object:Gem::Requirement
57
71
  requirements:
58
72
  - - "~>"
59
73
  - !ruby/object:Gem::Version
60
- version: '0.2'
74
+ version: '0.4'
61
75
  type: :runtime
62
76
  prerelease: false
63
77
  version_requirements: !ruby/object:Gem::Requirement
64
78
  requirements:
65
79
  - - "~>"
66
80
  - !ruby/object:Gem::Version
67
- version: '0.2'
81
+ version: '0.4'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: pgoutput-decoder
70
84
  requirement: !ruby/object:Gem::Requirement
71
85
  requirements:
72
86
  - - "~>"
73
87
  - !ruby/object:Gem::Version
74
- version: '0.1'
88
+ version: '0.2'
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 0.2.0
75
92
  type: :runtime
76
93
  prerelease: false
77
94
  version_requirements: !ruby/object:Gem::Requirement
78
95
  requirements:
79
96
  - - "~>"
80
97
  - !ruby/object:Gem::Version
81
- version: '0.1'
98
+ version: '0.2'
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 0.2.0
82
102
  - !ruby/object:Gem::Dependency
83
103
  name: pgoutput-parser
84
104
  requirement: !ruby/object:Gem::Requirement
@@ -99,20 +119,20 @@ dependencies:
99
119
  requirements:
100
120
  - - "~>"
101
121
  - !ruby/object:Gem::Version
102
- version: '0.2'
122
+ version: '0.3'
103
123
  - - ">="
104
124
  - !ruby/object:Gem::Version
105
- version: 0.2.0
125
+ version: 0.3.0
106
126
  type: :runtime
107
127
  prerelease: false
108
128
  version_requirements: !ruby/object:Gem::Requirement
109
129
  requirements:
110
130
  - - "~>"
111
131
  - !ruby/object:Gem::Version
112
- version: '0.2'
132
+ version: '0.3'
113
133
  - - ">="
114
134
  - !ruby/object:Gem::Version
115
- version: 0.2.0
135
+ version: 0.3.0
116
136
  - !ruby/object:Gem::Dependency
117
137
  name: sqlite3
118
138
  requirement: !ruby/object:Gem::Requirement
@@ -181,6 +201,7 @@ files:
181
201
  - lib/mammoth/dead_letter_store.rb
182
202
  - lib/mammoth/delivered_envelope_store.rb
183
203
  - lib/mammoth/delivery_processor.rb
204
+ - lib/mammoth/delivery_progress_coordinator.rb
184
205
  - lib/mammoth/delivery_worker.rb
185
206
  - lib/mammoth/destinations/adapter.rb
186
207
  - lib/mammoth/destinations/registry.rb
@@ -199,6 +220,7 @@ files:
199
220
  - lib/mammoth/operational_state/registry.rb
200
221
  - lib/mammoth/operational_state/sqlite_adapter.rb
201
222
  - lib/mammoth/persisted_payload_deserializer.rb
223
+ - lib/mammoth/postgres_observability_metrics.rb
202
224
  - lib/mammoth/registry.rb
203
225
  - lib/mammoth/replication_consumer.rb
204
226
  - lib/mammoth/route_filter.rb
@@ -208,6 +230,8 @@ files:
208
230
  - lib/mammoth/runtimes/inline_adapter.rb
209
231
  - lib/mammoth/runtimes/registry.rb
210
232
  - lib/mammoth/sources/postgres.rb
233
+ - lib/mammoth/sources/postgres_publication_inspector.rb
234
+ - lib/mammoth/sources/postgres_slot_health.rb
211
235
  - lib/mammoth/sql/__bootstrap__.sql
212
236
  - lib/mammoth/sqlite_store.rb
213
237
  - lib/mammoth/status.rb