pgbus 0.13.4 → 0.13.6

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: 9f59e5211aa8a7dedbbca32416bf5f82943848229ea5cf0a967f3681f14c4613
4
- data.tar.gz: 8db3311f9117fbb1980982bb4f0de9a25ac610ff59898e8877631fdbc5c92f5f
3
+ metadata.gz: 65404b35486048a6edc37551a47d482d4e92e76f462ba930196b81473f5f304e
4
+ data.tar.gz: 90bb9c64bc3fbc9b7a920da9f2bf5b8c3caaa0ff273ae05fecdbf5bc0f8a35f0
5
5
  SHA512:
6
- metadata.gz: 478a033b2857060eec4240e8556e789af8055fdc34fc3c17639646159e168289e1f7e0ea80443137b9980294dde3d114488abbb76317e7a2f079d2df22e79fb5
7
- data.tar.gz: 9e9ae02d538d96d30f141bed4a5da3e0420841da0364b452d0aa347b6ce242f9eae605514c148805542bdd24b44f949592e0d155f58b7a20f765e70231fd04b3
6
+ metadata.gz: 68bbec64ec3e81a979caf0b5e6bb2fda4aef6b658084aa47041b33e25f1c0a6d84d7643780e0c9c9f50db3fc18a5f437778bcf5159c45328046568a4bc3c899c
7
+ data.tar.gz: 40847bed714c4de82160d1ade8ca7c992612d363f8c59ee0c5d1eb30c4fccb1ac98b8e099e723247e9758474a2bd60864dac98eabc611ec9a466ba3ac89020f2
data/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ### Fixed
4
4
 
5
+ - **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
+
5
7
  - **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
8
 
7
9
  - **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,39 @@ 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
+ # Install-race losers see the winner's DDL as one of these. Matched by
32
+ # class NAME so the check works whether or not the pg gem's generated
33
+ # error classes are loaded in this process (mirrors the defined?(PG::…)
34
+ # guards used elsewhere in this file).
35
+ DUPLICATE_INSTALL_ERROR_CLASSES = %w[
36
+ PG::UniqueViolation
37
+ PG::DuplicateSchema
38
+ PG::DuplicateTable
39
+ PG::DuplicateObject
40
+ PG::DuplicateFunction
41
+ ].freeze
42
+ private_constant :DUPLICATE_INSTALL_ERROR_CLASSES
43
+
44
+ # Process-wide, not per-instance: on the shared-AR Proc path two Client
45
+ # instances share one underlying libpq connection while each holding their
46
+ # own @pgmq_mutex, so a per-instance guard cannot serialize bootstrap DDL —
47
+ # concurrent install traffic desyncs the protocol ("message type 0x…
48
+ # arrived from server while idle") and wedges a thread on a socket read
49
+ # (issue #397, forensics in getzazu/app#3413).
50
+ @pgmq_install_mutex = Mutex.new
51
+
52
+ class << self
53
+ attr_reader :pgmq_install_mutex
54
+ end
55
+
23
56
  # Throttle window for PGMQ's enable_notify_insert trigger. Postgres
24
57
  # NOTIFYs are coalesced into one wake-up per window, so a value of 250ms
25
58
  # means: at most 4 broadcasts/sec per queue, regardless of insert rate.
@@ -245,10 +278,7 @@ module Pgbus
245
278
  # present, no tracking row) apart from "PGMQ not installed at all".
246
279
  def pgmq_installed?
247
280
  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?
281
+ conn.exec(PGMQ_META_CHECK_SQL).ntuples.positive?
252
282
  end
253
283
  end
254
284
 
@@ -943,13 +973,10 @@ module Pgbus
943
973
  def ensure_pgmq_schema
944
974
  return if @schema_ensured
945
975
 
946
- synchronized do
976
+ self.class.pgmq_install_mutex.synchronize do
947
977
  return if @schema_ensured
948
978
 
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?
952
- end
979
+ with_raw_connection { |raw_conn| install_pgmq_schema_serialized(raw_conn) }
953
980
  @schema_ensured = true
954
981
  end
955
982
  rescue StandardError => e
@@ -958,6 +985,36 @@ module Pgbus
958
985
  "Ensure the pgbus database exists and migrations have been run."
959
986
  end
960
987
 
988
+ # Check-and-install inside one transaction holding a fixed advisory lock:
989
+ # pg_advisory_xact_lock serializes installers across processes and releases
990
+ # itself at COMMIT/ROLLBACK — safe through transaction-pooling poolers,
991
+ # where a session-level lock could be released on a different server
992
+ # connection than the one that acquired it (issue #397).
993
+ def install_pgmq_schema_serialized(conn)
994
+ conn.exec("BEGIN")
995
+ conn.exec("SELECT pg_advisory_xact_lock(#{PGMQ_INSTALL_LOCK_KEY})")
996
+ install_pgmq_schema(conn) if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
997
+ conn.exec("COMMIT")
998
+ rescue StandardError => e
999
+ begin
1000
+ conn.exec("ROLLBACK")
1001
+ rescue StandardError
1002
+ # A connection broken enough to refuse ROLLBACK also fails the
1003
+ # re-check below, which surfaces the state honestly; re-raising the
1004
+ # ROLLBACK error here would mask the original install failure.
1005
+ end
1006
+ raise e unless duplicate_install_error?(e)
1007
+
1008
+ # A process without the advisory lock (older pgbus, or the extension
1009
+ # path) won the install race — re-check instead of failing on its
1010
+ # success.
1011
+ raise e if conn.exec(PGMQ_META_CHECK_SQL).ntuples.zero?
1012
+ end
1013
+
1014
+ def duplicate_install_error?(error)
1015
+ DUPLICATE_INSTALL_ERROR_CLASSES.include?(error.class.name)
1016
+ end
1017
+
961
1018
  def install_pgmq_schema(conn)
962
1019
  mode = config.pgmq_schema_mode
963
1020
 
data/lib/pgbus/streams.rb CHANGED
@@ -386,10 +386,26 @@ module Pgbus
386
386
  # after_commit, so we have to check #open? explicitly — otherwise
387
387
  # every call path would hit the "deferred" branch and we'd lose the
388
388
  # msg_id return value.
389
+ #
390
+ # The probe must only inspect a connection the calling thread/fiber
391
+ # ALREADY leases — `connection_pool.active_connection?`, never
392
+ # `ActiveRecord::Base.connection`. On Rails 7.2+ `.connection` takes a
393
+ # sticky executor-scoped lease: on a non-executor thread (the
394
+ # Coalescer's flush thread, app worker threads) it is never released —
395
+ # one pool connection pinned per thread — and inside
396
+ # `with_connection` the sticky flag defeats the block-exit release, so
397
+ # the CALLER's connection leaks when its thread dies. Both variants
398
+ # exhausted an exactly-sized pool deterministically (Zazu fan-out
399
+ # incident, 2026-08-05). Semantics are unchanged: a transaction is
400
+ # per-lease, so a thread holding no connection can have no open
401
+ # transaction to defer on — the old code's fresh checkout always
402
+ # answered nil anyway, at the price of the leak.
389
403
  def current_open_transaction
390
404
  return nil unless defined?(::ActiveRecord::Base)
391
405
 
392
- connection = ::ActiveRecord::Base.connection
406
+ connection = ::ActiveRecord::Base.connection_pool.active_connection?
407
+ return nil unless connection
408
+
393
409
  transaction = connection.current_transaction
394
410
  transaction if transaction.open?
395
411
  rescue StandardError => e
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.4"
4
+ VERSION = "0.13.6"
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.4
4
+ version: 0.13.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson