pgbus 0.13.6 → 0.13.8
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/app/models/pgbus/stream_queue.rb +37 -5
- data/lib/pgbus/client.rb +122 -25
- 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: 403234bf91b0ae06bd9c1331b6356450c98cc7a062a7ebcfb45f159950d07c2c
|
|
4
|
+
data.tar.gz: a314687f0071c9588433dcd452c146178cc1afc52a2e46a675e998004bdbef2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80ce61932ee3f9c34562c353219ae5c19a2a1b312c8418e6ed645e32c66ac2e7f590edd7b9cc861e4f13b4bd784a7f2712d4b45b11f0a0c49b30fa117a664501
|
|
7
|
+
data.tar.gz: 91856c566af986f8de528c40e036a00548adf0d7bb0d6e1fc96abb887120613df0b9f8fb22a04292bf4bb794baf06e6a7668b823b05fed730d52b420ff5cfc9b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
### Fixed
|
|
4
4
|
|
|
5
|
+
- **`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
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
5
9
|
- **PGMQ schema installation is now race-safe across clients and processes (issue #397).** `ensure_pgmq_schema` guarded check+install with `@schema_ensured` + `synchronized` — both per-instance, and `synchronized` is a no-op on the dedicated-connection path — so two Client instances (or two threads on the dedicated path) could install concurrently. The loser's `PG::UniqueViolation` (`Key (nspname)=(pgmq) already exists`) surfaced as `SchemaNotReady` even though the schema was fine, and on the shared-AR Proc path — where two instances each hold their *own* mutex around one shared libpq connection — the concurrent install traffic desynced the protocol (`message type 0x… arrived from server while idle`) and left a thread blocked on a socket read forever (downstream forensics: a CI shard going silent until the merge queue's timeout evicted the PR, getzazu/app#3413). Three changes: **(1)** schema bootstrap is serialized process-wide through a class-level mutex, not per-instance state; **(2)** check+install runs inside one explicit transaction holding `pg_advisory_xact_lock` on a fixed key (`Pgbus::Client::PGMQ_INSTALL_LOCK_KEY`), serializing installers across processes — xact-scoped so the lock releases itself at COMMIT/ROLLBACK and stays safe through transaction-pooling poolers, where a session lock's unlock could land on a different server connection; **(3)** a duplicate-object install failure (`PG::UniqueViolation`, `PG::DuplicateSchema`, `PG::DuplicateTable`, `PG::DuplicateObject`, `PG::DuplicateFunction` — a process without the advisory lock, e.g. older pgbus or the extension path, won the race) is rescued by re-checking `pgmq.meta`: present means proceed as installed, absent means the original error is re-raised wrapped in `SchemaNotReady`. Refs #397.
|
|
6
10
|
|
|
7
11
|
- **SSE delivery no longer strips newlines from broadcast payloads — multiline payloads are framed as consecutive `data:` lines per the SSE spec (issue #392).** `Streams::Envelope.message` collapsed `\r`/`\n` in the payload to nothing before writing the single `data:` line, silently corrupting any whitespace-significant broadcast (pre-formatted `<pre>` content, textarea seeds, JSON-in-data frames) on **both** the ephemeral and durable delivery paths — HTML's whitespace tolerance is why it went unnoticed. A multiline payload is now split on `\r\n`/`\r`/`\n` into consecutive `data:` lines, which EventSource clients rejoin with `\n`, making delivery lossless (a trailing newline survives via an empty final `data:` line; `\r` variants normalize to `\n` — SSE line terminators cannot be carried raw). The original injection defense is preserved: every payload line carries the `data:` prefix followed by one space, so a crafted payload still cannot forge `id:`/`event:` fields, and single-line fields (event names, comments) still strip newlines. The `<pgbus-stream-source>` element's fetch-path parser had the matching client-side bug — it joined `data:` lines without `\n` *and* `trim()`ed payload whitespace — and now follows EventSource semantics (join with `\n`, strip only the single leading space). Refs #392.
|
|
@@ -25,17 +25,34 @@ module Pgbus
|
|
|
25
25
|
class StreamQueue < BusRecord
|
|
26
26
|
self.table_name = "pgbus_stream_queues"
|
|
27
27
|
|
|
28
|
+
# Serializes the WARN-once latch in log_record_failure: record! runs on
|
|
29
|
+
# the coalescer flush thread as well as callers' threads, and an
|
|
30
|
+
# unsynchronized check-and-set could WARN more than once per process.
|
|
31
|
+
@record_failure_mutex = Mutex.new
|
|
32
|
+
|
|
28
33
|
class << self
|
|
29
|
-
#
|
|
34
|
+
# Inserts the physical queue name. Idempotent and cheap to call on
|
|
30
35
|
# every broadcast; the caller (`ensure_stream_queue`) also memoizes
|
|
31
36
|
# per-process, so the DB write happens once per stream per process.
|
|
32
37
|
# Errors are swallowed — a registry hiccup must never abort a broadcast.
|
|
33
38
|
# Returns true on a successful write, false when the table is absent or
|
|
34
|
-
# the
|
|
39
|
+
# the insert failed (so callers like `backfill!` can report accurately).
|
|
40
|
+
#
|
|
41
|
+
# Deliberately raw SQL rather than `upsert(unique_by:)` (issue #401):
|
|
42
|
+
# Rails resolves `unique_by:` through the pool's schema cache, which
|
|
43
|
+
# caches a negative `data_source_exists?` probe permanently — one wrong
|
|
44
|
+
# first probe (while the table genuinely exists and the live
|
|
45
|
+
# `table_exists?` guard above passes) poisons every subsequent record!
|
|
46
|
+
# in the process with "No unique index found". The unique index is owned
|
|
47
|
+
# by this gem's own migration, so there is nothing to resolve.
|
|
35
48
|
def record!(queue_name)
|
|
36
49
|
return false unless table_exists?
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
conn = connection
|
|
52
|
+
conn.execute(
|
|
53
|
+
"INSERT INTO #{conn.quote_table_name(table_name)} (queue_name) " \
|
|
54
|
+
"VALUES (#{conn.quote(queue_name)}) ON CONFLICT (queue_name) DO NOTHING"
|
|
55
|
+
)
|
|
39
56
|
# Keep the in-process cache consistent with the write so a subsequent
|
|
40
57
|
# stream? check reflects this registration without a re-query. Only
|
|
41
58
|
# update an ALREADY-LOADED cache — if @all_names is still nil (this
|
|
@@ -43,12 +60,15 @@ module Pgbus
|
|
|
43
60
|
# fabricate a one-entry set and silently hide every other
|
|
44
61
|
# already-registered stream until the next reset_cache!. Leaving it
|
|
45
62
|
# nil lets the next all_names call do a real load, which already
|
|
46
|
-
# includes this row since the
|
|
63
|
+
# includes this row since the insert above has committed.
|
|
47
64
|
@all_names&.add(queue_name)
|
|
48
65
|
true
|
|
49
|
-
rescue
|
|
66
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
50
67
|
Pgbus.logger.debug { "[Pgbus] Failed to record stream queue #{queue_name}: #{e.message}" }
|
|
51
68
|
false
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
log_record_failure(queue_name, e)
|
|
71
|
+
false
|
|
52
72
|
end
|
|
53
73
|
|
|
54
74
|
# Set of all registered physical stream queue names. Memoized so a
|
|
@@ -124,6 +144,18 @@ module Pgbus
|
|
|
124
144
|
|
|
125
145
|
private
|
|
126
146
|
|
|
147
|
+
# A non-ActiveRecord error out of a plain INSERT is a bug signal (the
|
|
148
|
+
# old path's ArgumentError from index resolution was one), not a DB
|
|
149
|
+
# hiccup — surface it at WARN once per process instead of drowning a
|
|
150
|
+
# process-lifetime malfunction in per-broadcast DEBUG spam.
|
|
151
|
+
def log_record_failure(queue_name, error)
|
|
152
|
+
message = "[Pgbus] Failed to record stream queue #{queue_name}: #{error.class}: #{error.message}"
|
|
153
|
+
first = @record_failure_mutex.synchronize do
|
|
154
|
+
@record_failure_warned ? false : (@record_failure_warned = true)
|
|
155
|
+
end
|
|
156
|
+
first ? Pgbus.logger.warn { message } : Pgbus.logger.debug { message }
|
|
157
|
+
end
|
|
158
|
+
|
|
127
159
|
def load_names
|
|
128
160
|
return Set.new unless table_exists?
|
|
129
161
|
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -28,6 +28,9 @@ module Pgbus
|
|
|
28
28
|
PGMQ_META_CHECK_SQL = "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
|
|
29
29
|
private_constant :PGMQ_META_CHECK_SQL
|
|
30
30
|
|
|
31
|
+
PGMQ_INSTALL_SAVEPOINT = "pgbus_pgmq_install"
|
|
32
|
+
private_constant :PGMQ_INSTALL_SAVEPOINT
|
|
33
|
+
|
|
31
34
|
# Install-race losers see the winner's DDL as one of these. Matched by
|
|
32
35
|
# class NAME so the check works whether or not the pg gem's generated
|
|
33
36
|
# error classes are loaded in this process (mirrors the defined?(PG::…)
|
|
@@ -320,12 +323,13 @@ module Pgbus
|
|
|
320
323
|
dlq_name = config.dead_letter_queue_name(name)
|
|
321
324
|
return if @queues_created[dlq_name]
|
|
322
325
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
326
|
+
if queue_ddl_rides_caller_transaction?
|
|
327
|
+
create_dead_letter_queue_physically(dlq_name)
|
|
328
|
+
else
|
|
329
|
+
@queues_created.compute_if_absent(dlq_name) do
|
|
330
|
+
create_dead_letter_queue_physically(dlq_name)
|
|
331
|
+
true
|
|
327
332
|
end
|
|
328
|
-
true
|
|
329
333
|
end
|
|
330
334
|
end
|
|
331
335
|
|
|
@@ -976,8 +980,29 @@ module Pgbus
|
|
|
976
980
|
self.class.pgmq_install_mutex.synchronize do
|
|
977
981
|
return if @schema_ensured
|
|
978
982
|
|
|
979
|
-
|
|
980
|
-
|
|
983
|
+
# Cache only a durable result: true only when this call owned the
|
|
984
|
+
# COMMIT. A savepoint-path ensure rides the CALLER's transaction — if
|
|
985
|
+
# that later rolls back the schema is gone (and even a schema found
|
|
986
|
+
# already-present there may be the caller's own uncommitted work), so
|
|
987
|
+
# a cached true would skip every future check (#399 review).
|
|
988
|
+
#
|
|
989
|
+
# synchronized (the per-instance connection mutex) nests INSIDE the
|
|
990
|
+
# class-level install mutex — that lock order is safe because no path
|
|
991
|
+
# acquires them the other way round — so the shared Proc connection is
|
|
992
|
+
# never touched while another thread of this instance is mid-operation
|
|
993
|
+
# on it (single-owner invariant; #399 review).
|
|
994
|
+
durable = synchronized do
|
|
995
|
+
with_raw_connection do |raw_conn|
|
|
996
|
+
if inside_caller_transaction?(raw_conn)
|
|
997
|
+
install_pgmq_schema_in_savepoint(raw_conn)
|
|
998
|
+
false
|
|
999
|
+
else
|
|
1000
|
+
install_pgmq_schema_in_own_transaction(raw_conn)
|
|
1001
|
+
true
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
1005
|
+
@schema_ensured = true if durable
|
|
981
1006
|
end
|
|
982
1007
|
rescue StandardError => e
|
|
983
1008
|
raise Pgbus::SchemaNotReady,
|
|
@@ -985,30 +1010,64 @@ module Pgbus
|
|
|
985
1010
|
"Ensure the pgbus database exists and migrations have been run."
|
|
986
1011
|
end
|
|
987
1012
|
|
|
988
|
-
# Check-and-install
|
|
989
|
-
#
|
|
990
|
-
#
|
|
991
|
-
#
|
|
992
|
-
#
|
|
993
|
-
|
|
1013
|
+
# Check-and-install under a fixed advisory lock: pg_advisory_xact_lock
|
|
1014
|
+
# serializes installers across processes and releases itself when its
|
|
1015
|
+
# transaction ends — safe through transaction-pooling poolers, where a
|
|
1016
|
+
# session-level lock could be released on a different server connection
|
|
1017
|
+
# than the one that acquired it (issue #397).
|
|
1018
|
+
#
|
|
1019
|
+
# The transactional framing must respect who owns the transaction. A
|
|
1020
|
+
# Proc-supplied shared connection (the Rails-lambda path) can arrive
|
|
1021
|
+
# mid-transaction — e.g. perform_later inside an application
|
|
1022
|
+
# `transaction do` block. BEGIN there is a warning-level no-op, and the
|
|
1023
|
+
# matching COMMIT/ROLLBACK would then commit or destroy the CALLER's
|
|
1024
|
+
# transaction (#398 review). So: own the transaction only when the
|
|
1025
|
+
# connection is idle; ride the caller's transaction via a savepoint
|
|
1026
|
+
# otherwise.
|
|
1027
|
+
#
|
|
1028
|
+
# respond_to? guard: a Proc can hand back any connection-shaped object;
|
|
1029
|
+
# only a real PG::Connection reports transaction_status (and its presence
|
|
1030
|
+
# guarantees the PG constants below are loaded).
|
|
1031
|
+
def inside_caller_transaction?(conn)
|
|
1032
|
+
conn.respond_to?(:transaction_status) && conn.transaction_status != PG::PQTRANS_IDLE
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def install_pgmq_schema_in_own_transaction(conn)
|
|
994
1036
|
conn.exec("BEGIN")
|
|
995
1037
|
conn.exec("SELECT pg_advisory_xact_lock(#{PGMQ_INSTALL_LOCK_KEY})")
|
|
996
1038
|
install_pgmq_schema(conn) if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
|
|
997
1039
|
conn.exec("COMMIT")
|
|
998
1040
|
rescue StandardError => e
|
|
1041
|
+
recover_from_install_failure(conn, e, "ROLLBACK")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# The advisory lock joins the CALLER's transaction here, so it is held
|
|
1045
|
+
# until that transaction ends — longer than the install needs, but xact
|
|
1046
|
+
# locks cannot be released early by design, and over-holding only delays
|
|
1047
|
+
# a concurrent installer, never corrupts it.
|
|
1048
|
+
def install_pgmq_schema_in_savepoint(conn)
|
|
1049
|
+
conn.exec("SAVEPOINT #{PGMQ_INSTALL_SAVEPOINT}")
|
|
1050
|
+
conn.exec("SELECT pg_advisory_xact_lock(#{PGMQ_INSTALL_LOCK_KEY})")
|
|
1051
|
+
install_pgmq_schema(conn) if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
|
|
1052
|
+
conn.exec("RELEASE SAVEPOINT #{PGMQ_INSTALL_SAVEPOINT}")
|
|
1053
|
+
rescue StandardError => e
|
|
1054
|
+
recover_from_install_failure(conn, e, "ROLLBACK TO SAVEPOINT #{PGMQ_INSTALL_SAVEPOINT}")
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def recover_from_install_failure(conn, error, rollback_sql)
|
|
999
1058
|
begin
|
|
1000
|
-
conn.exec(
|
|
1059
|
+
conn.exec(rollback_sql)
|
|
1001
1060
|
rescue StandardError
|
|
1002
|
-
# A connection broken enough to refuse
|
|
1061
|
+
# A connection broken enough to refuse the rollback also fails the
|
|
1003
1062
|
# re-check below, which surfaces the state honestly; re-raising the
|
|
1004
|
-
#
|
|
1063
|
+
# rollback error here would mask the original install failure.
|
|
1005
1064
|
end
|
|
1006
|
-
raise
|
|
1065
|
+
raise error unless duplicate_install_error?(error)
|
|
1007
1066
|
|
|
1008
1067
|
# A process without the advisory lock (older pgbus, or the extension
|
|
1009
1068
|
# path) won the install race — re-check instead of failing on its
|
|
1010
1069
|
# success.
|
|
1011
|
-
raise
|
|
1070
|
+
raise error if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
|
|
1012
1071
|
end
|
|
1013
1072
|
|
|
1014
1073
|
def duplicate_install_error?(error)
|
|
@@ -1095,14 +1154,52 @@ module Pgbus
|
|
|
1095
1154
|
def ensure_single_queue(full_name)
|
|
1096
1155
|
return if @queues_created[full_name]
|
|
1097
1156
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1157
|
+
if queue_ddl_rides_caller_transaction?
|
|
1158
|
+
create_queue_physically(full_name)
|
|
1159
|
+
else
|
|
1160
|
+
@queues_created.compute_if_absent(full_name) do
|
|
1161
|
+
create_queue_physically(full_name)
|
|
1162
|
+
true
|
|
1104
1163
|
end
|
|
1105
|
-
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
def create_queue_physically(full_name)
|
|
1168
|
+
synchronized do
|
|
1169
|
+
create_queue_table(full_name)
|
|
1170
|
+
enable_notify_if_needed(full_name, NOTIFY_THROTTLE_MS)
|
|
1171
|
+
create_fifo_index_if_needed(full_name)
|
|
1172
|
+
end
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def create_dead_letter_queue_physically(dlq_name)
|
|
1176
|
+
synchronized { create_queue_table(dlq_name) }
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# Runs inside synchronized — callers own the connection mutex.
|
|
1180
|
+
def create_queue_table(name)
|
|
1181
|
+
@pgmq.create(name)
|
|
1182
|
+
tune_autovacuum(name)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
# Queue DDL on the shared Proc-supplied connection joins any transaction
|
|
1186
|
+
# the caller has open, so a @queues_created cache write there outlives a
|
|
1187
|
+
# caller rollback — later ensures would skip recreation and message
|
|
1188
|
+
# operations would fail (#399 review; same durability rule as
|
|
1189
|
+
# @schema_ensured). Create the queue (idempotent CREATE IF NOT EXISTS)
|
|
1190
|
+
# but let the next ensure re-check. Dedicated String/Hash paths run DDL
|
|
1191
|
+
# on pgmq-ruby's own pool connections, never inside an application
|
|
1192
|
+
# transaction, so they always cache.
|
|
1193
|
+
#
|
|
1194
|
+
# The probe itself must hold the connection mutex: even the local
|
|
1195
|
+
# transaction_status read honors the single-owner invariant on the
|
|
1196
|
+
# shared PG::Connection (#399 review). Sequential with — never nested
|
|
1197
|
+
# inside — the create's own synchronized block.
|
|
1198
|
+
def queue_ddl_rides_caller_transaction?
|
|
1199
|
+
return false unless @shared_connection
|
|
1200
|
+
|
|
1201
|
+
synchronized do
|
|
1202
|
+
with_raw_connection { |conn| inside_caller_transaction?(conn) }
|
|
1106
1203
|
end
|
|
1107
1204
|
end
|
|
1108
1205
|
|
data/lib/pgbus/version.rb
CHANGED