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.
@@ -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
- @pool.wait_for_termination(30)
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
- @health_server = Pgbus::Web::HealthServer.new(port: config.health_port, bind: config.health_bind)
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 all children with timeout
880
- deadline = Time.now + 30
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
@@ -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 the 30s
179
- # wait_for_termination timeout in shutdown. Bounded by
180
- # config.drain_timeout so a stuck job can't wedge the loop forever.
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
- @pool.wait_for_termination(30)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.0"
4
+ VERSION = "0.13.2"
5
5
  end
@@ -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
- def initialize(data_source: nil)
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