pgbus 0.9.6 → 0.9.8
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 +38 -0
- data/README.md +119 -1
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +45 -6
- data/app/views/pgbus/jobs/show.html.erb +1 -1
- data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
- data/config/locales/da.yml +4 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fi.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/nb.yml +4 -0
- data/config/locales/nl.yml +4 -0
- data/config/locales/pt.yml +4 -0
- data/config/locales/sv.yml +4 -0
- data/lib/pgbus/active_job/executor.rb +25 -4
- data/lib/pgbus/cli/dlq.rb +164 -0
- data/lib/pgbus/cli.rb +18 -1
- data/lib/pgbus/client/connection_health.rb +194 -0
- data/lib/pgbus/client.rb +592 -73
- data/lib/pgbus/config_loader.rb +23 -4
- data/lib/pgbus/configuration.rb +102 -13
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +250 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +7 -0
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/instrumentation.rb +1 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/metrics/backend.rb +38 -0
- data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
- data/lib/pgbus/metrics/backends/statsd.rb +64 -0
- data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
- data/lib/pgbus/metrics/subscriber.rb +190 -0
- data/lib/pgbus/metrics.rb +42 -0
- data/lib/pgbus/process/consumer.rb +215 -8
- data/lib/pgbus/process/consumer_priority.rb +34 -0
- data/lib/pgbus/process/dispatcher.rb +265 -41
- data/lib/pgbus/process/heartbeat.rb +18 -5
- data/lib/pgbus/process/memory_usage.rb +48 -0
- data/lib/pgbus/process/notify_listener.rb +26 -7
- data/lib/pgbus/process/notify_probe.rb +96 -0
- data/lib/pgbus/process/primary_validator.rb +53 -0
- data/lib/pgbus/process/signal_handler.rb +6 -0
- data/lib/pgbus/process/supervisor.rb +396 -46
- data/lib/pgbus/process/worker.rb +298 -35
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +20 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/payload_filter.rb +68 -0
- data/lib/pgbus/web/streamer/instance.rb +55 -1
- data/lib/pgbus/web/streamer/listener.rb +72 -9
- data/lib/pgbus.rb +37 -0
- data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
- data/lib/rubocop/pgbus.rb +5 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -1
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -7,11 +7,30 @@ module Pgbus
|
|
|
7
7
|
class Worker
|
|
8
8
|
include SignalHandler
|
|
9
9
|
|
|
10
|
-
attr_reader :queues, :threads, :config, :execution_mode
|
|
11
|
-
|
|
10
|
+
attr_reader :queues, :threads, :config, :execution_mode,
|
|
11
|
+
:rate_counter, :wake_signal, :restore_streak, :lifecycle
|
|
12
|
+
# stat_buffer is writable so a test can swap in a buffer double after
|
|
13
|
+
# construction to assert graceful_shutdown / check_recycle flush it. The
|
|
14
|
+
# executor captured the original buffer at construction, but these paths
|
|
15
|
+
# flush @stat_buffer directly, so the swap is observable.
|
|
16
|
+
attr_accessor :stat_buffer
|
|
17
|
+
# notify_listener / notify_retry_at / notify_retry_backoff are writable so
|
|
18
|
+
# tests can seed the self-healing listener state, re-arm the backoff window
|
|
19
|
+
# between calls, and simulate start_notify_listener assigning the listener
|
|
20
|
+
# from inside a stub (production mutates all three in the run loop).
|
|
21
|
+
attr_accessor :notify_listener, :notify_retry_at, :notify_retry_backoff
|
|
22
|
+
|
|
23
|
+
# The collaborators below (rate_counter, wake_signal, stat_buffer) and the
|
|
24
|
+
# recycle clock (started_at_monotonic) accept injected seeds so tests can
|
|
25
|
+
# observe or stub them without poking private ivars. All default to the
|
|
26
|
+
# exact values production constructs, so behavior is unchanged.
|
|
12
27
|
def initialize(queues:, threads: 5, config: Pgbus.configuration,
|
|
13
28
|
single_active_consumer: false, consumer_priority: 0,
|
|
14
|
-
execution_mode: :threads, group_mode: nil
|
|
29
|
+
execution_mode: :threads, group_mode: nil, liveness_pipe: nil,
|
|
30
|
+
rate_counter: nil, wake_signal: nil, stat_buffer: :default,
|
|
31
|
+
notify_listener: nil, notify_retry_at: 0.0,
|
|
32
|
+
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
33
|
+
started_at_monotonic: nil)
|
|
15
34
|
@queues = Array(queues)
|
|
16
35
|
@initial_queues = @queues.dup.freeze
|
|
17
36
|
@wildcard = @queues.include?("*")
|
|
@@ -38,20 +57,47 @@ module Pgbus
|
|
|
38
57
|
@jobs_failed = Concurrent::AtomicFixnum.new(0)
|
|
39
58
|
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
40
59
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
41
|
-
@rate_counter = RateCounter.new(:processed, :failed, :dequeued)
|
|
60
|
+
@rate_counter = rate_counter || RateCounter.new(:processed, :failed, :dequeued)
|
|
42
61
|
@started_at = Time.current
|
|
43
|
-
@started_at_monotonic = monotonic_now
|
|
44
|
-
|
|
62
|
+
@started_at_monotonic = started_at_monotonic || monotonic_now
|
|
63
|
+
# stat_buffer: :default means "build one iff config.stats_enabled";
|
|
64
|
+
# passing an explicit value (including nil) overrides that for tests.
|
|
65
|
+
@stat_buffer =
|
|
66
|
+
if stat_buffer == :default
|
|
67
|
+
if config.stats_enabled
|
|
68
|
+
Pgbus::StatBuffer.new(
|
|
69
|
+
flush_size: config.stats_flush_size,
|
|
70
|
+
flush_interval: config.stats_flush_interval
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
stat_buffer
|
|
75
|
+
end
|
|
45
76
|
@executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
|
|
46
|
-
@wake_signal = WakeSignal.new
|
|
77
|
+
@wake_signal = wake_signal || WakeSignal.new
|
|
47
78
|
@pool = ExecutionPools.build(
|
|
48
79
|
mode: @execution_mode,
|
|
49
80
|
capacity: threads,
|
|
50
81
|
on_state_change: -> { @wake_signal.notify! }
|
|
51
82
|
)
|
|
52
83
|
@circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
|
|
84
|
+
@drain_started_at = nil
|
|
85
|
+
# Evict/restore cooldown state (issue #209). When a permanently-deleted
|
|
86
|
+
# queue is evicted down to an empty list, restoring the initial queues
|
|
87
|
+
# immediately re-triggers the same undefined-table error every loop tick.
|
|
88
|
+
# These back off the restore attempt on an exponential schedule instead.
|
|
89
|
+
@restore_streak = 0
|
|
90
|
+
@last_evicted_at = nil
|
|
91
|
+
@deferral_warned = false
|
|
53
92
|
@queue_lock = QueueLock.new if @single_active_consumer
|
|
54
|
-
@notify_listener =
|
|
93
|
+
@notify_listener = notify_listener
|
|
94
|
+
@notify_retry_at = notify_retry_at
|
|
95
|
+
@notify_retry_backoff = notify_retry_backoff
|
|
96
|
+
# OS-level liveness channel to the supervisor watchdog. Optional: nil
|
|
97
|
+
# unless the supervisor forked us with one. Written from stamp_loop_tick
|
|
98
|
+
# so the watchdog can detect a wedged worker even when the database (and
|
|
99
|
+
# thus the Heartbeat's loop_tick_at) is unavailable.
|
|
100
|
+
@liveness_pipe = liveness_pipe
|
|
55
101
|
end
|
|
56
102
|
|
|
57
103
|
def stats
|
|
@@ -69,8 +115,47 @@ module Pgbus
|
|
|
69
115
|
}.merge(@pool.metadata)
|
|
70
116
|
end
|
|
71
117
|
|
|
118
|
+
# Test seams for the atomic counters recycle logic and prefetch
|
|
119
|
+
# flow-control consult. Production only increments these during message
|
|
120
|
+
# handling; seeding them lets a test cross a threshold without running
|
|
121
|
+
# thousands of real jobs.
|
|
122
|
+
def jobs_processed=(count)
|
|
123
|
+
@jobs_processed.value = count
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def in_flight=(count)
|
|
127
|
+
@in_flight.value = count
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The last wall-clock loop-tick stamp (Time.now.to_f) fed to the
|
|
131
|
+
# heartbeat's loop_tick_supplier. Wall-clock — NOT monotonic — so it stays
|
|
132
|
+
# comparable across the process boundary the supervisor watchdog reads it
|
|
133
|
+
# over. nil until the first stamp_loop_tick.
|
|
134
|
+
def last_loop_tick
|
|
135
|
+
@loop_tick_at.get
|
|
136
|
+
end
|
|
137
|
+
|
|
72
138
|
NOTIFY_FALLBACK_POLL_SECONDS = 15
|
|
73
139
|
|
|
140
|
+
# NotifyListener startup can fail on a transient boot-time condition (DB
|
|
141
|
+
# restarting, failover, DNS blip) or its thread can die mid-run. Rather
|
|
142
|
+
# than downgrade to blind polling until process restart, the loop retries
|
|
143
|
+
# from ensure_notify_listener on an exponential backoff: NOTIFY_RETRY_BASE
|
|
144
|
+
# doubling up to NOTIFY_RETRY_MAX. Constant-tuned, matching DRAIN_TIMEOUT
|
|
145
|
+
# and CircuitBreaker/Dispatcher precedent.
|
|
146
|
+
NOTIFY_RETRY_BASE_SECONDS = 5
|
|
147
|
+
NOTIFY_RETRY_MAX_SECONDS = 300
|
|
148
|
+
|
|
149
|
+
# Upper bound on the drain phase. Waiting for quiesced? must be
|
|
150
|
+
# bounded: a permanently-stuck job would otherwise hold the loop open
|
|
151
|
+
# forever — recycling never completes, TERM shutdown hangs the whole
|
|
152
|
+
# process tree, and the loop keeps stamping loop_tick so the
|
|
153
|
+
# supervisor watchdog never intervenes. After this many seconds the
|
|
154
|
+
# loop falls through to shutdown, whose wait_for_termination(30)
|
|
155
|
+
# bounds the remaining in-flight work. Tuned via constant rather than
|
|
156
|
+
# configuration, matching CircuitBreaker/Dispatcher precedent.
|
|
157
|
+
DRAIN_TIMEOUT = 30
|
|
158
|
+
|
|
74
159
|
def run
|
|
75
160
|
setup_signals
|
|
76
161
|
start_heartbeat
|
|
@@ -87,9 +172,14 @@ module Pgbus
|
|
|
87
172
|
process_signals
|
|
88
173
|
check_recycle
|
|
89
174
|
refresh_wildcard_queues
|
|
175
|
+
ensure_notify_listener
|
|
90
176
|
|
|
91
177
|
break if @lifecycle.stopped?
|
|
92
|
-
|
|
178
|
+
# quiesced? (all slots free), not idle? (any slot free) — exiting
|
|
179
|
+
# with work still in flight abandons those jobs to the 30s
|
|
180
|
+
# wait_for_termination timeout in shutdown. Bounded by
|
|
181
|
+
# DRAIN_TIMEOUT so a stuck job can't wedge the loop forever.
|
|
182
|
+
break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
|
|
93
183
|
|
|
94
184
|
claim_and_execute if @lifecycle.can_process?
|
|
95
185
|
@stat_buffer&.flush_if_due
|
|
@@ -103,6 +193,13 @@ module Pgbus
|
|
|
103
193
|
Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
|
|
104
194
|
Pgbus.stopping = true
|
|
105
195
|
@lifecycle.transition_to(:draining)
|
|
196
|
+
# Flush buffered stats at drain entry so the ≤ stats_flush_interval /
|
|
197
|
+
# stats_flush_size window isn't lost if the supervisor watchdog SIGKILLs
|
|
198
|
+
# a stalled worker before the drain-loop shutdown flush runs. Runs on the
|
|
199
|
+
# main loop thread (signals are dispatched via process_signals, not in
|
|
200
|
+
# trap context), so the DB write is safe. flush is thread-safe and
|
|
201
|
+
# no-ops when the buffer is empty.
|
|
202
|
+
@stat_buffer&.flush
|
|
106
203
|
@wake_signal.notify!
|
|
107
204
|
end
|
|
108
205
|
|
|
@@ -121,6 +218,15 @@ module Pgbus
|
|
|
121
218
|
# regex on every queue-missing error in hot fetch/read paths.
|
|
122
219
|
MISSING_QUEUE_REGEX = /pgmq\.q_(\w+)/
|
|
123
220
|
|
|
221
|
+
# Exponential backoff bounds for restoring evicted queues (issue #209).
|
|
222
|
+
# After the worker's queues are fully evicted (queue table permanently
|
|
223
|
+
# deleted), the first restore waits RESTORE_COOLDOWN_BASE seconds; each
|
|
224
|
+
# consecutive failed restore doubles the wait up to RESTORE_COOLDOWN_MAX.
|
|
225
|
+
# A successful fetch resets the streak so a recreated queue restores
|
|
226
|
+
# promptly. Constant-tuned, matching NOTIFY_RETRY/DRAIN_TIMEOUT precedent.
|
|
227
|
+
RESTORE_COOLDOWN_BASE = 30
|
|
228
|
+
RESTORE_COOLDOWN_MAX = 300
|
|
229
|
+
|
|
124
230
|
private
|
|
125
231
|
|
|
126
232
|
def claim_and_execute
|
|
@@ -168,17 +274,33 @@ module Pgbus
|
|
|
168
274
|
return []
|
|
169
275
|
end
|
|
170
276
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
277
|
+
results =
|
|
278
|
+
if priority_enabled?
|
|
279
|
+
fetch_prioritized(active_queues, qty)
|
|
280
|
+
elsif @group_mode
|
|
281
|
+
fetch_grouped(active_queues, qty)
|
|
282
|
+
elsif active_queues.size == 1
|
|
283
|
+
queue = active_queues.first
|
|
284
|
+
messages = Pgbus.client.read_batch(queue, qty: qty) || []
|
|
285
|
+
messages.map { |m| [queue, m] }
|
|
286
|
+
else
|
|
287
|
+
fetch_multi(active_queues, qty)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A read that reached here without an undefined-queue error means the
|
|
291
|
+
# queue tables exist again; drop the restore backoff so a recreated
|
|
292
|
+
# queue is reinstated promptly after the next eviction (issue #209).
|
|
293
|
+
@restore_streak = 0
|
|
294
|
+
results
|
|
295
|
+
rescue Pgbus::ConnectionCircuitOpenError
|
|
296
|
+
# The client-level connection breaker is open: the database has failed
|
|
297
|
+
# enough consecutive connection attempts that reads now fail fast. Idle
|
|
298
|
+
# this poll without an ErrorReporter call — the whole point of the
|
|
299
|
+
# breaker is to stop every worker flooding the error tracker for the
|
|
300
|
+
# duration of a database outage. The open/close transitions are logged
|
|
301
|
+
# once by the client (Client#log_circuit_open / #log_circuit_close),
|
|
302
|
+
# not per poll here.
|
|
303
|
+
[]
|
|
182
304
|
rescue StandardError => e
|
|
183
305
|
if undefined_queue_table_error?(e)
|
|
184
306
|
evict_missing_queues(e)
|
|
@@ -339,17 +461,56 @@ module Pgbus
|
|
|
339
461
|
end
|
|
340
462
|
end
|
|
341
463
|
Pgbus.logger.error { "[Pgbus] Queue table missing: #{error.message}" }
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
464
|
+
return unless @queues.empty? && !@wildcard
|
|
465
|
+
|
|
466
|
+
# Open (or re-arm) the restore cooldown window: stamp the eviction time so
|
|
467
|
+
# restore_evicted_queues can measure the backoff, and clear the
|
|
468
|
+
# per-window deferral-warned flag so the next deferral logs exactly once.
|
|
469
|
+
@last_evicted_at = monotonic_now
|
|
470
|
+
@deferral_warned = false
|
|
471
|
+
restore_evicted_queues
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Restore the worker's initial queues after a full eviction — but only once
|
|
475
|
+
# the exponential cooldown has elapsed (issue #209). While the cooldown is
|
|
476
|
+
# pending, leaves @queues empty (the caller idles via the empty-active_queues
|
|
477
|
+
# path) and logs a single deferral warn per window rather than one error pair
|
|
478
|
+
# per loop tick. Each actual restore escalates the streak so a permanently
|
|
479
|
+
# deleted queue backs off toward RESTORE_COOLDOWN_MAX instead of spinning.
|
|
345
480
|
def restore_evicted_queues
|
|
481
|
+
return unless restore_cooldown_elapsed?
|
|
482
|
+
|
|
346
483
|
@queues = @initial_queues.dup
|
|
484
|
+
@restore_streak += 1
|
|
347
485
|
Pgbus.logger.warn do
|
|
348
486
|
"[Pgbus] Worker queue list was empty after eviction — " \
|
|
349
487
|
"restoring initial queues: #{@queues.join(", ")}"
|
|
350
488
|
end
|
|
351
489
|
end
|
|
352
490
|
|
|
491
|
+
# True once RESTORE_COOLDOWN_BASE * 2**streak seconds (capped at
|
|
492
|
+
# RESTORE_COOLDOWN_MAX) have passed since the window opened. When still
|
|
493
|
+
# pending, emits at most one deferral warn per window naming the wait.
|
|
494
|
+
def restore_cooldown_elapsed?
|
|
495
|
+
return true if @last_evicted_at.nil?
|
|
496
|
+
|
|
497
|
+
wait = restore_cooldown_seconds
|
|
498
|
+
return true if monotonic_now - @last_evicted_at >= wait
|
|
499
|
+
|
|
500
|
+
unless @deferral_warned
|
|
501
|
+
@deferral_warned = true
|
|
502
|
+
Pgbus.logger.warn do
|
|
503
|
+
"[Pgbus] All queues evicted; deferring restore — next restore attempt in #{wait}s " \
|
|
504
|
+
"(streak=#{@restore_streak})"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
false
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def restore_cooldown_seconds
|
|
511
|
+
[RESTORE_COOLDOWN_BASE * (2**@restore_streak), RESTORE_COOLDOWN_MAX].min
|
|
512
|
+
end
|
|
513
|
+
|
|
353
514
|
def detect_zombie(queue_name, message)
|
|
354
515
|
return unless config.zombie_detection
|
|
355
516
|
return unless message.read_ct.to_i > 1
|
|
@@ -365,6 +526,20 @@ module Pgbus
|
|
|
365
526
|
Pgbus.logger.debug { "[Pgbus] Zombie detection failed: #{e.class}: #{e.message}" }
|
|
366
527
|
end
|
|
367
528
|
|
|
529
|
+
# Lazily stamps the drain start on first call — the predicate is only
|
|
530
|
+
# reached while draining, so this covers every path into the drain
|
|
531
|
+
# state (graceful_shutdown, recycling) without hooking each one.
|
|
532
|
+
def drain_deadline_exceeded?
|
|
533
|
+
@drain_started_at ||= monotonic_now
|
|
534
|
+
return false unless monotonic_now - @drain_started_at > DRAIN_TIMEOUT
|
|
535
|
+
|
|
536
|
+
Pgbus.logger.warn do
|
|
537
|
+
"[Pgbus] Worker drain deadline (#{DRAIN_TIMEOUT}s) reached with #{@in_flight.value} job(s) " \
|
|
538
|
+
"still in flight — proceeding to shutdown"
|
|
539
|
+
end
|
|
540
|
+
true
|
|
541
|
+
end
|
|
542
|
+
|
|
368
543
|
def check_recycle
|
|
369
544
|
return unless @lifecycle.running?
|
|
370
545
|
|
|
@@ -373,6 +548,9 @@ module Pgbus
|
|
|
373
548
|
|
|
374
549
|
Pgbus.stopping = true
|
|
375
550
|
@lifecycle.transition_to(:draining)
|
|
551
|
+
# Flush buffered stats on recycle-triggered drain for the same reason as
|
|
552
|
+
# graceful_shutdown: shrink the SIGKILL loss window. Same-thread, safe.
|
|
553
|
+
@stat_buffer&.flush
|
|
376
554
|
Pgbus::Instrumentation.instrument(
|
|
377
555
|
"pgbus.worker.recycle",
|
|
378
556
|
reason: reason,
|
|
@@ -416,16 +594,9 @@ module Pgbus
|
|
|
416
594
|
true
|
|
417
595
|
end
|
|
418
596
|
|
|
597
|
+
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
419
598
|
def current_memory_mb
|
|
420
|
-
|
|
421
|
-
`ps -o rss= -p #{::Process.pid}`.to_i / 1024
|
|
422
|
-
else
|
|
423
|
-
begin
|
|
424
|
-
File.read("/proc/#{::Process.pid}/statm").split[1].to_i * 4096 / (1024 * 1024)
|
|
425
|
-
rescue Errno::ENOENT
|
|
426
|
-
0
|
|
427
|
-
end
|
|
428
|
-
end
|
|
599
|
+
MemoryUsage.current_mb
|
|
429
600
|
end
|
|
430
601
|
|
|
431
602
|
def notify_wakeup?
|
|
@@ -433,7 +604,11 @@ module Pgbus
|
|
|
433
604
|
end
|
|
434
605
|
|
|
435
606
|
def wake_timeout
|
|
436
|
-
|
|
607
|
+
# A dead listener (running? false) will never wake the loop, so treat
|
|
608
|
+
# it as absent and keep polling at the short interval until
|
|
609
|
+
# ensure_notify_listener restarts it. Only a live listener earns the
|
|
610
|
+
# long NOTIFY-mode ceiling.
|
|
611
|
+
return effective_polling_interval unless notify_wakeup? && @notify_listener&.running?
|
|
437
612
|
|
|
438
613
|
[effective_polling_interval, config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
|
|
439
614
|
end
|
|
@@ -467,6 +642,42 @@ module Pgbus
|
|
|
467
642
|
end
|
|
468
643
|
end
|
|
469
644
|
|
|
645
|
+
# Self-heal a NotifyListener that never started or whose thread died.
|
|
646
|
+
# Called each loop iteration but gated by a monotonic backoff timestamp
|
|
647
|
+
# so a persistent outage retries on 5s→…→300s intervals, not every tick
|
|
648
|
+
# (mirrors refresh_wildcard_queues' throttle). A restarted listener has
|
|
649
|
+
# its queue subscription reconciled (wildcard workers) and the backoff
|
|
650
|
+
# reset; a still-failing restart doubles the backoff up to the cap.
|
|
651
|
+
def ensure_notify_listener
|
|
652
|
+
return unless notify_wakeup?
|
|
653
|
+
return if @notify_listener&.running?
|
|
654
|
+
return if monotonic_now < @notify_retry_at
|
|
655
|
+
|
|
656
|
+
stop_dead_notify_listener
|
|
657
|
+
start_notify_listener
|
|
658
|
+
|
|
659
|
+
if @notify_listener&.running?
|
|
660
|
+
sync_notify_listener_queues
|
|
661
|
+
@notify_retry_backoff = NOTIFY_RETRY_BASE_SECONDS
|
|
662
|
+
else
|
|
663
|
+
@notify_retry_backoff = [@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
|
|
664
|
+
end
|
|
665
|
+
@notify_retry_at = monotonic_now + @notify_retry_backoff
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Stop a listener whose thread died so its dedicated PG connection is
|
|
669
|
+
# released before start_notify_listener allocates a fresh one. A nil or
|
|
670
|
+
# still-running listener is left alone (the caller already gated on it).
|
|
671
|
+
def stop_dead_notify_listener
|
|
672
|
+
return if @notify_listener.nil?
|
|
673
|
+
|
|
674
|
+
@notify_listener.stop
|
|
675
|
+
rescue StandardError => e
|
|
676
|
+
Pgbus.logger.warn { "[Pgbus] Failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
|
|
677
|
+
ensure
|
|
678
|
+
@notify_listener = nil
|
|
679
|
+
end
|
|
680
|
+
|
|
470
681
|
def sync_notify_listener_queues
|
|
471
682
|
return unless @notify_listener
|
|
472
683
|
|
|
@@ -494,12 +705,49 @@ module Pgbus
|
|
|
494
705
|
queues: queues, threads: threads, pid: ::Process.pid,
|
|
495
706
|
execution_mode: @execution_mode, consumer_priority: @consumer_priority
|
|
496
707
|
},
|
|
497
|
-
on_beat: -> {
|
|
498
|
-
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
708
|
+
on_beat: -> { on_heartbeat },
|
|
709
|
+
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
710
|
+
metadata_supplier: -> { throughput_metadata }
|
|
499
711
|
)
|
|
500
712
|
@heartbeat.start
|
|
501
713
|
end
|
|
502
714
|
|
|
715
|
+
# Runs once per heartbeat interval (not per job), so it's the right place
|
|
716
|
+
# to snapshot the per-beat rate counters and emit connection-pool
|
|
717
|
+
# observability without touching any per-job hot path. Pool utilization
|
|
718
|
+
# goes out as a `pgbus.client.pool` event carrying {size:, available:,
|
|
719
|
+
# pool_timeout:}. Reading the pool must never crash the beat — pool_stats
|
|
720
|
+
# already rescues to {}, and this whole method is guarded so a listener or
|
|
721
|
+
# unexpected error can't take down the heartbeat thread.
|
|
722
|
+
def on_heartbeat
|
|
723
|
+
@rate_counter.snapshot!
|
|
724
|
+
emit_pool_stats
|
|
725
|
+
rescue StandardError => e
|
|
726
|
+
Pgbus.logger.debug { "[Pgbus] Worker heartbeat hook error: #{e.class}: #{e.message}" }
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def emit_pool_stats
|
|
730
|
+
stats = Pgbus.client.pool_stats
|
|
731
|
+
return if stats.empty?
|
|
732
|
+
|
|
733
|
+
Pgbus::Instrumentation.instrument("pgbus.client.pool", stats)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Per-worker throughput persisted into pgbus_processes.metadata on every
|
|
737
|
+
# heartbeat so the dashboard can show cluster-wide live rates. Called by
|
|
738
|
+
# the Heartbeat's metadata_supplier after on_beat (snapshot!) has
|
|
739
|
+
# refreshed the rate counter, so these rates are current. Keys are
|
|
740
|
+
# stringified because the value round-trips through JSON in the metadata
|
|
741
|
+
# column.
|
|
742
|
+
def throughput_metadata
|
|
743
|
+
{
|
|
744
|
+
"rates" => @rate_counter.rates.transform_keys(&:to_s),
|
|
745
|
+
"jobs_processed" => @jobs_processed.value,
|
|
746
|
+
"jobs_failed" => @jobs_failed.value,
|
|
747
|
+
"in_flight" => @in_flight.value
|
|
748
|
+
}
|
|
749
|
+
end
|
|
750
|
+
|
|
503
751
|
def shutdown
|
|
504
752
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
505
753
|
@notify_listener&.stop
|
|
@@ -520,8 +768,23 @@ module Pgbus
|
|
|
520
768
|
# Wall clock is required because the supervisor watchdog reads
|
|
521
769
|
# this value from a different process (cross-fork) and the
|
|
522
770
|
# dashboard reads it from a different host.
|
|
771
|
+
#
|
|
772
|
+
# Also pokes the OS-level liveness pipe (when the supervisor forked us
|
|
773
|
+
# with one) so the watchdog has a database-independent signal. The write
|
|
774
|
+
# is non-blocking and never raises in the hot path: exception: false
|
|
775
|
+
# returns :wait_writable on a full pipe (which itself proves recent,
|
|
776
|
+
# undrained ticks — liveness — so a dropped write is fine), and the
|
|
777
|
+
# rescue covers the reader-gone / fd-closed cases so a dead pipe can
|
|
778
|
+
# never crash the worker loop. The payload is a content-free byte: the
|
|
779
|
+
# supervisor treats "any bytes readable" as liveness and stamps arrival
|
|
780
|
+
# time on its own monotonic clock, so no timestamp crosses the fork.
|
|
523
781
|
def stamp_loop_tick
|
|
524
782
|
@loop_tick_at.set(Time.now.to_f)
|
|
783
|
+
return unless @liveness_pipe
|
|
784
|
+
|
|
785
|
+
@liveness_pipe.write_nonblock("\0", exception: false)
|
|
786
|
+
rescue Errno::EPIPE, IOError, Errno::EBADF
|
|
787
|
+
nil
|
|
525
788
|
end
|
|
526
789
|
end
|
|
527
790
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
module Recurring
|
|
5
7
|
class Scheduler
|
|
@@ -12,6 +14,7 @@ module Pgbus
|
|
|
12
14
|
@schedule = Schedule.new(config: config)
|
|
13
15
|
@shutting_down = false
|
|
14
16
|
@last_runs = {}
|
|
17
|
+
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def run
|
|
@@ -25,6 +28,7 @@ module Pgbus
|
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
loop do
|
|
31
|
+
stamp_loop_tick
|
|
28
32
|
break if @shutting_down
|
|
29
33
|
|
|
30
34
|
process_signals
|
|
@@ -100,10 +104,20 @@ module Pgbus
|
|
|
100
104
|
Pgbus.logger.error { "[Pgbus] Failed to sync recurring tasks: #{e.class}: #{e.message}" }
|
|
101
105
|
end
|
|
102
106
|
|
|
107
|
+
# Wall clock (not monotonic) so the dashboard can compare the beacon
|
|
108
|
+
# against Time.now across processes; the heartbeat timer thread reads it
|
|
109
|
+
# via loop_tick_supplier. A scheduler wedged inside tick stops advancing
|
|
110
|
+
# this while heartbeats keep firing, so the beacon ages and the processes
|
|
111
|
+
# page can surface the stall.
|
|
112
|
+
def stamp_loop_tick
|
|
113
|
+
@loop_tick_at.set(Time.now.to_f)
|
|
114
|
+
end
|
|
115
|
+
|
|
103
116
|
def start_heartbeat
|
|
104
117
|
@heartbeat = Process::Heartbeat.new(
|
|
105
118
|
kind: "scheduler",
|
|
106
|
-
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size }
|
|
119
|
+
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size },
|
|
120
|
+
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
107
121
|
)
|
|
108
122
|
@heartbeat.start
|
|
109
123
|
end
|
|
@@ -37,11 +37,13 @@ module Pgbus
|
|
|
37
37
|
def broadcast_stream_to(*streamables, content:)
|
|
38
38
|
name = stream_name_from(streamables)
|
|
39
39
|
override = Thread.current[:pgbus_broadcast_durable]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
# When no explicit thread-local override is present, let the config
|
|
41
|
+
# resolver decide: it checks `streams_durable_patterns` first (exact
|
|
42
|
+
# string or regex match), then falls back to
|
|
43
|
+
# `streams_default_broadcast_mode`. Passing this through keeps
|
|
44
|
+
# pattern-based routing alive for the whole Turbo::Broadcastable and
|
|
45
|
+
# phlex-reactive broadcast path (see issue #267).
|
|
46
|
+
durable = override.nil? ? Pgbus.configuration.stream_durable?(name) : override
|
|
45
47
|
Pgbus.stream(name, durable: durable).broadcast(
|
|
46
48
|
content,
|
|
47
49
|
exclude: Thread.current[:pgbus_broadcast_exclude],
|
|
@@ -80,12 +80,23 @@ module Pgbus
|
|
|
80
80
|
"REINDEX TABLE CONCURRENTLY \"#{schema}\".\"#{relname}\""
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# stop_check is polled before each candidate; when it returns true the
|
|
84
|
+
# loop stops before touching the next table so a mid-pass shutdown does
|
|
85
|
+
# not block on VACUUM/REINDEX of the remaining tables. The default never
|
|
86
|
+
# stops, so callers that don't pass it are unaffected.
|
|
87
|
+
def run_maintenance(conn, threshold: BLOAT_THRESHOLD, reindex: true, stop_check: -> { false })
|
|
84
88
|
candidates = vacuum_candidates(conn, threshold: threshold)
|
|
85
89
|
return 0 if candidates.empty?
|
|
86
90
|
|
|
87
91
|
maintained = 0
|
|
88
|
-
candidates.
|
|
92
|
+
candidates.each_with_index do |candidate, index|
|
|
93
|
+
if stop_check.call
|
|
94
|
+
Pgbus.logger.info do
|
|
95
|
+
"[Pgbus::TableMaintenance] Maintenance interrupted by shutdown after #{index} of #{candidates.size}"
|
|
96
|
+
end
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
|
|
89
100
|
table = candidate[:table]
|
|
90
101
|
Pgbus.logger.info do
|
|
91
102
|
"[Pgbus::TableMaintenance] Vacuuming #{table} " \
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -1234,18 +1234,34 @@ module Pgbus
|
|
|
1234
1234
|
|
|
1235
1235
|
def derive_process_status(stale, metadata, kind)
|
|
1236
1236
|
return :stale if stale
|
|
1237
|
-
return :healthy unless
|
|
1237
|
+
return :healthy unless metadata.is_a?(Hash)
|
|
1238
|
+
|
|
1239
|
+
threshold = stall_threshold_for(kind)
|
|
1240
|
+
return :healthy unless threshold
|
|
1238
1241
|
|
|
1239
1242
|
loop_tick = metadata["loop_tick_at"]
|
|
1240
1243
|
return :healthy unless loop_tick
|
|
1241
1244
|
|
|
1242
|
-
threshold = Pgbus.configuration.stall_threshold
|
|
1243
|
-
return :healthy unless threshold&.positive?
|
|
1244
|
-
|
|
1245
1245
|
age = Time.now.to_f - loop_tick.to_f
|
|
1246
1246
|
age > threshold ? :stalled : :healthy
|
|
1247
1247
|
end
|
|
1248
1248
|
|
|
1249
|
+
# Kind-aware stall threshold. Workers run a tight loop, so the base
|
|
1250
|
+
# stall_threshold applies directly. The dispatcher and scheduler sleep
|
|
1251
|
+
# between iterations, so their beacon is naturally up to one sleep
|
|
1252
|
+
# interval stale even when healthy — widen the window by that interval to
|
|
1253
|
+
# avoid false stalls. Kinds with no loop beacon (nil) are never stalled.
|
|
1254
|
+
def stall_threshold_for(kind)
|
|
1255
|
+
base = Pgbus.configuration.stall_threshold
|
|
1256
|
+
return nil unless base&.positive?
|
|
1257
|
+
|
|
1258
|
+
case kind
|
|
1259
|
+
when "worker" then base
|
|
1260
|
+
when "dispatcher" then base + Pgbus.configuration.dispatch_interval
|
|
1261
|
+
when "scheduler" then base + Pgbus.configuration.recurring_schedule_interval
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1249
1265
|
def format_batch(record)
|
|
1250
1266
|
total = record.total_jobs
|
|
1251
1267
|
done = record.completed_jobs + record.discarded_jobs
|