pgbus 0.13.8 → 0.13.9
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 +4 -0
- data/lib/pgbus/client/ensure_stream_queue.rb +12 -3
- data/lib/pgbus/client.rb +89 -1
- data/lib/pgbus/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03f5f0c1044bc23fb429b3e24e13e3df5bfe6bffe5452ea3c746b24de081f822
|
|
4
|
+
data.tar.gz: bf1c7f3f71811469fef78fb8d93a66a77668229e0293d2e3442f62abedf6badf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f82fba75de8a30b79887b4535cf47865d624126dc7f81cdbba190490be2b57ad1ea69c5d6a1d8ea902b4d48bb85c7b19920326d18cbc9c411201f30ac5ee571
|
|
7
|
+
data.tar.gz: '039f1c2bcc8e4c65188f03794f42ca5e31aebc67ed4a9de869ed885306e7052ea370af331e57f9f104ca9ae65ae51ef4caf690516993966c92a4d117c91f7bd0'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
### Fixed
|
|
4
4
|
|
|
5
|
+
- **Concurrent queue creation no longer kills the loser with `PG::UniqueViolation` on `pg_class` (issue #404).** The sibling of the #403 trigger race, one DDL step earlier: `CREATE TABLE IF NOT EXISTS` is not race-safe — two backends creating a not-yet-existing queue both pass the existence check (READ COMMITTED; neither sees the other's uncommitted catalog rows), both insert into `pg_class`, and the loser raises `unique_violation` on `pg_class_relname_nsp_index` instead of the friendly `duplicate_table`. All the dedup guards (`@queues_created`, `Stream#@ensured`, `synchronized`) are process-local, so with lazily-created per-record durable stream queues two processes first-touching the same brand-new stream hit `pgmq.create` simultaneously — and via `send_message`'s `ensure_queue` the same window applies to plain job queues on cold post-deploy herds (production: AppSignal incident #963, an unrescued `PG::UniqueViolation` out of an unrelated `after_commit`). Three DDL steps now rescue the duplicate as success, mirroring the #397/#403 shape: **(1)** `create_queue_table` — `pgmq.create` is a single statement and therefore atomic, so when the loser unblocks the winner has committed the whole queue; the loser re-checks `pgmq.meta` and returns, retrying `pgmq.create` once when the re-check can't confirm (e.g. a leftover physical table without a meta row) with a second failure propagating (the retry's own error — the original duplicate is preserved deeper in its cause chain via Ruby's implicit `$!` chaining); **(2)** `create_fifo_index_if_needed` under group_mode — the duplicate proves the index exists; **(3)** the stream archive `msg_id` index in `ensure_stream_queue` — a raw `CREATE INDEX IF NOT EXISTS` with the same catalog race, where the loser now proceeds to register and memoize the stream instead of dropping the first broadcast. Duplicate detection reuses `DUPLICATE_INSTALL_ERROR_CLASSES`, matched directly (raw `conn.exec` paths) or as the `cause` of pgmq-ruby's `ConnectionError` wrapper. No locking added, no happy-path cost. Refs #404.
|
|
6
|
+
|
|
7
|
+
- **Concurrent lazy stream-queue ensures no longer drop the loser's broadcast with `PG::DuplicateObject` on the NOTIFY trigger (issue #403).** `Client#enable_notify_if_needed` is check-then-act — `notify_trigger_current?` is a plain SELECT and the surrounding `synchronized` is a process-local mutex — so after a deploy, when every process's `@queues_created` memo is cold, two processes handling the first broadcast for the same stream both see "trigger not current" and both run PGMQ's `DROP TRIGGER IF EXISTS` + `CREATE CONSTRAINT TRIGGER` cycle. The loser's CREATE blocks on the winner's table lock and fails with `duplicate_object` once the winner commits — surfaced as `PGMQ::Errors::ConnectionError`, killing that broadcast (in production: a deploy-time thundering herd turning unrelated `after_commit` writes into 500s, AppSignal incident #546). The existing mitigations all miss this window: the 0.12.x `notify_trigger_current?` check made repeat ensures cheap but left the TOCTOU race, the #397 advisory lock serializes schema install only, and the supervisor's pre-fork bootstrap covers job-container children but not web processes. The loser now treats the duplicate as success — the trigger provably exists because a concurrent caller just created it (same shape as the #397 duplicate-object rescue on schema install; matched on the trigger identifier, which survives message localization, plus the `PG::DuplicateObject` cause or the "already exists" text). Before returning, it re-checks `notify_trigger_current?`: racing ensures pass the same throttle so the check normally confirms and returns, but a job-queue ensure (250ms) racing the stream override (0ms) can leave the wrong interval installed — that mismatch retries `enable_notify_insert` once to converge; a second loss propagates. No locking added, no happy-path cost. Refs #403.
|
|
8
|
+
|
|
5
9
|
- **`StreamQueue.record!` no longer depends on Rails' pool schema-cache index resolution — one bad probe stopped poisoning stream registration for the process lifetime (issue #401).** `record!` used `upsert(unique_by: :queue_name)`, which resolves the unique index through the connection pool's schema cache. That cache stores a negative `data_source_exists?` answer permanently, and `SchemaCache#indexes` returns `[]` (uncached) whenever the cached probe says false — while the `table_exists?` guard at the top of `record!` is a live query. So a single wrong first probe on the pool cache (observed under PgBouncer transaction pooling in production, and from a coalescer flush thread racing foreground test DB work in CI) made the guard pass and the upsert raise `ArgumentError: No unique index found for queue_name` — swallowed at DEBUG — on **every** subsequent `record!` in that process until restart, leaving streams unregistered from that process's perspective (maintenance, orphan sweep, and wildcard classification degrade). The registry write is now a raw `INSERT … ON CONFLICT (queue_name) DO NOTHING` on the model's connection: the unique index is owned by the gem's own migration, so there is nothing for Rails to resolve, no schema-cache traffic leaves the hot first-broadcast path, and a poisoned cache can no longer break registration (pinned by an integration regression spec that deliberately poisons the pool cache, plus a `sql.active_record` assertion that no `SCHEMA` query is issued once the `table_exists?` memo is warm). Failure logging is now class-aware: a transient database error (`ActiveRecord::ActiveRecordError`) still logs at DEBUG per attempt, but a non-database failure — the bug-signal class the old `ArgumentError` belonged to — logs at WARN once per process (DEBUG thereafter) instead of drowning a process-lifetime malfunction in per-broadcast DEBUG spam. Return values and `backfill!`/`all_names` cache semantics are unchanged. Refs #401.
|
|
6
10
|
|
|
7
11
|
- **The schema-install transaction framing no longer commits or destroys a caller's open transaction (#398 review follow-up).** The #397 fix wrapped check+install in `BEGIN`…`COMMIT`/`ROLLBACK` unconditionally. On the Proc-supplied shared-connection path (the Rails lambda), the connection can arrive **mid-transaction** — e.g. `perform_later` inside an application `transaction do` block — where `BEGIN` is a warning-level no-op and the matching `COMMIT`/`ROLLBACK` then commits half of, or destroys, the *caller's* transaction. The framing is now `transaction_status`-aware: an idle connection gets the owned `BEGIN`…`COMMIT` as before; a connection already inside a transaction rides it via `SAVEPOINT pgbus_pgmq_install` / `RELEASE` (`ROLLBACK TO SAVEPOINT` on failure), so the caller's transaction is never touched. On the savepoint path the advisory lock joins the caller's transaction and is held until it ends — over-holding only delays a concurrent installer, never corrupts it — and `@schema_ensured` is NOT cached there: the install is only durable once the caller commits, so a cached true after an outer rollback would skip every future check against a missing schema. The same durability rule now governs `@queues_created`: queue DDL on the shared Proc-supplied connection joins the caller's open transaction, so queue creation there runs uncached (idempotent `CREATE IF NOT EXISTS`) and the next ensure re-checks — a cache write outliving a caller rollback would make later message operations fail against a missing queue. All shared-connection access in these paths — including the transaction-status probe and the schema install itself — holds the per-instance connection mutex, restoring the single-owner invariant the #397 fix had narrowed. Refs #398.
|
|
@@ -46,10 +46,19 @@ module Pgbus
|
|
|
46
46
|
ON pgmq.a_#{sanitized} (msg_id)
|
|
47
47
|
SQL
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
conn
|
|
49
|
+
begin
|
|
50
|
+
synchronized do
|
|
51
|
+
with_raw_connection do |conn|
|
|
52
|
+
conn.exec(sql)
|
|
53
|
+
end
|
|
52
54
|
end
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
# CREATE INDEX IF NOT EXISTS shares pgmq.create's catalog race
|
|
57
|
+
# (issue #404): two first-broadcasts both pass the existence
|
|
58
|
+
# check and the loser gets a raw unique_violation on pg_class.
|
|
59
|
+
# The duplicate proves the index exists — carry on to register
|
|
60
|
+
# and memoize.
|
|
61
|
+
raise unless duplicate_relation_error?(e)
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
# Record the physical queue name so maintenance (stream-archive prune,
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -64,6 +64,12 @@ module Pgbus
|
|
|
64
64
|
# re-running the trigger DDL on every queue.
|
|
65
65
|
NOTIFY_THROTTLE_MS = 250
|
|
66
66
|
|
|
67
|
+
# PGMQ's per-queue NOTIFY trigger name, as created by
|
|
68
|
+
# pgmq.enable_notify_insert — used to recognize the duplicate-trigger
|
|
69
|
+
# race loser (issue #403).
|
|
70
|
+
NOTIFY_TRIGGER_NAME = "trigger_notify_queue_insert_listeners"
|
|
71
|
+
private_constant :NOTIFY_TRIGGER_NAME
|
|
72
|
+
|
|
67
73
|
# Load the pgmq-ruby gem, defining the PGMQ module before requiring it so
|
|
68
74
|
# Zeitwerk's eager_load (called inside pgmq.rb) can resolve the constant.
|
|
69
75
|
# Without the pre-definition, Ruby 4.0 + Zeitwerk 2.7.5 raises NameError
|
|
@@ -1177,7 +1183,29 @@ module Pgbus
|
|
|
1177
1183
|
end
|
|
1178
1184
|
|
|
1179
1185
|
# Runs inside synchronized — callers own the connection mutex.
|
|
1186
|
+
#
|
|
1187
|
+
# CREATE TABLE IF NOT EXISTS is not race-safe: two backends creating a
|
|
1188
|
+
# not-yet-existing queue both pass the existence check (READ COMMITTED
|
|
1189
|
+
# — neither sees the other's uncommitted catalog rows), both insert
|
|
1190
|
+
# into pg_class, and the loser raises unique_violation on
|
|
1191
|
+
# pg_class_relname_nsp_index instead of the friendly duplicate_table
|
|
1192
|
+
# (issue #404 — the sibling of the #403 trigger race, one DDL step
|
|
1193
|
+
# earlier; `synchronized` is process-local, so nothing serializes
|
|
1194
|
+
# this across processes). pgmq.create is a single statement and
|
|
1195
|
+
# therefore atomic: by the time the loser unblocks, the winner has
|
|
1196
|
+
# committed the WHOLE queue — tables, indexes, and the pgmq.meta row
|
|
1197
|
+
# — so re-check meta and return (the winner also ran autovacuum
|
|
1198
|
+
# tuning). The retry covers the can't-confirm case (e.g. a leftover
|
|
1199
|
+
# physical table without a meta row); its failure propagates as the
|
|
1200
|
+
# retry's own error, with the original duplicate preserved deeper in
|
|
1201
|
+
# the cause chain (raised while $! held it, so Ruby chains it).
|
|
1180
1202
|
def create_queue_table(name)
|
|
1203
|
+
@pgmq.create(name)
|
|
1204
|
+
tune_autovacuum(name)
|
|
1205
|
+
rescue StandardError => e
|
|
1206
|
+
raise unless duplicate_relation_error?(e)
|
|
1207
|
+
return if queue_registered?(name)
|
|
1208
|
+
|
|
1181
1209
|
@pgmq.create(name)
|
|
1182
1210
|
tune_autovacuum(name)
|
|
1183
1211
|
end
|
|
@@ -1203,17 +1231,77 @@ module Pgbus
|
|
|
1203
1231
|
end
|
|
1204
1232
|
end
|
|
1205
1233
|
|
|
1234
|
+
# notify_trigger_current? is a plain SELECT and `synchronized` is a
|
|
1235
|
+
# process-local mutex, so this check-then-act races across processes:
|
|
1236
|
+
# after a deploy every process's @queues_created memo is cold, and two
|
|
1237
|
+
# web processes handling the first broadcast for the same lazy stream
|
|
1238
|
+
# queue both see "trigger not current" and both run PGMQ's
|
|
1239
|
+
# DROP + CREATE CONSTRAINT TRIGGER cycle. The loser's CREATE blocks on
|
|
1240
|
+
# the winner's table lock and fails with PG::DuplicateObject once the
|
|
1241
|
+
# winner commits (issue #403). The duplicate proves the trigger exists,
|
|
1242
|
+
# so treat it as success — same shape as the #397 duplicate-object
|
|
1243
|
+
# rescue on schema install. Re-check the throttle first: racing ensures
|
|
1244
|
+
# pass the same value, but a job-queue ensure (250ms) can race the
|
|
1245
|
+
# stream override (0ms), so a mismatch means the winner installed a
|
|
1246
|
+
# different interval — retry once to converge; a second loss propagates.
|
|
1206
1247
|
def enable_notify_if_needed(full_name, throttle_ms)
|
|
1207
1248
|
return unless config.listen_notify
|
|
1208
1249
|
return if notify_trigger_current?(full_name, throttle_ms)
|
|
1209
1250
|
|
|
1251
|
+
@pgmq.enable_notify_insert(full_name, throttle_interval_ms: throttle_ms)
|
|
1252
|
+
rescue PGMQ::Errors::ConnectionError => e
|
|
1253
|
+
raise unless duplicate_notify_trigger_error?(e)
|
|
1254
|
+
return if notify_trigger_current?(full_name, throttle_ms)
|
|
1255
|
+
|
|
1210
1256
|
@pgmq.enable_notify_insert(full_name, throttle_interval_ms: throttle_ms)
|
|
1211
1257
|
end
|
|
1212
1258
|
|
|
1259
|
+
# Matched on the trigger name (an identifier — survives server-side
|
|
1260
|
+
# message localization) plus either the PG::DuplicateObject cause set by
|
|
1261
|
+
# pgmq-ruby's `raise … ConnectionError` inside `rescue PG::Error` (the
|
|
1262
|
+
# defined? guard mirrors the other PG::… checks in this file: a cause
|
|
1263
|
+
# can only be a PG::DuplicateObject when the class is loaded) or the
|
|
1264
|
+
# English "already exists" text when a wrapper dropped the cause.
|
|
1265
|
+
def duplicate_notify_trigger_error?(error)
|
|
1266
|
+
message = error.message.to_s
|
|
1267
|
+
return false unless message.include?(NOTIFY_TRIGGER_NAME)
|
|
1268
|
+
|
|
1269
|
+
(defined?(PG::DuplicateObject) && error.cause.is_a?(PG::DuplicateObject)) ||
|
|
1270
|
+
message.include?("already exists")
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1213
1273
|
def create_fifo_index_if_needed(full_name)
|
|
1214
1274
|
return unless config.group_mode
|
|
1215
1275
|
|
|
1216
1276
|
@pgmq.create_fifo_index(full_name)
|
|
1277
|
+
rescue StandardError => e
|
|
1278
|
+
# CREATE INDEX IF NOT EXISTS has the same catalog race as
|
|
1279
|
+
# pgmq.create (issue #404): the loser's duplicate proves a
|
|
1280
|
+
# concurrent ensure created the index.
|
|
1281
|
+
raise unless duplicate_relation_error?(e)
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
# A relation-creation race loser's error: one of the duplicate DDL
|
|
1285
|
+
# classes directly (raw conn.exec paths), or wrapped — pgmq-ruby
|
|
1286
|
+
# raises ConnectionError inside `rescue PG::Error`, so Ruby sets the
|
|
1287
|
+
# duplicate as its cause automatically.
|
|
1288
|
+
def duplicate_relation_error?(error)
|
|
1289
|
+
duplicate_install_error?(error) || duplicate_install_error?(error.cause)
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# Whether pgmq.meta records the queue — the authoritative "create
|
|
1293
|
+
# committed" signal (pgmq.create writes it atomically with the
|
|
1294
|
+
# tables). The pooled checkout is a sequential sibling of the failed
|
|
1295
|
+
# create's (already returned when the exception unwound), so there is
|
|
1296
|
+
# no nested checkout — same reasoning as notify_trigger_current?.
|
|
1297
|
+
def queue_registered?(full_name)
|
|
1298
|
+
@pgmq.with_connection do |conn|
|
|
1299
|
+
conn.exec_params("SELECT 1 FROM pgmq.meta WHERE queue_name = $1 LIMIT 1", [full_name]).ntuples.positive?
|
|
1300
|
+
end
|
|
1301
|
+
rescue StandardError
|
|
1302
|
+
# Can't confirm (aborted caller transaction, schema not ready) —
|
|
1303
|
+
# fall through to the retry, which surfaces the state honestly.
|
|
1304
|
+
false
|
|
1217
1305
|
end
|
|
1218
1306
|
|
|
1219
1307
|
# Check whether the NOTIFY trigger already exists on this queue with the
|
|
@@ -1238,7 +1326,7 @@ module Pgbus
|
|
|
1238
1326
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
1239
1327
|
WHERE n.nspname = 'pgmq'
|
|
1240
1328
|
AND c.relname = pgmq.format_table_name($1, 'q')
|
|
1241
|
-
AND t.tgname = '
|
|
1329
|
+
AND t.tgname = '#{NOTIFY_TRIGGER_NAME}'
|
|
1242
1330
|
AND EXISTS (
|
|
1243
1331
|
SELECT 1 FROM pgmq.notify_insert_throttle
|
|
1244
1332
|
WHERE queue_name = $1
|
data/lib/pgbus/version.rb
CHANGED