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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +119 -1
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +45 -6
  6. data/app/views/pgbus/jobs/show.html.erb +1 -1
  7. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  8. data/config/locales/da.yml +4 -0
  9. data/config/locales/de.yml +4 -0
  10. data/config/locales/en.yml +4 -0
  11. data/config/locales/es.yml +4 -0
  12. data/config/locales/fi.yml +4 -0
  13. data/config/locales/fr.yml +4 -0
  14. data/config/locales/it.yml +4 -0
  15. data/config/locales/ja.yml +4 -0
  16. data/config/locales/nb.yml +4 -0
  17. data/config/locales/nl.yml +4 -0
  18. data/config/locales/pt.yml +4 -0
  19. data/config/locales/sv.yml +4 -0
  20. data/lib/pgbus/active_job/executor.rb +25 -4
  21. data/lib/pgbus/cli/dlq.rb +164 -0
  22. data/lib/pgbus/cli.rb +18 -1
  23. data/lib/pgbus/client/connection_health.rb +194 -0
  24. data/lib/pgbus/client.rb +592 -73
  25. data/lib/pgbus/config_loader.rb +23 -4
  26. data/lib/pgbus/configuration.rb +102 -13
  27. data/lib/pgbus/dedup_cache.rb +8 -0
  28. data/lib/pgbus/doctor.rb +250 -0
  29. data/lib/pgbus/engine.rb +15 -0
  30. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  31. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  32. data/lib/pgbus/instrumentation.rb +1 -0
  33. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  34. data/lib/pgbus/metrics/backend.rb +38 -0
  35. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  36. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  37. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  38. data/lib/pgbus/metrics/subscriber.rb +190 -0
  39. data/lib/pgbus/metrics.rb +42 -0
  40. data/lib/pgbus/process/consumer.rb +215 -8
  41. data/lib/pgbus/process/consumer_priority.rb +34 -0
  42. data/lib/pgbus/process/dispatcher.rb +265 -41
  43. data/lib/pgbus/process/heartbeat.rb +18 -5
  44. data/lib/pgbus/process/memory_usage.rb +48 -0
  45. data/lib/pgbus/process/notify_listener.rb +26 -7
  46. data/lib/pgbus/process/notify_probe.rb +96 -0
  47. data/lib/pgbus/process/primary_validator.rb +53 -0
  48. data/lib/pgbus/process/signal_handler.rb +6 -0
  49. data/lib/pgbus/process/supervisor.rb +396 -46
  50. data/lib/pgbus/process/worker.rb +298 -35
  51. data/lib/pgbus/recurring/scheduler.rb +15 -1
  52. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  53. data/lib/pgbus/table_maintenance.rb +13 -2
  54. data/lib/pgbus/version.rb +1 -1
  55. data/lib/pgbus/web/data_source.rb +20 -4
  56. data/lib/pgbus/web/health_app.rb +102 -0
  57. data/lib/pgbus/web/health_server.rb +144 -0
  58. data/lib/pgbus/web/payload_filter.rb +68 -0
  59. data/lib/pgbus/web/streamer/instance.rb +55 -1
  60. data/lib/pgbus/web/streamer/listener.rb +72 -9
  61. data/lib/pgbus.rb +37 -0
  62. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  63. data/lib/rubocop/pgbus.rb +5 -0
  64. data/lib/tasks/pgbus_doctor.rake +12 -0
  65. metadata +19 -1
@@ -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
- @stat_buffer = config.stats_enabled ? Pgbus::StatBuffer.new : nil
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 = nil
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
- break if @lifecycle.draining? && @pool.idle?
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
- if priority_enabled?
172
- fetch_prioritized(active_queues, qty)
173
- elsif @group_mode
174
- fetch_grouped(active_queues, qty)
175
- elsif active_queues.size == 1
176
- queue = active_queues.first
177
- messages = Pgbus.client.read_batch(queue, qty: qty) || []
178
- messages.map { |m| [queue, m] }
179
- else
180
- fetch_multi(active_queues, qty)
181
- end
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
- restore_evicted_queues if @queues.empty? && !@wildcard
343
- end
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
- if RUBY_PLATFORM.include?("darwin")
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
- return effective_polling_interval unless notify_wakeup? && @notify_listener
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: -> { @rate_counter.snapshot! },
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
- durable = if override.nil?
41
- Pgbus.configuration.streams_default_broadcast_mode == :durable
42
- else
43
- override
44
- end
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
- def run_maintenance(conn, threshold: BLOAT_THRESHOLD, reindex: true)
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.each do |candidate|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.9.6"
4
+ VERSION = "0.9.8"
5
5
  end
@@ -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 kind == "worker" && metadata.is_a?(Hash)
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