pgbus 0.9.7 → 0.9.9

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +454 -25
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  20. data/lib/pgbus/active_job/adapter.rb +1 -1
  21. data/lib/pgbus/active_job/executor.rb +25 -4
  22. data/lib/pgbus/cli/dlq.rb +164 -0
  23. data/lib/pgbus/cli.rb +18 -1
  24. data/lib/pgbus/client/connection_health.rb +194 -0
  25. data/lib/pgbus/client.rb +592 -73
  26. data/lib/pgbus/config_loader.rb +50 -4
  27. data/lib/pgbus/configuration.rb +294 -79
  28. data/lib/pgbus/dedup_cache.rb +8 -0
  29. data/lib/pgbus/doctor.rb +275 -0
  30. data/lib/pgbus/engine.rb +15 -0
  31. data/lib/pgbus/execution_pools/async_pool.rb +9 -2
  32. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  33. data/lib/pgbus/generators/config_converter.rb +7 -5
  34. data/lib/pgbus/generators/migration_detector.rb +20 -16
  35. data/lib/pgbus/instrumentation.rb +3 -1
  36. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  37. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  38. data/lib/pgbus/metrics/backend.rb +38 -0
  39. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  40. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  41. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  42. data/lib/pgbus/metrics/subscriber.rb +214 -0
  43. data/lib/pgbus/metrics.rb +43 -0
  44. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  45. data/lib/pgbus/pgmq_schema.rb +7 -2
  46. data/lib/pgbus/process/consumer.rb +354 -18
  47. data/lib/pgbus/process/consumer_priority.rb +34 -0
  48. data/lib/pgbus/process/dispatcher.rb +265 -41
  49. data/lib/pgbus/process/heartbeat.rb +18 -5
  50. data/lib/pgbus/process/memory_usage.rb +48 -0
  51. data/lib/pgbus/process/notify_listener.rb +26 -7
  52. data/lib/pgbus/process/notify_probe.rb +96 -0
  53. data/lib/pgbus/process/primary_validator.rb +53 -0
  54. data/lib/pgbus/process/signal_handler.rb +6 -0
  55. data/lib/pgbus/process/supervisor.rb +423 -50
  56. data/lib/pgbus/process/worker.rb +288 -35
  57. data/lib/pgbus/recurring/schedule.rb +1 -2
  58. data/lib/pgbus/recurring/scheduler.rb +15 -1
  59. data/lib/pgbus/serializer.rb +4 -4
  60. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  61. data/lib/pgbus/streams/signed_name.rb +2 -2
  62. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  63. data/lib/pgbus/table_maintenance.rb +13 -2
  64. data/lib/pgbus/uniqueness.rb +11 -12
  65. data/lib/pgbus/version.rb +1 -1
  66. data/lib/pgbus/web/data_source.rb +36 -4
  67. data/lib/pgbus/web/health_app.rb +102 -0
  68. data/lib/pgbus/web/health_server.rb +144 -0
  69. data/lib/pgbus/web/payload_filter.rb +3 -3
  70. data/lib/pgbus/web/streamer/instance.rb +58 -2
  71. data/lib/pgbus/web/streamer/listener.rb +69 -21
  72. data/lib/pgbus.rb +77 -0
  73. data/lib/tasks/pgbus_doctor.rake +12 -0
  74. metadata +19 -4
  75. data/app/models/pgbus/job_lock.rb +0 -98
  76. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
@@ -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,37 @@ 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 the
145
+ # CircuitBreaker/Dispatcher precedent.
146
+ NOTIFY_RETRY_BASE_SECONDS = 5
147
+ NOTIFY_RETRY_MAX_SECONDS = 300
148
+
74
149
  def run
75
150
  setup_signals
76
151
  start_heartbeat
@@ -87,9 +162,14 @@ module Pgbus
87
162
  process_signals
88
163
  check_recycle
89
164
  refresh_wildcard_queues
165
+ ensure_notify_listener
90
166
 
91
167
  break if @lifecycle.stopped?
92
- break if @lifecycle.draining? && @pool.idle?
168
+ # quiesced? (all slots free), not idle? (any slot free) — exiting
169
+ # with work still in flight abandons those jobs to the 30s
170
+ # wait_for_termination timeout in shutdown. Bounded by
171
+ # config.drain_timeout so a stuck job can't wedge the loop forever.
172
+ break if @lifecycle.draining? && (@pool.quiesced? || drain_deadline_exceeded?)
93
173
 
94
174
  claim_and_execute if @lifecycle.can_process?
95
175
  @stat_buffer&.flush_if_due
@@ -103,6 +183,13 @@ module Pgbus
103
183
  Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
104
184
  Pgbus.stopping = true
105
185
  @lifecycle.transition_to(:draining)
186
+ # Flush buffered stats at drain entry so the ≤ stats_flush_interval /
187
+ # stats_flush_size window isn't lost if the supervisor watchdog SIGKILLs
188
+ # a stalled worker before the drain-loop shutdown flush runs. Runs on the
189
+ # main loop thread (signals are dispatched via process_signals, not in
190
+ # trap context), so the DB write is safe. flush is thread-safe and
191
+ # no-ops when the buffer is empty.
192
+ @stat_buffer&.flush
106
193
  @wake_signal.notify!
107
194
  end
108
195
 
@@ -121,6 +208,15 @@ module Pgbus
121
208
  # regex on every queue-missing error in hot fetch/read paths.
122
209
  MISSING_QUEUE_REGEX = /pgmq\.q_(\w+)/
123
210
 
211
+ # Exponential backoff bounds for restoring evicted queues (issue #209).
212
+ # After the worker's queues are fully evicted (queue table permanently
213
+ # deleted), the first restore waits RESTORE_COOLDOWN_BASE seconds; each
214
+ # consecutive failed restore doubles the wait up to RESTORE_COOLDOWN_MAX.
215
+ # A successful fetch resets the streak so a recreated queue restores
216
+ # promptly. Constant-tuned, matching the NOTIFY_RETRY precedent.
217
+ RESTORE_COOLDOWN_BASE = 30
218
+ RESTORE_COOLDOWN_MAX = 300
219
+
124
220
  private
125
221
 
126
222
  def claim_and_execute
@@ -168,17 +264,33 @@ module Pgbus
168
264
  return []
169
265
  end
170
266
 
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
267
+ results =
268
+ if priority_enabled?
269
+ fetch_prioritized(active_queues, qty)
270
+ elsif @group_mode
271
+ fetch_grouped(active_queues, qty)
272
+ elsif active_queues.size == 1
273
+ queue = active_queues.first
274
+ messages = Pgbus.client.read_batch(queue, qty: qty) || []
275
+ messages.map { |m| [queue, m] }
276
+ else
277
+ fetch_multi(active_queues, qty)
278
+ end
279
+
280
+ # A read that reached here without an undefined-queue error means the
281
+ # queue tables exist again; drop the restore backoff so a recreated
282
+ # queue is reinstated promptly after the next eviction (issue #209).
283
+ @restore_streak = 0
284
+ results
285
+ rescue Pgbus::ConnectionCircuitOpenError
286
+ # The client-level connection breaker is open: the database has failed
287
+ # enough consecutive connection attempts that reads now fail fast. Idle
288
+ # this poll without an ErrorReporter call — the whole point of the
289
+ # breaker is to stop every worker flooding the error tracker for the
290
+ # duration of a database outage. The open/close transitions are logged
291
+ # once by the client (Client#log_circuit_open / #log_circuit_close),
292
+ # not per poll here.
293
+ []
182
294
  rescue StandardError => e
183
295
  if undefined_queue_table_error?(e)
184
296
  evict_missing_queues(e)
@@ -339,17 +451,56 @@ module Pgbus
339
451
  end
340
452
  end
341
453
  Pgbus.logger.error { "[Pgbus] Queue table missing: #{error.message}" }
342
- restore_evicted_queues if @queues.empty? && !@wildcard
343
- end
344
-
454
+ return unless @queues.empty? && !@wildcard
455
+
456
+ # Open (or re-arm) the restore cooldown window: stamp the eviction time so
457
+ # restore_evicted_queues can measure the backoff, and clear the
458
+ # per-window deferral-warned flag so the next deferral logs exactly once.
459
+ @last_evicted_at = monotonic_now
460
+ @deferral_warned = false
461
+ restore_evicted_queues
462
+ end
463
+
464
+ # Restore the worker's initial queues after a full eviction — but only once
465
+ # the exponential cooldown has elapsed (issue #209). While the cooldown is
466
+ # pending, leaves @queues empty (the caller idles via the empty-active_queues
467
+ # path) and logs a single deferral warn per window rather than one error pair
468
+ # per loop tick. Each actual restore escalates the streak so a permanently
469
+ # deleted queue backs off toward RESTORE_COOLDOWN_MAX instead of spinning.
345
470
  def restore_evicted_queues
471
+ return unless restore_cooldown_elapsed?
472
+
346
473
  @queues = @initial_queues.dup
474
+ @restore_streak += 1
347
475
  Pgbus.logger.warn do
348
476
  "[Pgbus] Worker queue list was empty after eviction — " \
349
477
  "restoring initial queues: #{@queues.join(", ")}"
350
478
  end
351
479
  end
352
480
 
481
+ # True once RESTORE_COOLDOWN_BASE * 2**streak seconds (capped at
482
+ # RESTORE_COOLDOWN_MAX) have passed since the window opened. When still
483
+ # pending, emits at most one deferral warn per window naming the wait.
484
+ def restore_cooldown_elapsed?
485
+ return true if @last_evicted_at.nil?
486
+
487
+ wait = restore_cooldown_seconds
488
+ return true if monotonic_now - @last_evicted_at >= wait
489
+
490
+ unless @deferral_warned
491
+ @deferral_warned = true
492
+ Pgbus.logger.warn do
493
+ "[Pgbus] All queues evicted; deferring restore — next restore attempt in #{wait}s " \
494
+ "(streak=#{@restore_streak})"
495
+ end
496
+ end
497
+ false
498
+ end
499
+
500
+ def restore_cooldown_seconds
501
+ [RESTORE_COOLDOWN_BASE * (2**@restore_streak), RESTORE_COOLDOWN_MAX].min
502
+ end
503
+
353
504
  def detect_zombie(queue_name, message)
354
505
  return unless config.zombie_detection
355
506
  return unless message.read_ct.to_i > 1
@@ -365,6 +516,20 @@ module Pgbus
365
516
  Pgbus.logger.debug { "[Pgbus] Zombie detection failed: #{e.class}: #{e.message}" }
366
517
  end
367
518
 
519
+ # Lazily stamps the drain start on first call — the predicate is only
520
+ # reached while draining, so this covers every path into the drain
521
+ # state (graceful_shutdown, recycling) without hooking each one.
522
+ def drain_deadline_exceeded?
523
+ @drain_started_at ||= monotonic_now
524
+ return false unless monotonic_now - @drain_started_at > config.drain_timeout
525
+
526
+ Pgbus.logger.warn do
527
+ "[Pgbus] Worker drain deadline (#{config.drain_timeout}s) reached with #{@in_flight.value} job(s) " \
528
+ "still in flight — proceeding to shutdown"
529
+ end
530
+ true
531
+ end
532
+
368
533
  def check_recycle
369
534
  return unless @lifecycle.running?
370
535
 
@@ -373,6 +538,9 @@ module Pgbus
373
538
 
374
539
  Pgbus.stopping = true
375
540
  @lifecycle.transition_to(:draining)
541
+ # Flush buffered stats on recycle-triggered drain for the same reason as
542
+ # graceful_shutdown: shrink the SIGKILL loss window. Same-thread, safe.
543
+ @stat_buffer&.flush
376
544
  Pgbus::Instrumentation.instrument(
377
545
  "pgbus.worker.recycle",
378
546
  reason: reason,
@@ -416,16 +584,9 @@ module Pgbus
416
584
  true
417
585
  end
418
586
 
587
+ # Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
419
588
  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
589
+ MemoryUsage.current_mb
429
590
  end
430
591
 
431
592
  def notify_wakeup?
@@ -433,7 +594,11 @@ module Pgbus
433
594
  end
434
595
 
435
596
  def wake_timeout
436
- return effective_polling_interval unless notify_wakeup? && @notify_listener
597
+ # A dead listener (running? false) will never wake the loop, so treat
598
+ # it as absent and keep polling at the short interval until
599
+ # ensure_notify_listener restarts it. Only a live listener earns the
600
+ # long NOTIFY-mode ceiling.
601
+ return effective_polling_interval unless notify_wakeup? && @notify_listener&.running?
437
602
 
438
603
  [effective_polling_interval, config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
439
604
  end
@@ -467,6 +632,42 @@ module Pgbus
467
632
  end
468
633
  end
469
634
 
635
+ # Self-heal a NotifyListener that never started or whose thread died.
636
+ # Called each loop iteration but gated by a monotonic backoff timestamp
637
+ # so a persistent outage retries on 5s→…→300s intervals, not every tick
638
+ # (mirrors refresh_wildcard_queues' throttle). A restarted listener has
639
+ # its queue subscription reconciled (wildcard workers) and the backoff
640
+ # reset; a still-failing restart doubles the backoff up to the cap.
641
+ def ensure_notify_listener
642
+ return unless notify_wakeup?
643
+ return if @notify_listener&.running?
644
+ return if monotonic_now < @notify_retry_at
645
+
646
+ stop_dead_notify_listener
647
+ start_notify_listener
648
+
649
+ if @notify_listener&.running?
650
+ sync_notify_listener_queues
651
+ @notify_retry_backoff = NOTIFY_RETRY_BASE_SECONDS
652
+ else
653
+ @notify_retry_backoff = [@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
654
+ end
655
+ @notify_retry_at = monotonic_now + @notify_retry_backoff
656
+ end
657
+
658
+ # Stop a listener whose thread died so its dedicated PG connection is
659
+ # released before start_notify_listener allocates a fresh one. A nil or
660
+ # still-running listener is left alone (the caller already gated on it).
661
+ def stop_dead_notify_listener
662
+ return if @notify_listener.nil?
663
+
664
+ @notify_listener.stop
665
+ rescue StandardError => e
666
+ Pgbus.logger.warn { "[Pgbus] Failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
667
+ ensure
668
+ @notify_listener = nil
669
+ end
670
+
470
671
  def sync_notify_listener_queues
471
672
  return unless @notify_listener
472
673
 
@@ -494,12 +695,49 @@ module Pgbus
494
695
  queues: queues, threads: threads, pid: ::Process.pid,
495
696
  execution_mode: @execution_mode, consumer_priority: @consumer_priority
496
697
  },
497
- on_beat: -> { @rate_counter.snapshot! },
498
- loop_tick_supplier: -> { @loop_tick_at.get }
698
+ on_beat: -> { on_heartbeat },
699
+ loop_tick_supplier: -> { @loop_tick_at.get },
700
+ metadata_supplier: -> { throughput_metadata }
499
701
  )
500
702
  @heartbeat.start
501
703
  end
502
704
 
705
+ # Runs once per heartbeat interval (not per job), so it's the right place
706
+ # to snapshot the per-beat rate counters and emit connection-pool
707
+ # observability without touching any per-job hot path. Pool utilization
708
+ # goes out as a `pgbus.client.pool` event carrying {size:, available:,
709
+ # pool_timeout:}. Reading the pool must never crash the beat — pool_stats
710
+ # already rescues to {}, and this whole method is guarded so a listener or
711
+ # unexpected error can't take down the heartbeat thread.
712
+ def on_heartbeat
713
+ @rate_counter.snapshot!
714
+ emit_pool_stats
715
+ rescue StandardError => e
716
+ Pgbus.logger.debug { "[Pgbus] Worker heartbeat hook error: #{e.class}: #{e.message}" }
717
+ end
718
+
719
+ def emit_pool_stats
720
+ stats = Pgbus.client.pool_stats
721
+ return if stats.empty?
722
+
723
+ Pgbus::Instrumentation.instrument("pgbus.client.pool", stats)
724
+ end
725
+
726
+ # Per-worker throughput persisted into pgbus_processes.metadata on every
727
+ # heartbeat so the dashboard can show cluster-wide live rates. Called by
728
+ # the Heartbeat's metadata_supplier after on_beat (snapshot!) has
729
+ # refreshed the rate counter, so these rates are current. Keys are
730
+ # stringified because the value round-trips through JSON in the metadata
731
+ # column.
732
+ def throughput_metadata
733
+ {
734
+ "rates" => @rate_counter.rates.transform_keys(&:to_s),
735
+ "jobs_processed" => @jobs_processed.value,
736
+ "jobs_failed" => @jobs_failed.value,
737
+ "in_flight" => @in_flight.value
738
+ }
739
+ end
740
+
503
741
  def shutdown
504
742
  Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
505
743
  @notify_listener&.stop
@@ -520,8 +758,23 @@ module Pgbus
520
758
  # Wall clock is required because the supervisor watchdog reads
521
759
  # this value from a different process (cross-fork) and the
522
760
  # dashboard reads it from a different host.
761
+ #
762
+ # Also pokes the OS-level liveness pipe (when the supervisor forked us
763
+ # with one) so the watchdog has a database-independent signal. The write
764
+ # is non-blocking and never raises in the hot path: exception: false
765
+ # returns :wait_writable on a full pipe (which itself proves recent,
766
+ # undrained ticks — liveness — so a dropped write is fine), and the
767
+ # rescue covers the reader-gone / fd-closed cases so a dead pipe can
768
+ # never crash the worker loop. The payload is a content-free byte: the
769
+ # supervisor treats "any bytes readable" as liveness and stamps arrival
770
+ # time on its own monotonic clock, so no timestamp crosses the fork.
523
771
  def stamp_loop_tick
524
772
  @loop_tick_at.set(Time.now.to_f)
773
+ return unless @liveness_pipe
774
+
775
+ @liveness_pipe.write_nonblock("\0", exception: false)
776
+ rescue Errno::EPIPE, IOError, Errno::EBADF
777
+ nil
525
778
  end
526
779
  end
527
780
  end
@@ -202,8 +202,7 @@ module Pgbus
202
202
 
203
203
  payload.merge(
204
204
  Pgbus::Uniqueness::METADATA_KEY => key,
205
- Pgbus::Uniqueness::STRATEGY_KEY => config[:strategy].to_s,
206
- Pgbus::Uniqueness::TTL_KEY => config[:lock_ttl]
205
+ Pgbus::Uniqueness::STRATEGY_KEY => config[:strategy].to_s
207
206
  )
208
207
  end
209
208
  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
@@ -55,21 +55,21 @@ module Pgbus
55
55
  # can be resolved — prevents loading arbitrary objects from crafted payloads.
56
56
  def locate_global_id(gid_string)
57
57
  gid = GlobalID.parse(gid_string)
58
- raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
58
+ raise Pgbus::SerializationError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
59
 
60
60
  allowed = Pgbus.configuration.allowed_global_id_models
61
61
  if allowed && allowed.empty?
62
- raise ArgumentError,
62
+ raise Pgbus::SerializationError,
63
63
  "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
64
64
  "Set to nil to allow all models, or add permitted classes."
65
65
  end
66
66
  if allowed&.any? { |entry| !entry.is_a?(Class) && !entry.is_a?(Module) }
67
- raise ArgumentError,
67
+ raise Pgbus::SerializationError,
68
68
  "allowed_global_id_models must contain Class/Module objects, " \
69
69
  "got: #{allowed.map(&:class).uniq.join(", ")}"
70
70
  end
71
71
  if allowed&.none? { |klass| gid.model_class <= klass }
72
- raise ArgumentError,
72
+ raise Pgbus::SerializationError,
73
73
  "GlobalID model #{gid.model_class} is not in allowed_global_id_models. " \
74
74
  "Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
75
75
  end
@@ -155,14 +155,6 @@ module Pgbus
155
155
  ensure
156
156
  previous.each { |tl_key, value| Thread.current[tl_key] = value }
157
157
  end
158
-
159
- # Kept for backward compatibility — the class-level durable callbacks
160
- # (broadcasts_to with durable:) and any external callers may still use it.
161
- def with_pgbus_durable(value, &)
162
- return yield if value.nil?
163
-
164
- with_pgbus_broadcast_opts(durable: value, &)
165
- end
166
158
  end
167
159
  end
168
160
  end
@@ -14,10 +14,10 @@ module Pgbus
14
14
  # `"gid://app/Order/42:messages"`). Verification returns that string;
15
15
  # tampered or unsigned input raises `InvalidSignedName`.
16
16
  module SignedName
17
- class InvalidSignedName < StandardError
17
+ class InvalidSignedName < Pgbus::Error
18
18
  end
19
19
 
20
- class MissingSecret < StandardError
20
+ class MissingSecret < Pgbus::Error
21
21
  end
22
22
 
23
23
  def self.verify!(token)
@@ -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} " \
@@ -43,28 +43,28 @@ module Pgbus
43
43
 
44
44
  METADATA_KEY = "pgbus_uniqueness_key"
45
45
  STRATEGY_KEY = "pgbus_uniqueness_strategy"
46
- TTL_KEY = "pgbus_uniqueness_lock_ttl"
47
-
48
- # TTL is kept for metadata compatibility but no longer drives lock expiry.
49
- # The lock exists until the job completes or is dead-lettered.
50
- DEFAULT_LOCK_TTL = 24 * 60 * 60
51
46
 
52
47
  VALID_STRATEGIES = %i[until_executed while_executing].freeze
53
48
  VALID_CONFLICTS = %i[reject discard log].freeze
54
49
 
55
50
  class_methods do
56
- def ensures_uniqueness(strategy: :until_executed, key: nil, lock_ttl: DEFAULT_LOCK_TTL, on_conflict: :reject)
51
+ def ensures_uniqueness(strategy: :until_executed, key: nil, on_conflict: :reject, **opts)
52
+ # lock_ttl was validated and stored in message metadata but never read
53
+ # by the lock lifecycle (the lock lives until the job completes or is
54
+ # dead-lettered, not until a TTL expires). Removed in 1.0.0.
55
+ if opts.key?(:lock_ttl)
56
+ raise ArgumentError,
57
+ "lock_ttl: was removed in pgbus 1.0.0 — it was validated but never read by anything. " \
58
+ "Remove it from ensures_uniqueness. See https://pgbus.dev/docs/upgrading-pgbus"
59
+ end
60
+ raise ArgumentError, "unknown keyword: #{opts.keys.first.inspect}" if opts.any?
57
61
  raise ArgumentError, "strategy must be one of: #{VALID_STRATEGIES.join(", ")}" unless VALID_STRATEGIES.include?(strategy)
58
62
  raise ArgumentError, "on_conflict must be one of: #{VALID_CONFLICTS.join(", ")}" unless VALID_CONFLICTS.include?(on_conflict)
59
-
60
- valid_ttl = lock_ttl.is_a?(Numeric) || (defined?(ActiveSupport::Duration) && lock_ttl.is_a?(ActiveSupport::Duration))
61
- raise ArgumentError, "lock_ttl must be a positive number or Duration" unless valid_ttl && lock_ttl.positive?
62
63
  raise ArgumentError, "key must be callable (Proc or lambda)" if key && !key.respond_to?(:call)
63
64
 
64
65
  @pgbus_uniqueness = {
65
66
  strategy: strategy,
66
67
  key: key || ->(*) { name },
67
- lock_ttl: lock_ttl,
68
68
  on_conflict: on_conflict
69
69
  }.freeze
70
70
  end
@@ -102,8 +102,7 @@ module Pgbus
102
102
 
103
103
  payload_hash.merge(
104
104
  METADATA_KEY => key,
105
- STRATEGY_KEY => config[:strategy].to_s,
106
- TTL_KEY => config[:lock_ttl]
105
+ STRATEGY_KEY => config[:strategy].to_s
107
106
  )
108
107
  end
109
108
 
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.7"
4
+ VERSION = "0.9.9"
5
5
  end