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 +4 -4
- data/CHANGELOG.md +2 -0
- data/README.md +1 -1
- data/Rakefile +6 -1
- data/lib/pgbus/configuration.rb +29 -0
- data/lib/pgbus/doctor.rb +12 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/streamer/failover_listener.rb +130 -0
- data/lib/pgbus/web/streamer/hub_client.rb +199 -0
- data/lib/pgbus/web/streamer/hub_protocol.rb +85 -0
- data/lib/pgbus/web/streamer/instance.rb +69 -20
- data/lib/pgbus/web/streamer/listener.rb +17 -2
- data/lib/pgbus/web/streamer/master_hub.rb +414 -0
- data/lib/pgbus/web/streamer/master_hub_boot.rb +149 -0
- data/lib/puma/plugin/pgbus_streams.rb +38 -2
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c955f8fb2ac4e66fb2db388505ee83341aa6dec7c00f606162f7fba7c29cb75
|
|
4
|
+
data.tar.gz: a31e43c6f48cb10609d73dc507e9d177d25a299ae25c6f5d5a39a045018c32e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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|...]"
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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 +=
|
|
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
|
@@ -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 =
|
|
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
|