pgbus 0.13.5 → 0.13.7

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: 2557dc106f7f9c66f1d8cd9aba8b08a984f9777f7c026fc66922b80006dcda92
4
- data.tar.gz: 2ca250fe0621970c4de8135f3928214d5906b22f6e83d59389b592787b0b1eda
3
+ metadata.gz: 36e4016b7a3103001ac29bb75d10c949b7cc8866a1ef511ad64a1ca61643e14a
4
+ data.tar.gz: 96897f60545b8ac14ec13d1cf267fecf408e690badb41973e0c9f7a9eb6967c3
5
5
  SHA512:
6
- metadata.gz: e107b44c7463c408dad493570ad5393b2a2b13fc63b3d9925fbfc0ddfb00f08c3f56eb59fa91a1f6f1959a581b9bc6c6afce45286379f394c9c0b711001a5617
7
- data.tar.gz: 9e20a794feac0b05270055a66ca6478ff3a7baeb4d845b267d624fc47b0d2038e1a1212a551bbd70ea461ca075728be81389b0e5497f105c0aa608ed7de43724
6
+ metadata.gz: 64601019ee0ffebe7babed1295b471aed0a116ee68efa25e11216c0709a3e217a31020e0d4f99abee78b17891f794d679db54bef293b2b1193815cc4df3cb721
7
+ data.tar.gz: 1524b6c8e4434d81e433c1a550259c44e5e47c308fbd594ae1777ae22472040504b97e173e9d4a2b11102ba1a7a79ab52ad7dd7c9efcc12c16f007cd48f779fb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ### Fixed
4
4
 
5
+ - **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.
6
+
7
+ - **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.
8
+
5
9
  - **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.
6
10
 
7
11
  - **Ephemeral broadcasts over the PG NOTIFY payload cap no longer fail — loudly on the sync path, silently in the coalescer — they auto-degrade to a durable publish (issue #391).** Ephemeral frames ride the NOTIFY payload itself, which PostgreSQL caps below 8000 bytes. Any rendered-component broadcast (a progress card with Tailwind classes easily exceeds it) previously raised `PGMQ::Errors::ConnectionError: … payload string too long` — an error class that sent diagnosis toward the connection, not the payload — and on the `coalesce:` path that raise happened inside the coalescer's flush thread, reaching no caller, no ErrorReporter, no log: small frames delivered, big frames vanished, and the operator saw "SSE works but updates don't arrive". Three changes: **(1)** `Stream#broadcast` now measures the wrapped JSON before the NOTIFY and publishes an over-budget frame durably instead (payload stored in PGMQ, the queue's insert trigger fires the NOTIFY as a bare wake on the same channel the subscriber already LISTENs on) — delivery semantics preserved on both the sync and coalesced paths, warn-logged and instrumented (`pgbus.stream.broadcast` with `ephemeral_fallback: true`). **(2)** Direct `Client#notify_stream` callers get publish-time validation: a typed `Pgbus::Streams::PayloadTooLarge` raised at the call site for payloads exceeding `Pgbus::Client::NotifyStream::NOTIFY_PAYLOAD_LIMIT_BYTES` (7999 bytes, the largest accepted payload), naming the stream, the byte count, and the durable-mode escape hatch. **(3)** The coalescer's flush thread routes every flush error through `ErrorReporter` (same report-don't-log reasoning as #352) — a background thread swallowing delivery failures is invisible to APM by construction. ⚠️ **Upgrade note for 0.13 installs:** `streams_default_broadcast_mode` defaults to `:ephemeral`, and that default is a **behavior change** for apps broadcasting rendered components (what `broadcast_render`-style usage produces) — before this fix, any frame over ~8KB was silently lost or misdiagnosed. Durable is the right mode for turbo-stream UI regardless (since-id replay needs the archive): pin `config.streams_default_broadcast_mode = :durable`, or use `streams_durable_patterns` for the streams that need it; the auto-fallback now covers whatever stays ephemeral. Refs #391.
data/lib/pgbus/client.rb CHANGED
@@ -20,6 +20,42 @@ module Pgbus
20
20
  PGMQ_REQUIRE_MUTEX = Mutex.new
21
21
  private_constant :PGMQ_REQUIRE_MUTEX
22
22
 
23
+ # Fixed advisory-lock key serializing pgmq schema installation across
24
+ # processes (issue #397). "pgmqinst" in ASCII hex — arbitrary but stable;
25
+ # it only has to be identical in every process that can install.
26
+ PGMQ_INSTALL_LOCK_KEY = 0x70676D71_696E7374
27
+
28
+ PGMQ_META_CHECK_SQL = "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
29
+ private_constant :PGMQ_META_CHECK_SQL
30
+
31
+ PGMQ_INSTALL_SAVEPOINT = "pgbus_pgmq_install"
32
+ private_constant :PGMQ_INSTALL_SAVEPOINT
33
+
34
+ # Install-race losers see the winner's DDL as one of these. Matched by
35
+ # class NAME so the check works whether or not the pg gem's generated
36
+ # error classes are loaded in this process (mirrors the defined?(PG::…)
37
+ # guards used elsewhere in this file).
38
+ DUPLICATE_INSTALL_ERROR_CLASSES = %w[
39
+ PG::UniqueViolation
40
+ PG::DuplicateSchema
41
+ PG::DuplicateTable
42
+ PG::DuplicateObject
43
+ PG::DuplicateFunction
44
+ ].freeze
45
+ private_constant :DUPLICATE_INSTALL_ERROR_CLASSES
46
+
47
+ # Process-wide, not per-instance: on the shared-AR Proc path two Client
48
+ # instances share one underlying libpq connection while each holding their
49
+ # own @pgmq_mutex, so a per-instance guard cannot serialize bootstrap DDL —
50
+ # concurrent install traffic desyncs the protocol ("message type 0x…
51
+ # arrived from server while idle") and wedges a thread on a socket read
52
+ # (issue #397, forensics in getzazu/app#3413).
53
+ @pgmq_install_mutex = Mutex.new
54
+
55
+ class << self
56
+ attr_reader :pgmq_install_mutex
57
+ end
58
+
23
59
  # Throttle window for PGMQ's enable_notify_insert trigger. Postgres
24
60
  # NOTIFYs are coalesced into one wake-up per window, so a value of 250ms
25
61
  # means: at most 4 broadcasts/sec per queue, regardless of insert rate.
@@ -245,10 +281,7 @@ module Pgbus
245
281
  # present, no tracking row) apart from "PGMQ not installed at all".
246
282
  def pgmq_installed?
247
283
  with_raw_connection do |conn|
248
- result = conn.exec(
249
- "SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1"
250
- )
251
- result.ntuples.positive?
284
+ conn.exec(PGMQ_META_CHECK_SQL).ntuples.positive?
252
285
  end
253
286
  end
254
287
 
@@ -290,12 +323,13 @@ module Pgbus
290
323
  dlq_name = config.dead_letter_queue_name(name)
291
324
  return if @queues_created[dlq_name]
292
325
 
293
- @queues_created.compute_if_absent(dlq_name) do
294
- synchronized do
295
- @pgmq.create(dlq_name)
296
- tune_autovacuum(dlq_name)
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
297
332
  end
298
- true
299
333
  end
300
334
  end
301
335
 
@@ -943,14 +977,32 @@ module Pgbus
943
977
  def ensure_pgmq_schema
944
978
  return if @schema_ensured
945
979
 
946
- synchronized do
980
+ self.class.pgmq_install_mutex.synchronize do
947
981
  return if @schema_ensured
948
982
 
949
- with_raw_connection do |raw_conn|
950
- exists = raw_conn.exec("SELECT 1 FROM pg_tables WHERE schemaname = 'pgmq' AND tablename = 'meta' LIMIT 1")
951
- install_pgmq_schema(raw_conn) if exists.ntuples.zero?
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
952
1004
  end
953
- @schema_ensured = true
1005
+ @schema_ensured = true if durable
954
1006
  end
955
1007
  rescue StandardError => e
956
1008
  raise Pgbus::SchemaNotReady,
@@ -958,6 +1010,70 @@ module Pgbus
958
1010
  "Ensure the pgbus database exists and migrations have been run."
959
1011
  end
960
1012
 
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)
1036
+ conn.exec("BEGIN")
1037
+ conn.exec("SELECT pg_advisory_xact_lock(#{PGMQ_INSTALL_LOCK_KEY})")
1038
+ install_pgmq_schema(conn) if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
1039
+ conn.exec("COMMIT")
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)
1058
+ begin
1059
+ conn.exec(rollback_sql)
1060
+ rescue StandardError
1061
+ # A connection broken enough to refuse the rollback also fails the
1062
+ # re-check below, which surfaces the state honestly; re-raising the
1063
+ # rollback error here would mask the original install failure.
1064
+ end
1065
+ raise error unless duplicate_install_error?(error)
1066
+
1067
+ # A process without the advisory lock (older pgbus, or the extension
1068
+ # path) won the install race — re-check instead of failing on its
1069
+ # success.
1070
+ raise error if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
1071
+ end
1072
+
1073
+ def duplicate_install_error?(error)
1074
+ DUPLICATE_INSTALL_ERROR_CLASSES.include?(error.class.name)
1075
+ end
1076
+
961
1077
  def install_pgmq_schema(conn)
962
1078
  mode = config.pgmq_schema_mode
963
1079
 
@@ -1038,14 +1154,52 @@ module Pgbus
1038
1154
  def ensure_single_queue(full_name)
1039
1155
  return if @queues_created[full_name]
1040
1156
 
1041
- @queues_created.compute_if_absent(full_name) do
1042
- synchronized do
1043
- @pgmq.create(full_name)
1044
- tune_autovacuum(full_name)
1045
- enable_notify_if_needed(full_name, NOTIFY_THROTTLE_MS)
1046
- create_fifo_index_if_needed(full_name)
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
1047
1163
  end
1048
- true
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) }
1049
1203
  end
1050
1204
  end
1051
1205
 
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.5"
4
+ VERSION = "0.13.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.5
4
+ version: 0.13.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson