pgbus 0.13.0 → 0.13.2
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 +10 -0
- data/README.md +78 -4
- data/Rakefile +6 -1
- data/app/models/pgbus/processed_event.rb +45 -0
- data/exe/pgbus-health +9 -0
- data/lib/generators/pgbus/add_processed_event_completion_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_processed_event_completion.rb.erb +19 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -0
- data/lib/pgbus/configuration.rb +72 -0
- data/lib/pgbus/doctor.rb +12 -1
- data/lib/pgbus/event_bus/handler.rb +49 -7
- data/lib/pgbus/generators/migration_detector.rb +13 -0
- data/lib/pgbus/health_probe.rb +132 -0
- data/lib/pgbus/process/consumer.rb +4 -1
- data/lib/pgbus/process/readiness_snapshot.rb +30 -0
- data/lib/pgbus/process/supervisor.rb +64 -3
- data/lib/pgbus/process/worker.rb +14 -4
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/health_app.rb +23 -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 +12 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
# Dependency-free readiness probe for container HEALTHCHECKs (issue #386).
|
|
7
|
+
#
|
|
8
|
+
# exe/pgbus-health loads this file via require_relative and nothing else:
|
|
9
|
+
# a docker HEALTHCHECK runs the probe every few seconds, so it must never
|
|
10
|
+
# drag in Bundler, Zeitwerk, Rails, or the rest of the gem. Only Ruby's
|
|
11
|
+
# bundled socket library is allowed here.
|
|
12
|
+
#
|
|
13
|
+
# healthcheck:
|
|
14
|
+
# cmd: bin/pgbus-health # port from PGBUS_HEALTH_PORT
|
|
15
|
+
# cmd: bin/pgbus-health --port 9394 --path /livez
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 healthy (HTTP 2xx), 1 unhealthy (non-2xx, refused, timeout),
|
|
18
|
+
# 2 usage error (no/invalid port).
|
|
19
|
+
class HealthProbe
|
|
20
|
+
EXIT_OK = 0
|
|
21
|
+
EXIT_UNHEALTHY = 1
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH = "/readyz"
|
|
25
|
+
DEFAULT_TIMEOUT = 2.0
|
|
26
|
+
HOST = "127.0.0.1"
|
|
27
|
+
|
|
28
|
+
USAGE = "usage: pgbus-health [--port PORT] [--path PATH] [--timeout SECONDS]\n " \
|
|
29
|
+
"port falls back to the PGBUS_HEALTH_PORT environment variable\n"
|
|
30
|
+
|
|
31
|
+
def self.run(argv, env: ENV, out: $stdout, err: $stderr)
|
|
32
|
+
new(argv, env: env, out: out, err: err).run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(argv, env: ENV, out: $stdout, err: $stderr)
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
@path = DEFAULT_PATH
|
|
39
|
+
@timeout = DEFAULT_TIMEOUT
|
|
40
|
+
@port = env["PGBUS_HEALTH_PORT"]
|
|
41
|
+
@usage_error = false
|
|
42
|
+
parse(argv)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
return usage_failure if @usage_error
|
|
47
|
+
|
|
48
|
+
port = Integer(@port, exception: false)
|
|
49
|
+
# Out-of-range ports would reach Socket.tcp and raise SocketError — a
|
|
50
|
+
# backtrace where a HEALTHCHECK needs a deterministic exit code.
|
|
51
|
+
return usage_failure unless port&.between?(1, 65_535)
|
|
52
|
+
|
|
53
|
+
probe(port)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Hand-rolled flag parsing: three flags do not justify optparse in a
|
|
59
|
+
# script whose reason to exist is loading nothing.
|
|
60
|
+
def parse(argv)
|
|
61
|
+
args = argv.dup
|
|
62
|
+
until args.empty?
|
|
63
|
+
flag = args.shift
|
|
64
|
+
value = args.shift
|
|
65
|
+
return @usage_error = true if value.nil?
|
|
66
|
+
|
|
67
|
+
case flag
|
|
68
|
+
when "--port" then @port = value
|
|
69
|
+
when "--path" then @path = value
|
|
70
|
+
when "--timeout"
|
|
71
|
+
# A typo'd timeout must be a usage error, not `to_f`'s silent 0.0 —
|
|
72
|
+
# a zero deadline reports the container unhealthy on every probe.
|
|
73
|
+
timeout = Float(value, exception: false)
|
|
74
|
+
return @usage_error = true unless timeout&.positive?
|
|
75
|
+
|
|
76
|
+
@timeout = timeout
|
|
77
|
+
else
|
|
78
|
+
return @usage_error = true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_failure
|
|
84
|
+
@err.write(USAGE)
|
|
85
|
+
EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def probe(port)
|
|
89
|
+
status = http_status(port)
|
|
90
|
+
healthy = status&.between?(200, 299)
|
|
91
|
+
@out.write("pgbus-health: #{@path} -> #{status || "no response"}\n")
|
|
92
|
+
healthy ? EXIT_OK : EXIT_UNHEALTHY
|
|
93
|
+
rescue SystemCallError, IOError, SocketError => e
|
|
94
|
+
@err.write("pgbus-health: #{@path} -> #{e.class}: #{e.message}\n")
|
|
95
|
+
EXIT_UNHEALTHY
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Minimal HTTP/1.0 exchange: send the request, read just the status line.
|
|
99
|
+
# The deadline covers connect and read together.
|
|
100
|
+
def http_status(port)
|
|
101
|
+
deadline = monotonic_now + @timeout
|
|
102
|
+
Socket.tcp(HOST, port, connect_timeout: @timeout) do |sock|
|
|
103
|
+
sock.write("GET #{@path} HTTP/1.0\r\nHost: #{HOST}\r\nConnection: close\r\n\r\n")
|
|
104
|
+
line = read_status_line(sock, deadline)
|
|
105
|
+
code = line&.split(" ", 3)&.fetch(1, nil)
|
|
106
|
+
Integer(code, exception: false)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_status_line(sock, deadline)
|
|
111
|
+
buffer = +""
|
|
112
|
+
until buffer.include?("\n")
|
|
113
|
+
remaining = deadline - monotonic_now
|
|
114
|
+
return nil if remaining <= 0 || !sock.wait_readable(remaining)
|
|
115
|
+
|
|
116
|
+
chunk = sock.read_nonblock(1024, exception: false)
|
|
117
|
+
return nil if chunk.nil? # EOF before a full status line
|
|
118
|
+
next if chunk == :wait_readable # spurious wakeup — re-wait on the deadline
|
|
119
|
+
|
|
120
|
+
buffer << chunk
|
|
121
|
+
end
|
|
122
|
+
buffer[/\A[^\r\n]*/]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ::Process, not Process — inside the Pgbus namespace the bare constant
|
|
126
|
+
# resolves to Pgbus::Process (the process model), which is also why this
|
|
127
|
+
# file must never be renamed into that namespace.
|
|
128
|
+
def monotonic_now
|
|
129
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -480,7 +480,10 @@ module Pgbus
|
|
|
480
480
|
def shutdown
|
|
481
481
|
stop_wake_source
|
|
482
482
|
@pool.shutdown
|
|
483
|
-
|
|
483
|
+
# The consumer has no quiesce-gated drain loop like Worker's, so this
|
|
484
|
+
# wait IS its drain window — bound it by the same knob workers use
|
|
485
|
+
# instead of a hardcoded 30s (issue #386).
|
|
486
|
+
@pool.wait_for_termination(config.drain_timeout)
|
|
484
487
|
@stat_buffer&.stop
|
|
485
488
|
@heartbeat&.stop
|
|
486
489
|
restore_signals
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Immutable container-local readiness state, published by the supervisor
|
|
6
|
+
# (one atomic swap per monitor pass) and read by the standalone health
|
|
7
|
+
# server's accept thread — the immutability is what makes the cross-thread
|
|
8
|
+
# handoff safe without a lock (issue #386).
|
|
9
|
+
#
|
|
10
|
+
# `expected` is the child count forked by boot_processes; `live` is the
|
|
11
|
+
# current fork-table size. A child sitting in crash-restart backoff keeps
|
|
12
|
+
# `live < expected`, which is exactly the signal a rolling deploy's health
|
|
13
|
+
# gate needs to fail on: the replacement container never goes ready, and
|
|
14
|
+
# the orchestrator keeps the old container running.
|
|
15
|
+
ReadinessSnapshot = Data.define(:booted, :shutting_down, :expected, :live) do
|
|
16
|
+
def ready?
|
|
17
|
+
booted && !shutting_down && live >= expected
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DRAINING wins over BOOTING: a supervisor told to stop mid-boot is
|
|
21
|
+
# leaving, not arriving, and must never look like it will become ready.
|
|
22
|
+
def status
|
|
23
|
+
return "DRAINING" if shutting_down
|
|
24
|
+
return "BOOTING" unless booted
|
|
25
|
+
|
|
26
|
+
ready? ? "OK" : "DEGRADED"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -44,6 +44,16 @@ module Pgbus
|
|
|
44
44
|
@pending_restarts = pending_restarts
|
|
45
45
|
@crash_counts = Hash.new(0)
|
|
46
46
|
@notify_hub = notify_hub
|
|
47
|
+
@intended_children = 0
|
|
48
|
+
@readiness = Concurrent::AtomicReference.new(
|
|
49
|
+
ReadinessSnapshot.new(booted: false, shutting_down: shutting_down, expected: 0, live: forks.size)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The current container-local readiness state. Safe to call from any
|
|
54
|
+
# thread (the health server's accept thread reads it per probe).
|
|
55
|
+
def readiness_snapshot
|
|
56
|
+
@readiness.get
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def shutting_down?
|
|
@@ -97,6 +107,7 @@ module Pgbus
|
|
|
97
107
|
start_notify_hub
|
|
98
108
|
|
|
99
109
|
boot_processes
|
|
110
|
+
mark_booted
|
|
100
111
|
monitor_loop
|
|
101
112
|
ensure
|
|
102
113
|
shutdown
|
|
@@ -105,17 +116,51 @@ module Pgbus
|
|
|
105
116
|
def graceful_shutdown
|
|
106
117
|
Pgbus.logger.info { "[Pgbus] Supervisor: graceful shutdown requested" }
|
|
107
118
|
@shutting_down = true
|
|
119
|
+
refresh_readiness
|
|
108
120
|
signal_children("TERM")
|
|
109
121
|
end
|
|
110
122
|
|
|
111
123
|
def immediate_shutdown
|
|
112
124
|
Pgbus.logger.warn { "[Pgbus] Supervisor: immediate shutdown requested" }
|
|
113
125
|
@shutting_down = true
|
|
126
|
+
refresh_readiness
|
|
114
127
|
signal_children("QUIT")
|
|
115
128
|
end
|
|
116
129
|
|
|
117
130
|
private
|
|
118
131
|
|
|
132
|
+
# Boot is complete: connection verified, queues bootstrapped, every
|
|
133
|
+
# configured child fork ATTEMPTED. The baseline is the larger of the
|
|
134
|
+
# intended-attempt count and the fork-table size: a boot-time fork
|
|
135
|
+
# failure (EAGAIN/ENOMEM, logged-and-swallowed in fork_*) leaves
|
|
136
|
+
# intended > live, so the readiness gate reports DEGRADED instead of
|
|
137
|
+
# blessing a container that is missing workers. Roles that legitimately
|
|
138
|
+
# declined to boot (scheduler with no recurring tasks) never reach a
|
|
139
|
+
# fork_* method and are counted by neither side.
|
|
140
|
+
def mark_booted
|
|
141
|
+
@booted = true
|
|
142
|
+
@expected_children = [@intended_children, @forks.size].max
|
|
143
|
+
refresh_readiness
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Count a child the configuration intends this boot to run. Called at
|
|
147
|
+
# the top of every fork_* method — before the fork can fail — and only
|
|
148
|
+
# pre-boot, so restart_child's re-forks never inflate the baseline.
|
|
149
|
+
def note_intended_child
|
|
150
|
+
@intended_children += 1 unless @booted
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Publish a fresh snapshot; the swapped-in Data is immutable, so the
|
|
154
|
+
# health server's accept thread always reads a consistent state.
|
|
155
|
+
def refresh_readiness
|
|
156
|
+
@readiness.set(
|
|
157
|
+
ReadinessSnapshot.new(
|
|
158
|
+
booted: !!@booted, shutting_down: @shutting_down,
|
|
159
|
+
expected: @expected_children || 0, live: @forks.size
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
119
164
|
# Log a single boot diagnostics banner: the settings that actually
|
|
120
165
|
# determine whether this deployment works. One consecutive block of
|
|
121
166
|
# "[Pgbus] boot:"-prefixed info lines so it reads cleanly under both the
|
|
@@ -253,6 +298,7 @@ module Pgbus
|
|
|
253
298
|
end
|
|
254
299
|
|
|
255
300
|
def fork_worker(worker_config, slot: nil)
|
|
301
|
+
note_intended_child
|
|
256
302
|
queues = worker_config[:queues] || [config.default_queue]
|
|
257
303
|
threads = worker_config[:threads] || 5
|
|
258
304
|
single_active = worker_config[:single_active_consumer] || false
|
|
@@ -337,6 +383,7 @@ module Pgbus
|
|
|
337
383
|
end
|
|
338
384
|
|
|
339
385
|
def fork_dispatcher
|
|
386
|
+
note_intended_child
|
|
340
387
|
pid = fork do
|
|
341
388
|
restore_signals
|
|
342
389
|
setup_child_process
|
|
@@ -364,6 +411,7 @@ module Pgbus
|
|
|
364
411
|
end
|
|
365
412
|
|
|
366
413
|
def fork_scheduler
|
|
414
|
+
note_intended_child
|
|
367
415
|
pid = fork do
|
|
368
416
|
restore_signals
|
|
369
417
|
setup_child_process
|
|
@@ -427,6 +475,7 @@ module Pgbus
|
|
|
427
475
|
end
|
|
428
476
|
|
|
429
477
|
def fork_consumer(consumer_config, slot: nil)
|
|
478
|
+
note_intended_child
|
|
430
479
|
# Array() so a consumer entry without :topics can't NoMethodError the
|
|
431
480
|
# supervisor on the topics.join log lines below.
|
|
432
481
|
topics = Array(consumer_config[:topics])
|
|
@@ -504,6 +553,7 @@ module Pgbus
|
|
|
504
553
|
end
|
|
505
554
|
|
|
506
555
|
def fork_outbox_poller
|
|
556
|
+
note_intended_child
|
|
507
557
|
pid = fork do
|
|
508
558
|
restore_signals
|
|
509
559
|
setup_child_process
|
|
@@ -537,6 +587,9 @@ module Pgbus
|
|
|
537
587
|
# refresh, and fork status broadcast (issue #381).
|
|
538
588
|
@notify_hub&.tick
|
|
539
589
|
end
|
|
590
|
+
# After reap + restarts so a clean recycle (reaped and re-forked in
|
|
591
|
+
# the same pass) never dips the published live count (issue #386).
|
|
592
|
+
refresh_readiness
|
|
540
593
|
interruptible_sleep(FORK_WAIT)
|
|
541
594
|
end
|
|
542
595
|
end
|
|
@@ -821,7 +874,12 @@ module Pgbus
|
|
|
821
874
|
def start_health_server
|
|
822
875
|
return unless config.health_port
|
|
823
876
|
|
|
824
|
-
|
|
877
|
+
# The standalone server answers /readyz from THIS supervisor's
|
|
878
|
+
# container-local snapshot — a rolling deploy's health gate must
|
|
879
|
+
# measure the new container, not the fleet-wide verdict a sibling
|
|
880
|
+
# container's workers can satisfy (issue #386).
|
|
881
|
+
app = Pgbus::Web::HealthApp.new(local_readiness: -> { readiness_snapshot })
|
|
882
|
+
@health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind, app: app)
|
|
825
883
|
@health_server.start
|
|
826
884
|
end
|
|
827
885
|
|
|
@@ -876,8 +934,11 @@ module Pgbus
|
|
|
876
934
|
end
|
|
877
935
|
|
|
878
936
|
def shutdown
|
|
879
|
-
# Wait for
|
|
880
|
-
|
|
937
|
+
# Wait for children to drain and exit, bounded by config.shutdown_timeout
|
|
938
|
+
# (default drain_timeout + 5) so raising the drain window can never
|
|
939
|
+
# mean SIGKILLing workers mid-drain. An orchestrator's stop grace
|
|
940
|
+
# period should exceed this value (issue #386).
|
|
941
|
+
deadline = Time.now + config.shutdown_timeout
|
|
881
942
|
|
|
882
943
|
until @forks.empty? || Time.now > deadline
|
|
883
944
|
reap_children
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -155,6 +155,12 @@ module Pgbus
|
|
|
155
155
|
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
156
156
|
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
157
157
|
|
|
158
|
+
# Residual pool wait in #shutdown, AFTER the drain loop already spent up
|
|
159
|
+
# to config.drain_timeout on in-flight jobs. Short by design: a job still
|
|
160
|
+
# running has proven it won't finish, and this wait competes with the
|
|
161
|
+
# supervisor's shutdown_timeout deadline (issue #386).
|
|
162
|
+
POOL_TERMINATION_WAIT = 5
|
|
163
|
+
|
|
158
164
|
def run
|
|
159
165
|
setup_signals
|
|
160
166
|
start_heartbeat
|
|
@@ -175,9 +181,9 @@ module Pgbus
|
|
|
175
181
|
|
|
176
182
|
break if @lifecycle.stopped?
|
|
177
183
|
# quiesced? (all slots free), not idle? (any slot free) — exiting
|
|
178
|
-
# with work still in flight abandons those jobs to
|
|
179
|
-
#
|
|
180
|
-
#
|
|
184
|
+
# with work still in flight abandons those jobs to shutdown's short
|
|
185
|
+
# POOL_TERMINATION_WAIT residual. Bounded by config.drain_timeout so
|
|
186
|
+
# a stuck job can't wedge the loop forever.
|
|
181
187
|
break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
|
|
182
188
|
|
|
183
189
|
claim_and_execute if @lifecycle.can_process?
|
|
@@ -792,7 +798,11 @@ module Pgbus
|
|
|
792
798
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
793
799
|
stop_wake_source
|
|
794
800
|
@pool.shutdown
|
|
795
|
-
|
|
801
|
+
# Residual wait only: the drain loop already waited up to
|
|
802
|
+
# config.drain_timeout for in-flight jobs. A job still running here has
|
|
803
|
+
# proven it won't finish; waiting another full window would push the
|
|
804
|
+
# worker past the supervisor's shutdown_timeout deadline (issue #386).
|
|
805
|
+
@pool.wait_for_termination(POOL_TERMINATION_WAIT)
|
|
796
806
|
@stat_buffer&.stop
|
|
797
807
|
@queue_lock&.unlock_all
|
|
798
808
|
@heartbeat&.stop
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/health_app.rb
CHANGED
|
@@ -49,8 +49,15 @@ module Pgbus
|
|
|
49
49
|
# @param data_source [Pgbus::Web::DataSource, nil] read layer for /readyz.
|
|
50
50
|
# nil (the default) builds a fresh DataSource per readiness check, which
|
|
51
51
|
# avoids serving stale metrics from a long-lived app's memoized instance.
|
|
52
|
-
|
|
52
|
+
# @param local_readiness [#call, nil] when set, /readyz answers from this
|
|
53
|
+
# callable's {Process::ReadinessSnapshot} instead of the cluster-wide
|
|
54
|
+
# analyzer — the supervisor's standalone HealthServer passes its own
|
|
55
|
+
# snapshot so a rolling deploy's health gate measures THIS container,
|
|
56
|
+
# not the fleet (issue #386). The Rails-mounted app leaves it nil and
|
|
57
|
+
# keeps the cluster verdict.
|
|
58
|
+
def initialize(data_source: nil, local_readiness: nil)
|
|
53
59
|
@data_source = data_source
|
|
60
|
+
@local_readiness = local_readiness
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
def call(env)
|
|
@@ -69,6 +76,8 @@ module Pgbus
|
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
def readyz
|
|
79
|
+
return local_readyz if @local_readiness
|
|
80
|
+
|
|
72
81
|
# HealthAnalyzer lives in the MCP namespace, which is excluded from
|
|
73
82
|
# Zeitwerk (its *tools* subclass the optional `mcp` gem). The analyzer
|
|
74
83
|
# itself has no gem dependency, so require just that one file — the
|
|
@@ -83,6 +92,19 @@ module Pgbus
|
|
|
83
92
|
[503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
|
|
84
93
|
end
|
|
85
94
|
|
|
95
|
+
# Container-local readiness: no database, no analyzer — just the
|
|
96
|
+
# supervisor's published snapshot. The error path mirrors the cluster
|
|
97
|
+
# readyz: 503 ERROR, logged, never swallowed.
|
|
98
|
+
def local_readyz
|
|
99
|
+
snapshot = @local_readiness.call
|
|
100
|
+
status = snapshot.ready? ? 200 : 503
|
|
101
|
+
body = { status: snapshot.status, expected: snapshot.expected, live: snapshot.live }
|
|
102
|
+
[status, JSON_HEADERS.dup, [body.to_json]]
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Pgbus.logger.error { "[Pgbus::Web::HealthApp] local readiness check failed: #{e.class}: #{e.message}" }
|
|
105
|
+
[503, JSON_HEADERS.dup, [{ status: "ERROR", error: e.message }.to_json]]
|
|
106
|
+
end
|
|
107
|
+
|
|
86
108
|
# Reuse an injected DataSource (tests, an app that wants one shared
|
|
87
109
|
# instance); otherwise build a fresh one each check so per-instance
|
|
88
110
|
# memoization can never serve stale queue/process metrics.
|
|
@@ -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
|