pgbus 0.13.0 → 0.13.1

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: '0589d2d6e69eb8b2ba5863cb8c8e2779bca29528030410508e1930fcfbd0d774'
4
- data.tar.gz: c491442cd7b1d2bdccc5ff95153dc31abb424111906e17872b6b112830a1cde7
3
+ metadata.gz: 8c955f8fb2ac4e66fb2db388505ee83341aa6dec7c00f606162f7fba7c29cb75
4
+ data.tar.gz: a31e43c6f48cb10609d73dc507e9d177d25a299ae25c6f5d5a39a045018c32e8
5
5
  SHA512:
6
- metadata.gz: 63e8480dcd7efb70bd7933cba734c23d7baa6f9e50ff658b575720da5ccc53e43fde1e4aca0fc7c30b6715448d040c1bb2fff472f5ac1686ae3e62853beb0bdd
7
- data.tar.gz: 186da9576934f14264e9d9989c1a035d1c725e8283d1a735692da90f1610f4c538776b558a526d0c536466f92e405ffcbf99fd5bf229f9bf9b92dd1e0d7ec876
6
+ metadata.gz: 7cd3fba720a5ab724fe4f0b5b89168cb2df4ba68e0a5a35cca725093724f8f2b2109c379a2263442111367001a52d2eda440eef8e1c2469653d2022d2a92d148
7
+ data.tar.gz: 4a4e5e1c2c229c599541e61ac6ad5602dfbfcf057b346b22b0271c10a48752c41075c7166b43dd55ece7ef4a30d79ec3fd6f5cac5b898b2578ecb90c73bcc2a8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - **Streams: one LISTEN connection per web host — `streams_listen_scope` (issue #382).** ⚠️ **Default behavior change.** Previously every Puma worker lazily opened its own dedicated streams LISTEN connection on first SSE use, so a web host pinned one direct connection per worker. Under the new default (`streams_listen_scope = :master`) the `pgbus_streams` Puma plugin runs a **MasterHub** in the preforking master: ONE `Web::Streamer::Listener` on the refcounted union of every worker's stream channels, fanning wakes — **including ephemeral payloads** — out to workers over a Unix domain socket with length-prefixed frames (`Streamer::HubProtocol`). Workers connect lazily (nothing is inherited across fork) and the synchronous `ensure_listening` ack contract is preserved cross-process: a sub is registered before LISTEN executes and acked only after, so the no-lost-broadcast guarantee holds. Backpressure follows the streams rules: durable wakes are droppable at a per-worker cap (they self-heal via `read_after`), **ephemeral wakes are never dropped** — a worker that stops draining is evicted, which triggers its own fallback. **Fallback is per-worker listeners, not loss**: whenever the hub is absent or dies (no `preload_app!`, single-mode Puma, crash, eviction) each worker's `FailoverListener` swaps in a real per-worker `Listener` and re-LISTENs its recorded subscriptions — connection footprint balloons back to pre-#382 levels (census-visible) but no broadcast semantics change; the worker stays local until it recycles. Measured (local PG, n=50): the master→worker hop is noise-level free — single-broadcast SSE roundtrip p50 16.00ms via the hub vs 16.93ms per-worker. **`:master` effectively requires `preload_app!`** (the hub waits for the app's pgbus initializer; without it the deadline expires quietly and workers stay per-worker). **Rollback:** `config.streams_listen_scope = :process`. Refs #382, builds on the #381 patterns.
6
+
5
7
  - **Host-level shared LISTEN: `worker_notify_scope` — the supervisor now owns ONE direct LISTEN connection for the whole host (issue #381).** ⚠️ **Default behavior change.** Previously every worker fork and every consumer fork opened its own dedicated LISTEN connection (`NotifyListener`), so a host's direct-connection footprint scaled with fork count — on transaction-pool PgBouncer platforms those connections come out of the scarcest slice of `max_connections`, and a 5-capsule + 2-consumer host pinned 7. Under the new default (`config.worker_notify_scope = :supervisor`) the supervisor runs a single `NotifyHub`: one `NotifyListener` on the union of every capsule's and consumer's queue channels (wildcards via the shared resolver, consumer sets via the registry), fanning wakes out to forks over per-fork pipes (`W` wake / `H` healthy / `P` degraded bytes; a fork whose pipe reports degraded or reaches EOF falls back to fast polling exactly like a failed local listener). Footprint drops to **1 direct LISTEN connection per job host**, verified by integration test: routing is per-fork (an insert wakes only the forks reading that queue, wildcard capsules unconditionally), and `pg_terminate_backend` on the shared connection is survived — reconnect, re-LISTEN, wakes flow again. **Rollback:** `config.worker_notify_scope = :fork` restores the previous per-fork listeners byte-for-byte. Dedicated LISTEN connections are now census-tagged `application_name=pgbus-listen` so `pg_stat_activity` can count them. Refs #381.
6
8
  - **`pgbus doctor`: new "Connection budget" check (issue #381).** Prints how many direct LISTEN connections the current config pins — 1 per host under `:supervisor` scope, capsules + consumers under `:fork` (honoring `config.roles`), plus a "+1 per web-server process (streams)" clause — so operators can do pooler capacity math from the doctor output alone. Informational, always `:ok`. Refs #381.
7
9
  - **Benchmarks: `rake bench:notify_wake` and `rake bench:notify_chaos` (issue #381).** Wake-path latency (send → wake, p50/p95/p99, direct vs hub-mediated), empty-read cost, LISTEN connection census, and failure-mode measurements (killed LISTEN backend, wedged fork, FD churn, fan-out cost). Refs #381.
data/README.md CHANGED
@@ -1876,7 +1876,7 @@ A single preflight command that answers "is this environment healthy enough to r
1876
1876
  | Broadcast queue | — | Turbo broadcasts share the default queue in production, or `streams_broadcast_queue` is set but no worker capsule drains it |
1877
1877
  | Primary affinity | — | Job connection is on a read-only replica (`pg_is_in_recovery`) — a read/write-splitting pooler may be stalling jobs |
1878
1878
  | Dedicated connections | Streamer LISTEN and/or worker notify dedicated path cannot connect | — |
1879
- | Connection budget | — (informational: prints how many direct LISTEN connections the current config pins — 1 per host under `worker_notify_scope: :supervisor`, one per fork under `:fork`, plus 1 per web process when streams are enabled) | — |
1879
+ | Connection budget | — (informational: prints how many direct LISTEN connections the current config pins — 1 per host under `worker_notify_scope: :supervisor`, one per fork under `:fork`; streams add 1 per web host under `streams_listen_scope: :master` or 1 per web process under `:process`) | — |
1880
1880
 
1881
1881
  ```bash
1882
1882
  pgbus doctor # prints the report; exit 1 unless every check passed
data/Rakefile CHANGED
@@ -25,7 +25,7 @@ namespace :bench do
25
25
  # no-DB unit suite that bench:all runs in CI.
26
26
  db_benches = %w[connection_pool_bench integration_bench streams_bench streams_read_pool_bench
27
27
  execution_modes_bench pool_swap_bench pool_autoscale_bench job_burst_bench
28
- notify_wake_bench notify_chaos_bench].freeze
28
+ notify_wake_bench notify_chaos_bench streams_hub_bench].freeze
29
29
  # The unit suite is every *_bench.rb that doesn't need a database, derived
30
30
  # from the directory so a new unit bench is picked up automatically (kept in
31
31
  # sync with bench:one, which globs the same files).
@@ -95,6 +95,11 @@ namespace :bench do
95
95
  ruby "benchmarks/notify_chaos_bench.rb"
96
96
  end
97
97
 
98
+ desc "Run streams master-hub latency benchmark (#382 hop cost + census; requires PGBUS_DATABASE_URL)"
99
+ task :streams_hub do
100
+ ruby "benchmarks/streams_hub_bench.rb"
101
+ end
102
+
98
103
  desc "Run a single benchmark: rake bench:one[client_bench]"
99
104
  task :one, [:name] do |_t, args|
100
105
  name = args[:name] or abort "Usage: rake bench:one[serialization_bench|client_bench|...]"
@@ -270,6 +270,7 @@ module Pgbus
270
270
 
271
271
  @worker_notify_wakeup = nil
272
272
  @worker_notify_scope = :supervisor
273
+ @streams_listen_scope = :master
273
274
  @worker_notify_host = nil
274
275
  @worker_notify_port = nil
275
276
  @worker_notify_database_url = nil
@@ -632,6 +633,34 @@ module Pgbus
632
633
  @doctor_on_boot = coerced
633
634
  end
634
635
 
636
+ # Where the streams LISTEN connection lives (issue #382):
637
+ # :master (default) — ONE shared listener in the preforking web master
638
+ # (MasterHub); workers connect lazily over a Unix socket and fall back
639
+ # to a per-worker listener whenever the hub is absent or dies.
640
+ # :process — one listener per web process: the pre-0.13 behavior, and
641
+ # the automatic behavior on single-mode / non-preforking servers.
642
+ attr_reader :streams_listen_scope
643
+
644
+ VALID_STREAMS_LISTEN_SCOPES = %i[master process].freeze
645
+
646
+ def streams_listen_scope=(scope)
647
+ coerced = case scope
648
+ when Symbol then scope
649
+ when String then scope.to_sym
650
+ else
651
+ raise Pgbus::ConfigurationError,
652
+ "Invalid streams_listen_scope type: #{scope.class}. " \
653
+ "Must be :master (one shared LISTEN connection per web host) or :process (one per worker)"
654
+ end
655
+ unless VALID_STREAMS_LISTEN_SCOPES.include?(coerced)
656
+ raise Pgbus::ConfigurationError,
657
+ "Invalid streams_listen_scope: #{coerced.inspect}. " \
658
+ "Must be :master (one shared LISTEN connection per web host) or :process (one per worker)"
659
+ end
660
+
661
+ @streams_listen_scope = coerced
662
+ end
663
+
635
664
  VALID_WORKER_NOTIFY_SCOPES = %i[supervisor fork].freeze
636
665
 
637
666
  # Validated at assignment time like the other enum options. A String is
data/lib/pgbus/doctor.rb CHANGED
@@ -394,12 +394,23 @@ module Pgbus
394
394
  consumers: consumers, con_plural: consumers == 1 ? "" : "s",
395
395
  share: count == 1 && @config.worker_notify_scope == :supervisor ? " share it" : ""
396
396
  )
397
- detail += " + 1 per web-server process (streams)" if @config.streams_enabled
397
+ detail += streams_budget_clause if @config.streams_enabled
398
398
  Check.new(name: "Connection budget", status: :ok, detail: detail)
399
399
  rescue StandardError => e
400
400
  Check.new(name: "Connection budget", status: :warn, detail: "#{e.class}: #{e.message}")
401
401
  end
402
402
 
403
+ # Streams add their own LISTEN footprint on web hosts: one per host with
404
+ # the master hub (#382, the default — workers fall back per-worker only
405
+ # during a hub outage), one per web process under :process scope.
406
+ def streams_budget_clause
407
+ if @config.streams_listen_scope == :master
408
+ " + 1 per web host (streams master hub; per-worker fallback during a hub outage costs 1 per web process)"
409
+ else
410
+ " + 1 per web-server process (streams)"
411
+ end
412
+ end
413
+
403
414
  # Open one dedicated connection the way the runtime does, verify it
404
415
  # answers, close it. Returns nil on success, "label: error" on failure.
405
416
  def probe_dedicated_connection(label, opts)
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.0"
4
+ VERSION = "0.13.1"
5
5
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ module Streamer
6
+ # The worker-side seam between the two listening modes (issue #382):
7
+ # starts on the master hub (HubClient) and fails over — once, one-way —
8
+ # to a per-worker Listener when the hub transport dies (master gone,
9
+ # ack deadline, eviction). The Dispatcher/Instance consume the same
10
+ # ensure_listening/remove_listening/stop surface either way and never
11
+ # learn which mode is active.
12
+ #
13
+ # Fallback direction is settled on #382: per-worker listener, not
14
+ # poll-only — ephemeral broadcasts have no polling equivalent (their
15
+ # payload exists only in the NOTIFY), so an outage trades connections
16
+ # for unchanged semantics. Once fallen back, the worker stays local
17
+ # until it recycles; no flap-back.
18
+ #
19
+ # The subscription set is recorded here so failover can rebuild the
20
+ # exact LISTEN set on the fresh local connection before anything else
21
+ # relies on it. ensure_listening NEVER raises to the dispatcher: on a
22
+ # double failure (hub dead AND local build failing — e.g. DB down) it
23
+ # logs and returns nil, matching the Listener's own ack-timeout
24
+ # contract, which the dispatcher already tolerates.
25
+ class FailoverListener
26
+ def initialize(hub_client:, local_listener_factory:, logger: Pgbus.logger)
27
+ @hub_client = hub_client
28
+ @local_listener_factory = local_listener_factory
29
+ @logger = logger
30
+ # @state_mutex guards the cheap shared state (@subscriptions, @impl,
31
+ # @failed_over) and is only ever held for constant-time work — the
32
+ # dispatcher's ensure/remove path must never wait behind a failover
33
+ # build. @failover_mutex serializes the (blocking) build + replay:
34
+ # a fresh PG connect + N re-LISTEN acks can stall for seconds when
35
+ # the trigger IS a database problem (review on #384).
36
+ @state_mutex = Mutex.new
37
+ @failover_mutex = Mutex.new
38
+ @subscriptions = Set.new
39
+ @impl = hub_client
40
+ @failed_over = false
41
+ end
42
+
43
+ # Interface parity with Listener for Instance#start: the hub client
44
+ # connected at construction and the fallback starts itself on swap.
45
+ def start
46
+ self
47
+ end
48
+
49
+ def ensure_listening(queue)
50
+ @state_mutex.synchronize { @subscriptions.add(queue) }
51
+ current_impl.ensure_listening(queue)
52
+ rescue HubClient::HubUnavailableError
53
+ fail_over!
54
+ begin
55
+ current_impl.ensure_listening(queue)
56
+ rescue HubClient::HubUnavailableError
57
+ # fail_over! itself failed (factory raised) and @impl is still the
58
+ # dead client — reported there; honor the nil-on-timeout contract.
59
+ nil
60
+ end
61
+ end
62
+
63
+ def remove_listening(queue)
64
+ @state_mutex.synchronize { @subscriptions.delete(queue) }
65
+ current_impl.remove_listening(queue)
66
+ rescue HubClient::HubUnavailableError => e
67
+ @logger.debug do
68
+ "[Pgbus::Streamer::FailoverListener] remove_listening on a dead hub client " \
69
+ "(#{e.message}) — ignoring, unlisten GC is best-effort"
70
+ end
71
+ nil
72
+ end
73
+
74
+ # Idempotent, callable from the client's on_failure (reader thread)
75
+ # and from a synchronous ensure failure (dispatcher thread).
76
+ # @failover_mutex serializes concurrent callers — the second blocks
77
+ # until the first finishes and then no-ops, so a synchronous retry
78
+ # after fail_over! always lands on the swapped-in local listener.
79
+ # The blocking build + replay runs OUTSIDE @state_mutex so concurrent
80
+ # ensure/remove/stop calls never stall behind it.
81
+ def fail_over!
82
+ local = nil
83
+ @failover_mutex.synchronize do
84
+ return if @state_mutex.synchronize { @failed_over }
85
+
86
+ local = @local_listener_factory.call
87
+ @state_mutex.synchronize { @subscriptions.dup }.each { |q| local.ensure_listening(q) }
88
+ # Subscriptions recorded between the snapshot and this swap arrive
89
+ # via their own retried ensure_listening call on the new impl.
90
+ @state_mutex.synchronize do
91
+ @impl = local
92
+ @failed_over = true
93
+ end
94
+ # Ownership transferred to @impl — the rescue must not stop it.
95
+ local = nil
96
+ end
97
+ rescue StandardError => e
98
+ # A listener the factory STARTED but that never swapped in (the
99
+ # replay raised) would otherwise leak its thread and LISTEN
100
+ # connection alongside the dead hub client.
101
+ begin
102
+ local&.stop
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ # Hub dead AND the local listener can't be built (DB down, config
107
+ # broken). Mark failed-over so callers stop rebuilding; @impl stays
108
+ # on the dead client — every ensure_listening resolves nil and the
109
+ # dispatcher rides its existing timeout tolerance until the worker
110
+ # recycles.
111
+ @state_mutex.synchronize { @failed_over = true }
112
+ @logger.error do
113
+ "[Pgbus::Streamer::FailoverListener] fallback listener failed to build " \
114
+ "(#{e.class}: #{e.message}) — streams degraded until this worker recycles"
115
+ end
116
+ end
117
+
118
+ def stop
119
+ current_impl.stop
120
+ end
121
+
122
+ private
123
+
124
+ def current_impl
125
+ @state_mutex.synchronize { @impl }
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ # Worker-side client for the MasterHub (issue #382). Presents the same
9
+ # surface the Dispatcher consumes from a Listener — synchronous
10
+ # `ensure_listening` (the no-lost-broadcast ack contract, now crossing
11
+ # the process boundary), async `remove_listening` — while wakes arrive
12
+ # as HubProtocol frames and are re-materialized into the worker's
13
+ # dispatch queue as WakeMessages.
14
+ #
15
+ # Failure model: this class never retries. Connect refusal, an ack
16
+ # deadline, or transport EOF (master died / eviction) marks the client
17
+ # dead, fails every pending sub, and fires +on_failure+ exactly once —
18
+ # the FailoverListener's cue to swap in a per-worker Listener. One-way:
19
+ # once a worker has fallen back it stays local until it recycles
20
+ # (settled on #382 — no flap-back complexity).
21
+ class HubClient
22
+ class HubUnavailableError < StandardError; end
23
+
24
+ # Optimistic before the first status broadcast, mirroring WakePipe /
25
+ # NotifyListener: a just-connected worker isn't treated as degraded
26
+ # before the hub has said anything.
27
+ def initialize(socket_path:, dispatch_queue:, ack_timeout: 2.0,
28
+ on_failure: nil, logger: Pgbus.logger)
29
+ @socket_path = socket_path
30
+ @dispatch_queue = dispatch_queue
31
+ @ack_timeout = ack_timeout
32
+ @on_failure = on_failure
33
+ @logger = logger
34
+ @write_mutex = Mutex.new
35
+ @ack_mutex = Mutex.new
36
+ @pending_acks = Hash.new { |h, k| h[k] = [] }
37
+ @hub_healthy = true
38
+ @dead = false
39
+ @stopping = false
40
+ @sock = nil
41
+ @reader = nil
42
+ end
43
+
44
+ def connect
45
+ @sock = UNIXSocket.new(@socket_path)
46
+ @reader = Thread.new { reader_loop }
47
+ self
48
+ rescue SystemCallError, IOError, ArgumentError, ThreadError => e
49
+ # ArgumentError: a socket path over the platform sun_path limit;
50
+ # IOError: a path that exists but is not a socket; ThreadError: the
51
+ # reader thread could not spawn. All must fall back exactly like a
52
+ # refused connect, never abort worker boot — and never leak the
53
+ # half-opened socket.
54
+ close_quietly(@sock)
55
+ @sock = nil
56
+ raise HubUnavailableError, "cannot reach master hub at #{@socket_path}: #{e.class}: #{e.message}"
57
+ end
58
+
59
+ def hub_healthy?
60
+ @hub_healthy
61
+ end
62
+
63
+ def dead?
64
+ @dead
65
+ end
66
+
67
+ # Synchronous, bounded: returns :done once the master has confirmed
68
+ # LISTEN is active for +queue+. Raises HubUnavailableError on a dead
69
+ # transport or an expired ack deadline (which also kills the
70
+ # transport — a hub that can't ack in time can't be trusted with the
71
+ # no-lost-broadcast contract either).
72
+ def ensure_listening(queue)
73
+ raise HubUnavailableError, "master hub transport is dead" if @dead
74
+
75
+ waiter = Queue.new
76
+ @ack_mutex.synchronize { @pending_acks[queue] << waiter }
77
+ write_frame({ "t" => "sub", "q" => queue })
78
+
79
+ result = waiter.pop(timeout: @ack_timeout)
80
+ if result.nil?
81
+ discard_waiter(queue, waiter)
82
+ mark_dead("sub ack for #{queue} not received within #{@ack_timeout}s")
83
+ raise HubUnavailableError, "master hub ack timeout for #{queue}"
84
+ end
85
+ raise HubUnavailableError, "master hub died while awaiting ack for #{queue}" if result == :dead
86
+
87
+ :done
88
+ end
89
+
90
+ # Lazy GC, fire-and-forget — no correctness path waits on UNLISTEN
91
+ # (mirrors Listener#remove_listening). A dead transport is a no-op:
92
+ # the master's EOF cleanup already released this worker's refs.
93
+ def remove_listening(queue)
94
+ return if @dead
95
+
96
+ write_frame({ "t" => "unsub", "q" => queue })
97
+ rescue HubUnavailableError
98
+ nil
99
+ end
100
+
101
+ def stop
102
+ @stopping = true
103
+ close_quietly(@sock)
104
+ @reader&.join(2)
105
+ @reader = nil
106
+ self
107
+ end
108
+
109
+ private
110
+
111
+ def reader_loop
112
+ loop do
113
+ frame = HubProtocol.read_frame(@sock)
114
+ break if frame.nil?
115
+
116
+ handle_frame(frame)
117
+ end
118
+ mark_dead("master hub closed the transport") unless @stopping
119
+ rescue HubProtocol::ProtocolError => e
120
+ mark_dead("master hub protocol error: #{e.message}") unless @stopping
121
+ rescue IOError, Errno::EBADF, Errno::ECONNRESET
122
+ mark_dead("master hub transport error") unless @stopping
123
+ rescue StandardError => e
124
+ # The reader thread is the ONLY detector of hub death — an
125
+ # unexpected error must not let it exit with the client still
126
+ # reporting healthy, or the worker goes silently deaf.
127
+ mark_dead("master hub reader crashed: #{e.class}: #{e.message}") unless @stopping
128
+ end
129
+
130
+ def handle_frame(frame)
131
+ case frame["t"]
132
+ when "wake"
133
+ @dispatch_queue << Listener::WakeMessage.new(queue_name: frame["q"], payload: frame["p"])
134
+ when "ack"
135
+ @ack_mutex.synchronize { @pending_acks[frame["q"]].shift }&.push(:ack)
136
+ when "status"
137
+ @hub_healthy = frame["healthy"]
138
+ else
139
+ @logger.warn { "[Pgbus::Streamer::HubClient] unknown frame from master: #{frame["t"].inspect}" }
140
+ end
141
+ end
142
+
143
+ # Frames must never interleave — all writes go through one mutex
144
+ # (writers: dispatcher thread via ensure/remove; no writer thread
145
+ # needed client-side, sub/unsub frames are tiny). Bounded: a master
146
+ # that stopped draining its input would otherwise block this write
147
+ # forever, and the ack deadline only starts ticking AFTER the write
148
+ # returns — so a stalled write is itself a failover trigger.
149
+ def write_frame(message)
150
+ data = HubProtocol.encode(message)
151
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + @ack_timeout
152
+ @write_mutex.synchronize do
153
+ until data.empty?
154
+ begin
155
+ written = @sock.write_nonblock(data)
156
+ data = data.byteslice(written..)
157
+ rescue IO::WaitWritable
158
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
159
+ raise Errno::ETIMEDOUT, "write stalled" if remaining <= 0 || !@sock.wait_writable(remaining)
160
+ end
161
+ end
162
+ end
163
+ rescue IOError, Errno::EPIPE, Errno::EBADF, Errno::ECONNRESET, Errno::ETIMEDOUT => e
164
+ mark_dead("write to master hub failed: #{e.class}")
165
+ raise HubUnavailableError, "master hub transport is dead"
166
+ end
167
+
168
+ # Idempotent: first caller flips @dead, fails every waiter, fires
169
+ # on_failure once. Reachable from the reader (EOF/protocol error) and
170
+ # from ack timeouts / failed writes on caller threads.
171
+ def mark_dead(reason)
172
+ waiters = @ack_mutex.synchronize do
173
+ return if @dead
174
+
175
+ @dead = true
176
+ drained = @pending_acks.values.flatten
177
+ @pending_acks.clear
178
+ drained
179
+ end
180
+ @hub_healthy = false
181
+ waiters.each { |w| w << :dead }
182
+ close_quietly(@sock)
183
+ @logger.warn { "[Pgbus::Streamer::HubClient] #{reason} — falling back to a per-worker listener" }
184
+ @on_failure&.call
185
+ end
186
+
187
+ def discard_waiter(queue, waiter)
188
+ @ack_mutex.synchronize { @pending_acks[queue].delete(waiter) }
189
+ end
190
+
191
+ def close_quietly(io)
192
+ io.close if io && !io.closed?
193
+ rescue IOError, Errno::EBADF
194
+ nil
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module Web
7
+ module Streamer
8
+ # Framing for the master-hub Unix socket (issue #382): 4-byte big-endian
9
+ # payload length + UTF-8 JSON. Unlike the job-side wake pipes (1-byte,
10
+ # lossy-by-design — Process::WakePipe), stream frames can carry an
11
+ # ephemeral broadcast's ONLY copy of its HTML, so the transport is
12
+ # length-prefixed and lossless; drop decisions are made per-message by
13
+ # the MasterHub, never by the wire format.
14
+ #
15
+ # Message shapes (JSON objects; "t" is the discriminator):
16
+ # worker → master: {t:"sub", q:} subscribe, synchronous — master acks
17
+ # {t:"unsub", q:} unsubscribe, fire-and-forget
18
+ # master → worker: {t:"ack", q:} sub acknowledged (LISTEN active)
19
+ # {t:"wake", q:, p: <String|nil>} durable (p:nil) or ephemeral wake
20
+ # {t:"status", healthy: <bool>} listener health broadcast
21
+ #
22
+ # Reads are blocking (each side owns a dedicated reader thread); a short
23
+ # read means the peer died mid-frame and is reported as EOF (nil), never
24
+ # as a truncated message.
25
+ module HubProtocol
26
+ class ProtocolError < StandardError; end
27
+
28
+ HEADER_BYTES = 4
29
+ # Generous ceiling for ephemeral HTML payloads; a frame announcing
30
+ # more than this is a corrupt stream or a runaway producer — sever
31
+ # rather than allocate.
32
+ MAX_FRAME_BYTES = 4 * 1024 * 1024
33
+
34
+ module_function
35
+
36
+ def encode(message)
37
+ json = JSON.generate(message)
38
+ bytes = json.b
39
+ raise ProtocolError, "frame too large: #{bytes.bytesize} bytes (max #{MAX_FRAME_BYTES})" if
40
+ bytes.bytesize > MAX_FRAME_BYTES
41
+
42
+ [bytes.bytesize].pack("N") + bytes
43
+ end
44
+
45
+ # Returns the decoded Hash, or nil on EOF — clean close, peer death
46
+ # mid-frame, OR a connection reset: an abrupt close can surface as
47
+ # ECONNRESET instead of orderly EOF depending on unread data and
48
+ # platform (Ruby 4.0 reports it deterministically where 3.x saw EOF),
49
+ # and both mean the same thing here: the peer is gone. Raises
50
+ # ProtocolError on an oversized announcement or malformed JSON.
51
+ def read_frame(io)
52
+ header = read_exactly(io, HEADER_BYTES)
53
+ return nil unless header
54
+
55
+ length = header.unpack1("N")
56
+ raise ProtocolError, "frame too large: #{length} bytes (max #{MAX_FRAME_BYTES})" if length > MAX_FRAME_BYTES
57
+
58
+ body = read_exactly(io, length)
59
+ return nil unless body
60
+
61
+ body = body.force_encoding(Encoding::UTF_8)
62
+ raise ProtocolError, "malformed frame: invalid UTF-8" unless body.valid_encoding?
63
+
64
+ decoded = JSON.parse(body)
65
+ raise ProtocolError, "malformed frame: expected a JSON object, got #{decoded.class}" unless decoded.is_a?(Hash)
66
+
67
+ decoded
68
+ rescue JSON::ParserError => e
69
+ raise ProtocolError, "malformed frame: #{e.message}"
70
+ rescue Errno::ECONNRESET
71
+ nil
72
+ end
73
+
74
+ # Blocking read of exactly +count+ bytes; nil on EOF (including EOF
75
+ # partway through — IO#read returns the short tail once, then nil).
76
+ def read_exactly(io, count)
77
+ data = io.read(count)
78
+ return nil if data.nil? || data.bytesize < count
79
+
80
+ data
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -39,7 +39,6 @@ module Pgbus
39
39
  @dispatch_queue = dispatch_queue || Queue.new
40
40
 
41
41
  @stream_counter = StreamCounter.new
42
- @pg_connection = pg_connection || build_pg_connection
43
42
  # Self-tuning streams-pool autoscaler (issue #323). Opt-in; nil unless
44
43
  # enabled AND on the dedicated connection path (the shared-AR streams
45
44
  # pool aliases the non-thread-safe job pool and resize is a no-op there).
@@ -50,25 +49,7 @@ module Pgbus
50
49
  if @config.streams_pool_autoscale && !@client.shared_connection?
51
50
  Pgbus::Streams::PoolAutoscaler.new(client: @client, config: @config, logger: @logger)
52
51
  end
53
- @listener = Listener.new(
54
- pg_connection: @pg_connection,
55
- dispatch_queue: @dispatch_queue,
56
- health_check_ms: @config.streams_listen_health_check_ms,
57
- # Opt-in dispatch-queue backpressure (issue #315 item 3). 0 =
58
- # unbounded (default). The queue itself stays an unbounded
59
- # Queue.new so the request-thread Connect push and the dispatcher's
60
- # own prune_dead self-post never block.
61
- dispatch_queue_limit: @config.streams_dispatch_queue_limit,
62
- maintenance: build_autoscale_maintenance,
63
- logger: @logger,
64
- # On reconnect the Listener rebuilds its OWN connection via this
65
- # factory (fresh connect re-resolves DNS, converges on the promoted
66
- # primary after a failover) instead of resetting a possibly-dead
67
- # socket. Always provided — even when an initial pg_connection: is
68
- # injected, the reconnect path builds a fresh raw connection. A test
69
- # can inject its own factory to avoid touching real configuration.
70
- connection_factory: connection_factory || -> { build_raw_pg_connection }
71
- )
52
+ @listener = build_listener(pg_connection, connection_factory)
72
53
  # Off-thread durable fanout writer (issue #321). Built only when
73
54
  # streams_writer_threads > 0; nil means fanout writes stay inline on
74
55
  # the dispatcher thread (the default, pre-#321 behavior). The pump
@@ -169,6 +150,74 @@ module Pgbus
169
150
 
170
151
  private
171
152
 
153
+ # Selects the wake source by streams_listen_scope (issue #382).
154
+ # :master with a reachable hub socket → FailoverListener over a
155
+ # HubClient (NO per-worker LISTEN connection is opened). Anything
156
+ # else — scope :process, no socket exported (single mode,
157
+ # non-preforking server, hub failed to start), or a refused connect —
158
+ # keeps today's per-worker Listener.
159
+ def build_listener(pg_connection, connection_factory)
160
+ hub = build_hub_listener(connection_factory)
161
+ return hub if hub
162
+
163
+ build_local_listener(pg_connection || build_pg_connection, connection_factory)
164
+ end
165
+
166
+ def build_hub_listener(connection_factory)
167
+ return nil unless @config.streams_listen_scope == :master
168
+
169
+ socket_path = ENV.fetch("PGBUS_STREAMS_HUB_SOCKET", nil)
170
+ return nil if socket_path.nil? || socket_path.empty?
171
+
172
+ # The worker's ack deadline must exceed the master's own internal
173
+ # ensure_listening budget (its listener's health-check cycle + 1s).
174
+ failover = nil
175
+ client = HubClient.new(
176
+ socket_path: socket_path,
177
+ dispatch_queue: @dispatch_queue,
178
+ ack_timeout: (@config.streams_listen_health_check_ms / 1000.0) + 2.0,
179
+ # failover is assigned right below; a transport death in the gap
180
+ # is caught by the FailoverListener's synchronous ensure path.
181
+ on_failure: -> { failover&.fail_over! },
182
+ logger: @logger
183
+ )
184
+ client.connect
185
+ failover = FailoverListener.new(
186
+ hub_client: client,
187
+ local_listener_factory: lambda do
188
+ build_local_listener(build_pg_connection, connection_factory).tap(&:start)
189
+ end,
190
+ logger: @logger
191
+ )
192
+ rescue HubClient::HubUnavailableError => e
193
+ @logger.info do
194
+ "[Pgbus::Streamer] master hub not reachable (#{e.message}) — using a per-worker listener"
195
+ end
196
+ nil
197
+ end
198
+
199
+ def build_local_listener(pg_connection, connection_factory)
200
+ Listener.new(
201
+ pg_connection: pg_connection,
202
+ dispatch_queue: @dispatch_queue,
203
+ health_check_ms: @config.streams_listen_health_check_ms,
204
+ # Opt-in dispatch-queue backpressure (issue #315 item 3). 0 =
205
+ # unbounded (default). The queue itself stays an unbounded
206
+ # Queue.new so the request-thread Connect push and the dispatcher's
207
+ # own prune_dead self-post never block.
208
+ dispatch_queue_limit: @config.streams_dispatch_queue_limit,
209
+ maintenance: build_autoscale_maintenance,
210
+ logger: @logger,
211
+ # On reconnect the Listener rebuilds its OWN connection via this
212
+ # factory (fresh connect re-resolves DNS, converges on the promoted
213
+ # primary after a failover) instead of resetting a possibly-dead
214
+ # socket. Always provided — even when an initial pg_connection: is
215
+ # injected, the reconnect path builds a fresh raw connection. A test
216
+ # can inject its own factory to avoid touching real configuration.
217
+ connection_factory: connection_factory || -> { build_raw_pg_connection }
218
+ )
219
+ end
220
+
172
221
  def safely
173
222
  yield
174
223
  rescue StandardError => e