feat(streams): master-owned shared LISTEN — one streams connection per web host (#382) - #384
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughStreams now support a master-owned PostgreSQL LISTEN connection. Workers receive wake frames over Unix sockets. The change adds listener scopes, acknowledgements, backpressure, health reporting, failover, Puma integration, tests, benchmarks, and documentation. ChangesStreams master hub
Estimated code review effort: 5 (Critical) | ~90+ minutes Sequence Diagram(s)sequenceDiagram
participant PumaPlugin
participant MasterHubBoot
participant MasterHub
participant HubClient
participant StreamerInstance
PumaPlugin->>MasterHubBoot: start in cluster mode
MasterHubBoot->>MasterHub: start after configuration readiness
MasterHubBoot-->>StreamerInstance: export hub socket path
StreamerInstance->>HubClient: connect and subscribe
HubClient->>MasterHub: send subscription
MasterHub-->>HubClient: acknowledge after LISTEN
MasterHub-->>HubClient: forward wake frame
HubClient-->>StreamerInstance: dispatch stream wake
HubClient->>StreamerInstance: trigger local-listener fallback
Possibly related issues
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 17
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@benchmarks/streams_hub_bench.rb`:
- Around line 88-94: Update the sampling loop around wait_for_events to capture
its returned event count and validate it before appending to samples. Require
the expected count of i + 2, and abort or report failure consistently with the
warmup validation if delivery is incomplete; only record timing samples after
successful delivery.
In `@lib/pgbus/web/streamer/failover_listener.rb`:
- Around line 66-84: Update fail_over! so `@failed_over` is set under `@mutex`, then
build the local listener and replay subscriptions outside the lock; reacquire
`@mutex` only to swap `@impl` to the completed listener. Preserve the existing
one-way no-retry behavior by keeping `@failed_over` true and leaving `@impl`
unchanged when the factory or replay raises, while retaining the current error
logging.
In `@lib/pgbus/web/streamer/hub_client.rb`:
- Around line 104-116: Update reader_loop to rescue unexpected StandardError
exceptions after the existing specific rescues and call mark_dead with an
appropriate error message unless `@stopping`, ensuring errors from handle_frame or
logging cannot terminate the reader thread silently. Preserve the existing
protocol and transport-specific handling.
- Around line 44-50: Widen the rescue in HubClient#connect to convert socket
setup failures such as ArgumentError and IOError, in addition to
SystemCallError, into HubUnavailableError with the existing contextual message,
allowing Instance#build_hub_listener to fall back to a per-worker listener.
- Around line 134-139: Update HubClient#write_frame to wait for socket
writability with a bounded timeout before writing, using the client’s configured
`@ack_timeout` or established write-timeout mechanism. If the writable wait
expires, mark the hub dead and raise HubUnavailableError so failover proceeds;
preserve the existing transport-error handling for write failures.
In `@lib/pgbus/web/streamer/master_hub_boot.rb`:
- Around line 50-57: Synchronize shared `@hub` and `@running` state in
MasterHubBoot#start, `#stop`, and `#wait_and_start` with a Mutex, ensuring hub
creation, assignment, startup, and teardown cannot race. Make `#stop` wait for the
background startup path to finish before clearing or stopping `@hub`, so it
returns only after the hub and its thread are fully stopped.
In `@lib/pgbus/web/streamer/master_hub.rb`:
- Around line 85-86: The hub socket currently inherits an unsafe mode; update
master_hub.rb at lines 85-86 to call File.chmod(0o600, `@socket_path`) immediately
after UNIXServer.new(`@socket_path`). Add a master_hub_spec.rb example at lines
262-280 asserting File.stat(socket_path).mode & 0o777 equals 0o600 after
hub.start.
- Around line 93-105: Synchronize the shared running-state in MasterHub#stop and
the corresponding status/accept loop checks using `@table_mutex` or
Concurrent::AtomicBoolean. Make the check-and-transition to stopped atomic so
concurrent stop calls allow only one caller to close the server, signal workers,
join threads, stop the listener, and remove the socket; subsequent calls should
return self without repeating cleanup.
- Around line 191-200: Ensure every long-lived MasterHub loop logs failures with
Pgbus.logger and continues running. In
lib/pgbus/web/streamer/master_hub.rb:191-200, move rescue handling inside
fanout_loop; at 125-132, rescue per iteration around `@server.accept` and
register_worker while `@running`, retaining the outer stop-path rescue; at
253-267, add StandardError handling after socket rescues, log the worker id, and
evict_worker; at 269-284, rescue each health/status iteration and replace sleep
`@status_interval` with a stop-signal wait so stop wakes the thread. Each site
requires direct changes.
- Around line 202-209: Update deliver and release_queue_refs to read `@queue_refs`
without invoking its default proc, so missing queue names do not create empty
Set entries. Preserve the existing target filtering and release behavior for
queues that already have references, while avoiding release/listener actions for
queues absent from the table.
In `@spec/integration/streams/master_hub_e2e_spec.rb`:
- Around line 128-135: Update the ensure block in the integration example to
call hub.stop, alongside the existing client, worker, and harness cleanup.
Ensure the hub is stopped even when expectations fail before the mid-test
hub.stop call, while preserving the existing cleanup operations.
- Around line 20-34: Update the setup and teardown hooks in the master hub
integration spec to save the original values of streams_listen_health_check_ms,
streams_heartbeat_interval, and streams_write_deadline_ms before overriding them
in before(:all), then restore each saved value in after(:all) alongside
listen_notify and streams_signed_name_secret.
In `@spec/integration/streams/master_hub_spec.rb`:
- Around line 155-157: Update the example teardown around MasterHub to call
hub.stop from the ensure block, ensuring cleanup runs even when earlier
expectations or wait_until raise. Track whether the happy-path stop already
occurred and only perform the ensure stop when needed, while preserving safe
cleanup if the hub was not stopped.
In `@spec/pgbus/web/streamer/hub_client_spec.rb`:
- Around line 102-115: Replace the fixed sleep waits in the hub client
status-tracking examples and the corresponding examples around the reader-thread
assertions with bounded polling that exits as soon as the expected state is
observed. Preserve the fast path and enforce a timeout so slow CI runs fail
clearly; for the negative failures assertion, use a short fixed wait or assert
after client.stop joins the reader thread.
In `@spec/pgbus/web/streamer/instance_spec.rb`:
- Around line 585-593: Update the “falls back to a per-worker Listener when the
socket path is exported but dead” example to set streams_listen_scope explicitly
to :master, matching the sibling examples and ensuring the test reaches the
dead-socket connection fallback in build_hub_listener.
In `@spec/pgbus/web/streamer/master_hub_spec.rb`:
- Around line 262-280: Add an example in the `#stop` specs that starts `hub`,
inspects `File.stat(socket_path).mode & 0o777`, and asserts the socket mode is
`0o600`, preserving the owner-only permission contract.
- Around line 171-174: Replace the compound matcher around
worker_b.wait_readable with a bounded drain of worker_b’s available frames,
decoding each via HubProtocol.read_frame and asserting directly that none has
“t” equal to “wake”. Keep the bounded timeout and avoid blocking reads inside
the matcher so any leaked wake frame is detected regardless of arrival order.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b995f4b4-76b7-4101-9a41-6b2ea41e8afb
📒 Files selected for processing (27)
CHANGELOG.mdREADME.mdRakefilebenchmarks/streams_hub_bench.rbdocs/app/models/config_reference.rbdocs/app/views/docs/pages/performance_tuning.rbdocs/performance.mdlib/pgbus/configuration.rblib/pgbus/doctor.rblib/pgbus/web/streamer/failover_listener.rblib/pgbus/web/streamer/hub_client.rblib/pgbus/web/streamer/hub_protocol.rblib/pgbus/web/streamer/instance.rblib/pgbus/web/streamer/listener.rblib/pgbus/web/streamer/master_hub.rblib/pgbus/web/streamer/master_hub_boot.rblib/puma/plugin/pgbus_streams.rbspec/integration/streams/master_hub_e2e_spec.rbspec/integration/streams/master_hub_spec.rbspec/pgbus/configuration_spec.rbspec/pgbus/doctor_spec.rbspec/pgbus/web/streamer/failover_listener_spec.rbspec/pgbus/web/streamer/hub_client_spec.rbspec/pgbus/web/streamer/hub_protocol_spec.rbspec/pgbus/web/streamer/instance_spec.rbspec/pgbus/web/streamer/master_hub_boot_spec.rbspec/pgbus/web/streamer/master_hub_spec.rb
- master_hub: socket chmod 0600 (payload-carrying, no peer auth — mode IS the access control); @queue_refs is a plain Hash created only at sub time (default-proc reads leaked one empty Set per wake on unsubscribed channels — unbounded for per-record streams); @running synchronized + idempotent stop; per-iteration rescues in all four loops (one bad message/connection can no longer silently kill accept/fanout/writer/ status); status thread waits on a stop signal instead of sleeping - hub_client: connect rescue widened (ArgumentError sun_path overflow, IOError) so worker boot never aborts; reader-thread catch-all marks the transport dead (silent reader death = permanently deaf worker, no failover); write_frame bounded by the ack deadline (a non-draining master can no longer block the dispatcher before the ack timer starts) - failover_listener: blocking build + replay moved outside the state mutex — the dispatcher's ensure/remove path never stalls behind a failover whose trigger is a stalling database; a second concurrent fail_over! waits and no-ops - master_hub_boot: @hub/@running under a mutex with register-or-late-stop, so a hub whose build outlives stop's join budget is still torn down - bench: per-sample delivery validation (a dropped wake can no longer feed a ~10s timeout into the reported percentiles) - specs: chmod example, drain-based negative routing assertion, bounded polls instead of fixed sleeps, e2e config restore + hub.stop in ensure, explicit scope in the dead-socket example
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
lib/pgbus/web/streamer/master_hub_boot.rb (1)
85-107: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRescue paths discard a started listener without stopping it. Both sites build an object that already owns a started PostgreSQL LISTEN connection and a thread, then drop the reference in a
rescue StandardErrorwithout callingstop. The connection and thread stay alive for the life of the process, which is the resource the:masterscope exists to save.
lib/pgbus/web/streamer/master_hub_boot.rb#L85-L107: holdhubin a method-scoped local and callhub&.stopin the rescue, becauseMasterHub#startstarts the sharedListenerbefore it binds the Unix socket.lib/pgbus/web/streamer/failover_listener.rb#L77-L101: holdlocalin a method-scoped local, track whether the swap completed, and calllocal.stopin the rescue when the swap never happened.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/pgbus/web/streamer/master_hub_boot.rb` around lines 85 - 107, Rescue paths leak started listeners by discarding them without stopping. In lib/pgbus/web/streamer/master_hub_boot.rb:85-107, keep hub method-scoped and call hub&.stop in the rescue. In lib/pgbus/web/streamer/failover_listener.rb:77-101, keep local method-scoped, track whether the swap completed, and call local.stop in rescue when it did not.benchmarks/streams_hub_bench.rb (1)
1-51: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winValidate
HUB_BENCH_SAMPLESbefore running the benchmark.
Integer(ENV.fetch("HUB_BENCH_SAMPLES", "50"))accepts zero and negative values. These values makeSAMPLES.timesproduce no samples, sosorted.lastisnil. RequireSAMPLES.positive?and abort with a clear error when the value is not positive.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@benchmarks/streams_hub_bench.rb` around lines 1 - 51, Validate HUB_BENCH_SAMPLES immediately after parsing it into SAMPLES, requiring SAMPLES.positive? and aborting with a clear, actionable error when it is zero or negative; preserve the existing default and benchmark flow for positive values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/pgbus/web/streamer/master_hub.rb`:
- Around line 91-95: Update the socket setup around UNIXServer.new in the master
hub to temporarily set a restrictive process umask while binding, restoring the
previous umask afterward with guaranteed cleanup; retain File.chmod as the
second permission guarantee. Ensure the umask scope covers socket creation and
does not leave the process umask changed.
---
Outside diff comments:
In `@benchmarks/streams_hub_bench.rb`:
- Around line 1-51: Validate HUB_BENCH_SAMPLES immediately after parsing it into
SAMPLES, requiring SAMPLES.positive? and aborting with a clear, actionable error
when it is zero or negative; preserve the existing default and benchmark flow
for positive values.
In `@lib/pgbus/web/streamer/master_hub_boot.rb`:
- Around line 85-107: Rescue paths leak started listeners by discarding them
without stopping. In lib/pgbus/web/streamer/master_hub_boot.rb:85-107, keep hub
method-scoped and call hub&.stop in the rescue. In
lib/pgbus/web/streamer/failover_listener.rb:77-101, keep local method-scoped,
track whether the swap completed, and call local.stop in rescue when it did not.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: cd650460-2d6c-4d44-9680-67271b028f3c
📒 Files selected for processing (10)
benchmarks/streams_hub_bench.rblib/pgbus/web/streamer/failover_listener.rblib/pgbus/web/streamer/hub_client.rblib/pgbus/web/streamer/master_hub.rblib/pgbus/web/streamer/master_hub_boot.rbspec/integration/streams/master_hub_e2e_spec.rbspec/integration/streams/master_hub_spec.rbspec/pgbus/web/streamer/hub_client_spec.rbspec/pgbus/web/streamer/instance_spec.rbspec/pgbus/web/streamer/master_hub_spec.rb
…ter hub (#382 step 1) Lossless framed transport (unlike the deliberately lossy 1-byte job-side wake pipes): 4-byte big-endian length + UTF-8 JSON, blocking reads with short-read-as-EOF semantics, oversize and malformed-frame guards.
…tep 2) Owns a single Web::Streamer::Listener on the refcounted union of every worker's channels; workers connect lazily over a Unix socket and receive HubProtocol frames. Register-before-LISTEN + ack-after-ensure preserves the no-lost-wake contract cross-process (every sub round-trips the listener's idempotent ack; only UNLISTEN is refcounted). Backpressure: per-worker outbox + writer thread, durable wakes droppable at a cap, ephemeral never dropped, hard-cap eviction severs a non-draining worker so it self-degrades to its own listener. Listener gains alive?/connected? health readers for the status broadcasts.
…p 3) Listener-shaped surface (sync ensure_listening ack contract preserved cross-process, async remove_listening); wakes re-materialize into the worker's dispatch queue as WakeMessages. Never retries: connect refusal, ack deadline, or EOF marks the transport dead, fails pending subs, and fires on_failure exactly once — the FailoverListener's swap cue.
…eam (#382 step 4) Records the subscription set; on hub transport death (async on_failure or a synchronous ensure failure) builds the local Listener once, re-LISTENs the recorded set, and swaps. Never raises to the dispatcher: a double failure (hub dead + local build failing) degrades to the Listener's existing nil-on-timeout contract until the worker recycles.
…382 step 6) :master + reachable hub socket (PGBUS_STREAMS_HUB_SOCKET) → FailoverListener over a HubClient with NO per-worker LISTEN connection opened; scope :process, an absent/dead socket (single mode, hub failed to start), or a refused connect keeps today's per-worker Listener. Streamer Listener's channel constants are now single-sourced from NotifyListener.
MasterHubBoot exports the socket path pre-fork (workers inherit ENV) and defers the actual hub start behind a config-readiness poller — with preload_app! the initializer lands before the first fork; without it the deadline expires quietly and workers keep per-worker listeners (:master effectively requires preload_app!, documented). Cluster mode only; every failure path degrades to no-socket fallback.
…ub acceptance (#382 steps 8-10) Doctor's Connection budget prints '1 per web host (streams master hub)' under :master, per-process under :process. Integration: real PG proves one census-tagged connection serving multiple workers, ephemeral payload fidelity, LISTEN-backend-kill recovery, and master-death failover with wake continuity. E2E: two full streamer Instances (FailoverListener → HubClient → Dispatcher → hijacked SSE sockets) deliver through ONE shared connection, then keep delivering after the hub dies — census 1 → 2, the accepted fallback balloon.
Same single-broadcast SSE roundtrip under :process vs :master. Measured (local PG, n=50, durable mode): :process p50=16.93ms p95=26.67ms; :master p50=16.00ms p95=19.19ms — the socket hop is noise-level free. Durable mode + a warmup probe because the default :ephemeral mode races subscription setup and a lost first event wedges cumulative waits.
…bers, tuning page (#382 step 12)
An abrupt peer close can surface as Errno::ECONNRESET instead of orderly EOF depending on unread data and platform — Ruby 4.0 reports it deterministically where 3.x saw EOF. The protocol's contract is 'peer gone = nil', so the mapping belongs in read_frame; both consumers already rescued it defensively, the spec's raw read did not.
- master_hub: socket chmod 0600 (payload-carrying, no peer auth — mode IS the access control); @queue_refs is a plain Hash created only at sub time (default-proc reads leaked one empty Set per wake on unsubscribed channels — unbounded for per-record streams); @running synchronized + idempotent stop; per-iteration rescues in all four loops (one bad message/connection can no longer silently kill accept/fanout/writer/ status); status thread waits on a stop signal instead of sleeping - hub_client: connect rescue widened (ArgumentError sun_path overflow, IOError) so worker boot never aborts; reader-thread catch-all marks the transport dead (silent reader death = permanently deaf worker, no failover); write_frame bounded by the ack deadline (a non-draining master can no longer block the dispatcher before the ack timer starts) - failover_listener: blocking build + replay moved outside the state mutex — the dispatcher's ensure/remove path never stalls behind a failover whose trigger is a stalling database; a second concurrent fail_over! waits and no-ops - master_hub_boot: @hub/@running under a mutex with register-or-late-stop, so a hub whose build outlives stop's join budget is still torn down - bench: per-sample delivery validation (a dropped wake can no longer feed a ~10s timeout into the reported percentiles) - specs: chmod example, drain-based negative routing assertion, bounded polls instead of fixed sleeps, e2e config restore + hub.stop in ensure, explicit scope in the dead-socket example
bae2e22 to
ea7a87e
Compare
…cket umask(0o177) around the UNIXServer bind so the socket never exists world-reachable, even for the microseconds before chmod; the chmod stays as the second guarantee. Process-wide umask is acceptable: one bind, at hub start, restored in ensure.
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/pgbus/doctor.rb`:
- Around line 397-413: Update streams_budget_clause for :master scope to append
a brief note that the reported per-web-host budget assumes preload_app! and a
running, reachable streams master hub. Keep the existing :process clause and
budget behavior unchanged.
In `@lib/pgbus/web/streamer/failover_listener.rb`:
- Around line 63-68: Update the rescue for HubClient::HubUnavailableError in
remove_listening to log the failure through Pgbus.logger before returning nil,
matching the file’s existing error-logging behavior and keeping other error
paths unchanged.
In `@lib/pgbus/web/streamer/hub_protocol.rb`:
- Around line 45-66: Update read_frame to reject invalid UTF-8 bodies before
JSON.parse by validating body.valid_encoding? and raising ProtocolError, then
validate the parsed JSON result is a Hash and raise ProtocolError otherwise.
Preserve the existing malformed-JSON, oversized-frame, and connection-reset
handling.
In `@lib/pgbus/web/streamer/instance.rb`:
- Around line 166-197: Update build_hub_listener to rescue ThreadError raised
during HubClient startup, close `@sock` before converting it to
HubClient::HubUnavailableError, and let the existing fallback logging and
per-worker listener path handle the converted error.
In `@lib/pgbus/web/streamer/master_hub_boot.rb`:
- Around line 25-27: Add require "tmpdir" at the top of master_hub_boot.rb so
Dir.tmpdir is available when default_socket_path is evaluated. Keep the existing
default_socket_path implementation unchanged.
In `@lib/pgbus/web/streamer/master_hub.rb`:
- Around line 183-196: Update reader_loop to rescue unexpected StandardError
exceptions after the existing protocol and socket-error handlers, logging the
failure through `@logger` with the worker id and exception details before
cleanup_worker(id) runs. Mirror the writer loop’s unexpected-error handling
without changing the existing specialized rescue behavior.
- Around line 87-109: Update MasterHub#start to clean up partial startup
failures: wrap all steps after `@listener` is created in failure handling that
stops the listener, resets `@running` to false under `@table_mutex`, and re-raises
the original exception. Preserve normal startup behavior and ensure cleanup also
covers failures from UNIXServer.new, File.chmod, or thread creation.
In `@lib/puma/plugin/pgbus_streams.rb`:
- Around line 52-55: Update log_error to accept an operation name with "streamer
teardown" as its default, and include that name in the logged message. In
boot_master_hub’s rescue path, pass a startup-specific operation such as "master
hub boot" when calling log_error, while preserving the existing teardown
behavior for other callers.
In `@spec/integration/streams/master_hub_e2e_spec.rb`:
- Around line 20-40: Update the before(:all) and after(:all) hooks to save and
restore Pgbus.configuration.streams_signed_name_secret using the same pattern as
the other settings. Replace the unconditional nil assignment with restoration of
the saved value, while preserving the existing client reset behavior.
In `@spec/pgbus/configuration_spec.rb`:
- Around line 1651-1675: Add an example to the `#streams_listen_scope` spec
covering a non-symbolizable value such as an Integer, and assert that assigning
it raises Pgbus::ConfigurationError. Match the existing `#worker_notify_scope`
rejection test’s behavior and style so the streams_listen_scope= type-check else
branch is covered.
In `@spec/pgbus/web/streamer/master_hub_boot_spec.rb`:
- Around line 111-131: Add a `#stop` example covering the register-or-late-stop
race: create a separate `described_class` instance with a hub factory blocked by
a `Queue`, start it with configuration and master scope ready, call `stop` while
construction is blocked, release the factory afterward, then wait for and assert
that the built `hub` received `stop`. Use the existing `factory_calls`,
`wait_until`, and hub-stop assertion helpers.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: d2b3f2db-ff04-44b8-92c0-75b175e971b5
📒 Files selected for processing (27)
CHANGELOG.mdREADME.mdRakefilebenchmarks/streams_hub_bench.rbdocs/app/models/config_reference.rbdocs/app/views/docs/pages/performance_tuning.rbdocs/performance.mdlib/pgbus/configuration.rblib/pgbus/doctor.rblib/pgbus/web/streamer/failover_listener.rblib/pgbus/web/streamer/hub_client.rblib/pgbus/web/streamer/hub_protocol.rblib/pgbus/web/streamer/instance.rblib/pgbus/web/streamer/listener.rblib/pgbus/web/streamer/master_hub.rblib/pgbus/web/streamer/master_hub_boot.rblib/puma/plugin/pgbus_streams.rbspec/integration/streams/master_hub_e2e_spec.rbspec/integration/streams/master_hub_spec.rbspec/pgbus/configuration_spec.rbspec/pgbus/doctor_spec.rbspec/pgbus/web/streamer/failover_listener_spec.rbspec/pgbus/web/streamer/hub_client_spec.rbspec/pgbus/web/streamer/hub_protocol_spec.rbspec/pgbus/web/streamer/instance_spec.rbspec/pgbus/web/streamer/master_hub_boot_spec.rbspec/pgbus/web/streamer/master_hub_spec.rb
- master_hub: start releases the listener (its LISTEN connection is the resource this hub conserves) when a later step fails — bad socket path, chmod, thread spawn; reader logs unexpected errors before severing - hub_protocol: reject non-object frames and invalid UTF-8 with ProtocolError instead of letting dispatch raise TypeError - hub_client: connect also rescues ThreadError and closes the half-open socket before raising HubUnavailableError - master_hub_boot: require tmpdir (Dir.tmpdir undefined without it under a minimal Puma boot); race spec for register-or-late-stop - failover_listener: log the swallowed HubUnavailableError in remove_listening (best-effort GC, but never invisible) - plugin: log_error names the failing operation (boot vs teardown) - doctor: master-hub clause notes the per-worker fallback cost - specs: e2e restores streams_signed_name_secret, non-symbolizable streams_listen_scope coverage, doctor status matcher no longer depends on ActiveSupport load order (pre-existing isolated-run flake)
- FailoverListener#fail_over!: a listener the factory STARTED but whose subscription replay raised was leaked (thread + LISTEN connection) alongside the dead hub client; local is now method-scoped, ownership transfers on swap, and the rescue stops the never-swapped listener (spec-pinned) - MasterHubBoot#wait_and_start: the rescue cleared @hub without stopping the method-scoped hub, leaking it on a post-start failure - bench: HUB_BENCH_SAMPLES validated positive at parse time Umask-around-bind from the same review batch was already implemented in 22996e2.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/pgbus/web/streamer/master_hub.rb (1)
91-136: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftSerialize
startandstopresource transitions.
startsets@runningbefore@listener,@server, and the threads exist. Ifstopruns while@listener_factory.callblocks, it sets@runningto false and returns after cleaning no listener or server.startcan then create the listener, socket, and accept thread. Later calls tostopreturn early because@runningis false.Guard startup, startup-failure cleanup, and shutdown with one lifecycle mutex or equivalent state transition. Add a race spec that blocks
listener_factory, callsstop, then releases the factory and verifies that no hub resources remain active.As per coding guidelines: “Do not leave shared mutable state unsynchronized; use
MutexorConcurrentprimitives for concurrent code.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/pgbus/web/streamer/master_hub.rb` around lines 91 - 136, The start/stop lifecycle is unsynchronized, allowing stop to finish before start has created its resources. Add a dedicated lifecycle mutex/state transition around the full startup sequence in start, including failure cleanup, and the shutdown sequence in stop; ensure stop waits for an in-progress start and subsequent stop calls can clean up all resources. Add a race spec that blocks listener_factory, invokes stop, releases the factory, and verifies no listener, socket, or worker threads remain active.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@lib/pgbus/web/streamer/master_hub.rb`:
- Around line 91-136: The start/stop lifecycle is unsynchronized, allowing stop
to finish before start has created its resources. Add a dedicated lifecycle
mutex/state transition around the full startup sequence in start, including
failure cleanup, and the shutdown sequence in stop; ensure stop waits for an
in-progress start and subsequent stop calls can clean up all resources. Add a
race spec that blocks listener_factory, invokes stop, releases the factory, and
verifies no listener, socket, or worker threads remain active.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 23019484-c0b1-4093-91a2-0d7b4be243d5
📒 Files selected for processing (12)
lib/pgbus/doctor.rblib/pgbus/web/streamer/failover_listener.rblib/pgbus/web/streamer/hub_client.rblib/pgbus/web/streamer/hub_protocol.rblib/pgbus/web/streamer/master_hub.rblib/pgbus/web/streamer/master_hub_boot.rblib/puma/plugin/pgbus_streams.rbspec/integration/streams/master_hub_e2e_spec.rbspec/pgbus/configuration_spec.rbspec/pgbus/doctor_spec.rbspec/pgbus/web/streamer/hub_protocol_spec.rbspec/pgbus/web/streamer/master_hub_boot_spec.rb
A stop racing an in-progress start (blocked in the listener factory's PG connect) previously returned having torn down nothing, and start then finished building a fully live hub — listener, socket, and threads surviving a completed stop. A lifecycle mutex now wraps both full sequences: stop waits for the in-progress start, then tears down. Loop threads never take the mutex (they read @running via @table_mutex), so holding it across the blocking startup cannot deadlock. Race spec: gated factory, concurrent stop observed waiting, clean final state.
Summary
Implements #382: one streams LISTEN connection per web host — the web-side completion of #381's connection-footprint work, built on its shipped patterns (scope config, census tag, doctor budget, fail-safe fallback discipline).
Streamer::MasterHub(lib/pgbus/web/streamer/master_hub.rb) runs in the Puma master: ONEWeb::Streamer::Listeneron the refcounted union of every worker's stream channels, fanning wakes — including ephemeral payloads — to workers over a Unix domain socket.Streamer::HubProtocol— length-prefixed JSON frames (sub/unsub/ack/wake/status). Deliberately lossless, unlike Connection efficiency: host-level shared LISTEN (supervisor-owned NotifyListener, one direct connection per host) + explicit multi-queue read priority contract #381's 1-byte wake pipes: ephemeral frames carry the only copy of their HTML.Streamer::HubClient+Streamer::FailoverListener— the worker side. The synchronousensure_listeningack contract crosses the process boundary intact (register-before-LISTEN, ack-after-ensure); any transport failure (connect refused, ack deadline, EOF, eviction) swaps in a real per-workerListenerwith the recorded subscription set, one-way, until the worker recycles.read_after), ephemeral never dropped — a non-draining worker is evicted and thereby self-degrades to its own listener without touching siblings.Streamer::MasterHubBoot+pgbus_streamsplugin — exports the socket path pre-fork, defers hub start behind a config-readiness poller (preload_app!effectively required for:master; without it everything quietly stays per-worker).streams_listen_scope—:master(default) |:process.docs/performance.mdupdated.Refs #382 (close at your discretion — acceptance below). Refs #381.
Acceptance vs the plan on #382
pg_notifypayload fidelity)Measurements (same machine, local PG)
Baseline captured on
mainbefore any code (streams_bench): single-broadcast SSE roundtrip median ~13–15ms, p95 ~20–24ms.benchmarks/streams_hub_bench.rb(n=50, durable broadcasts)::process(per-worker, pre-#382):master(hub → socket hop)The master→worker hop is noise-level free — DB round trips dominate. Honest framing: connection-footprint win, not a latency win (same as #381).
Failure modes, all measured/asserted against real PG:
pg_terminate_backend): listener reconnects, wakes flow again — integration-asserted with real ephemeral payloads before/after.Test plan
bundle exec rspec spec/pgbus— 3539 examples, 0 failures (seed 12345)master_hub_spec,master_hub_e2e_spec, plus pre-existingaudience/replay/transactionalstreams flows and the Connection efficiency: host-level shared LISTEN (supervisor-owned NotifyListener, one direct connection per host) + explicit multi-queue read priority contract #381 specs — 14 examples, 0 failures on Rails 8.1bundle exec rake rubocop— 542 files, no offensesrake lint+ 69 specs green (config-reference drift guard includesstreams_listen_scope)Deviations & judgment calls
:masterdefault immediately, one PR.ensure_listening; only UNLISTEN is refcounted. A refcount shortcut on subscribe would ack worker B while worker A's LISTEN was still in flight — reopening the lost-wake gap. My own first spec draft had this wrong; the corrected contract is pinned with a comment.preload_app!the plugin'sstartruns before the app loads, so the hub can't build eagerly. Withoutpreload_app!the deadline expires quietly and workers stay per-worker::mastereffectively requirespreload_app!(documented on the tuning page).pumaCLI cluster driving.:ephemeralbroadcast mode races subscription setup, and one lost first event wedges cumulative waits at their timeout — discovered when the first bench run recorded exactly 10010ms per sample.:master, listener-hosted autoscaler maintenance only runs on the fallback local listener (no idle per-worker LISTEN connection to ride); the publish-path autoscale trigger is unaffected.Gemfile.lockdrift to Rails 7.1 madetransactional_specfail locally (Transaction#after_commitis 7.2+); verified pre-existing/environmental against main and green after re-resolving to 8.1. Not a branch defect; the lock is gitignored.Summary by CodeRabbit
New Features
streams_listen_scopewith:master(default) and:processoptions.Documentation
Tests