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
@@ -11,7 +11,7 @@ module Pgbus
11
11
  # Vendored SQL files live in lib/pgbus/pgmq_schema/pgmq_v{VERSION}.sql
12
12
  # and are exact copies of the upstream pgmq-extension/sql/pgmq.sql at each release.
13
13
  module PgmqSchema
14
- class VersionNotFoundError < StandardError; end
14
+ class VersionNotFoundError < Pgbus::Error; end
15
15
 
16
16
  SCHEMA_DIR = File.expand_path("pgmq_schema", __dir__).freeze
17
17
 
@@ -112,7 +112,11 @@ module Pgbus
112
112
  EXECUTE 'DROP FUNCTION IF EXISTS ' || r.func_sig || ' CASCADE';
113
113
  END LOOP;
114
114
 
115
- -- Drop custom types in pgmq schema
115
+ -- Drop custom composite types in pgmq schema (e.g. message_record,
116
+ -- queue_record, metrics_result). Standalone `CREATE TYPE ... AS (...)`
117
+ -- types get a pg_class shell with relkind = 'c'; only skip row-types
118
+ -- backed by an actual relation (table/view/etc.), which must be
119
+ -- dropped via DROP TABLE rather than DROP TYPE.
116
120
  FOR r IN
117
121
  SELECT n.nspname || '.' || t.typname AS type_name
118
122
  FROM pg_catalog.pg_type t
@@ -122,6 +126,7 @@ module Pgbus
122
126
  AND NOT EXISTS (
123
127
  SELECT 1 FROM pg_catalog.pg_class c
124
128
  WHERE c.reltype = t.oid
129
+ AND c.relkind <> 'c'
125
130
  )
126
131
  LOOP
127
132
  EXECUTE 'DROP TYPE IF EXISTS ' || r.type_name || ' CASCADE';
@@ -7,29 +7,125 @@ module Pgbus
7
7
  class Consumer
8
8
  include SignalHandler
9
9
 
10
- attr_reader :topics, :threads, :config, :execution_mode
10
+ attr_reader :topics, :threads, :config, :execution_mode,
11
+ :queue_names, :wake_signal, :notify_retry_backoff, :circuit_breaker
12
+ # notify_listener is writable so tests can simulate a start_notify_listener
13
+ # success from inside a stub (production sets it in start_notify_listener).
14
+ # notify_retry_at is writable so a test can re-arm the backoff window
15
+ # between successive ensure_notify_listener calls.
16
+ attr_accessor :notify_listener, :notify_retry_at
17
+ # stat_buffer is writable so a test can swap in a buffer double after
18
+ # construction and assert graceful_shutdown / check_recycle / shutdown flush
19
+ # it (mirrors Worker#stat_buffer).
20
+ attr_accessor :stat_buffer
11
21
 
12
- def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads)
22
+ def shutting_down?
23
+ @shutting_down
24
+ end
25
+
26
+ def jobs_processed
27
+ @jobs_processed.value
28
+ end
29
+
30
+ # Seed the processed-job counter. Used by tests to drive the recycle
31
+ # thresholds without running thousands of real jobs; production only ever
32
+ # increments it via the AtomicFixnum during message handling.
33
+ def jobs_processed=(count)
34
+ @jobs_processed.value = count
35
+ end
36
+
37
+ # Mirrors Worker::NOTIFY_FALLBACK_POLL_SECONDS. When a live NotifyListener
38
+ # drives wake-up latency, the empty-read wait is a safety net rather than
39
+ # the steady-state cadence, so it rises to this ceiling: one missed NOTIFY
40
+ # costs bounded latency, never a stuck queue.
41
+ NOTIFY_FALLBACK_POLL_SECONDS = 15
42
+
43
+ # NotifyListener startup can fail on a transient boot-time condition (DB
44
+ # restarting, failover, DNS blip) or its thread can die mid-run. The loop
45
+ # retries from ensure_notify_listener on an exponential backoff:
46
+ # NOTIFY_RETRY_BASE doubling up to NOTIFY_RETRY_MAX. Matches Worker.
47
+ NOTIFY_RETRY_BASE_SECONDS = 5
48
+ NOTIFY_RETRY_MAX_SECONDS = 300
49
+
50
+ # The notify-listener lifecycle state (`notify_listener`, `notify_retry_at`,
51
+ # `notify_retry_backoff`), the recycle clock (`started_at_monotonic`), and
52
+ # the resolved subscription set (`queue_names`) accept injected seeds so
53
+ # tests can drive the run loop from a known state without poking private
54
+ # ivars. All default to the values production initializes to; `queue_names`
55
+ # defaults to nil, meaning "derive from the registry in setup_subscriptions".
56
+ def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads,
57
+ queue_names: nil, liveness_pipe: nil, stat_buffer: :default,
58
+ notify_listener: nil, notify_retry_at: 0.0,
59
+ notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
60
+ started_at_monotonic: nil)
13
61
  @topics = Array(topics)
14
62
  @threads = threads
15
63
  @config = config
16
64
  @execution_mode = ExecutionPools.normalize_mode(execution_mode)
17
65
  @shutting_down = false
18
- @pool = ExecutionPools.build(mode: @execution_mode, capacity: threads)
66
+ @recycling = false
67
+ @jobs_processed = Concurrent::AtomicFixnum.new(0)
68
+ @loop_tick_at = Concurrent::AtomicReference.new(nil)
69
+ @started_at_monotonic = started_at_monotonic || monotonic_now
70
+ @wake_signal = WakeSignal.new
71
+ @pool = ExecutionPools.build(
72
+ mode: @execution_mode,
73
+ capacity: threads,
74
+ on_state_change: -> { @wake_signal.notify! }
75
+ )
19
76
  @registry = EventBus::Registry.instance
77
+ @circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
78
+ # stat_buffer: :default means "build one iff config.stats_enabled";
79
+ # passing an explicit value (including nil) overrides that for tests.
80
+ @stat_buffer =
81
+ if stat_buffer == :default
82
+ if config.stats_enabled
83
+ Pgbus::StatBuffer.new(
84
+ flush_size: config.stats_flush_size,
85
+ flush_interval: config.stats_flush_interval
86
+ )
87
+ end
88
+ else
89
+ stat_buffer
90
+ end
91
+ @queue_names = queue_names
92
+ @notify_listener = notify_listener
93
+ @notify_retry_at = notify_retry_at
94
+ @notify_retry_backoff = notify_retry_backoff
95
+ # OS-level liveness channel to the supervisor watchdog. nil unless the
96
+ # supervisor forked us with one. Written from stamp_loop_tick so the
97
+ # watchdog can detect a wedged consumer even when the database is down.
98
+ @liveness_pipe = liveness_pipe
99
+ end
100
+
101
+ # The last wall-clock loop-tick stamp (Time.now.to_f) fed to the
102
+ # heartbeat's loop_tick_supplier. Wall-clock so it stays comparable across
103
+ # the process boundary the supervisor watchdog reads it over. nil until the
104
+ # first stamp_loop_tick.
105
+ def last_loop_tick
106
+ @loop_tick_at.get
20
107
  end
21
108
 
22
109
  def run
23
110
  setup_signals
24
111
  start_heartbeat
25
112
  setup_subscriptions
26
- Pgbus.logger.info { "[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads}" }
113
+ start_notify_listener
114
+ Pgbus.logger.info do
115
+ "[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads} " \
116
+ "notify_wakeup=#{notify_wakeup?} pid=#{::Process.pid}"
117
+ end
27
118
 
28
119
  loop do
120
+ stamp_loop_tick
121
+ process_signals
122
+ check_recycle
123
+ ensure_notify_listener
124
+
29
125
  break if @shutting_down
30
126
 
31
- process_signals
32
127
  consume
128
+ @stat_buffer&.flush_if_due
33
129
  end
34
130
 
35
131
  shutdown
@@ -37,10 +133,17 @@ module Pgbus
37
133
 
38
134
  def graceful_shutdown
39
135
  @shutting_down = true
136
+ # Flush buffered stats at drain entry so the window since the last flush
137
+ # isn't lost if the supervisor watchdog SIGKILLs a stalled consumer
138
+ # before shutdown runs. Same-thread (signals dispatched via
139
+ # process_signals, not trap context), so the DB write is safe.
140
+ @stat_buffer&.flush
141
+ @wake_signal.notify!
40
142
  end
41
143
 
42
144
  def immediate_shutdown
43
145
  @shutting_down = true
146
+ @wake_signal.notify!
44
147
  @pool.kill
45
148
  end
46
149
 
@@ -55,17 +158,12 @@ module Pgbus
55
158
 
56
159
  def consume
57
160
  idle = @pool.available_capacity
58
- return interruptible_sleep(config.polling_interval) if idle <= 0
161
+ return @wake_signal.wait(timeout: wake_timeout) if idle <= 0
59
162
 
60
- tagged_messages = if @queue_names.size == 1
61
- queue = @queue_names.first
62
- (Pgbus.client.read_batch(queue, qty: idle) || []).map { |m| [queue, m] }
63
- else
64
- fetch_multi_consumer(idle)
65
- end
163
+ tagged_messages = fetch_messages(idle)
66
164
 
67
165
  if tagged_messages.empty?
68
- interruptible_sleep(config.polling_interval)
166
+ @wake_signal.wait(timeout: wake_timeout)
69
167
  return
70
168
  end
71
169
 
@@ -74,10 +172,38 @@ module Pgbus
74
172
  end
75
173
  end
76
174
 
175
+ # Returns an array of [queue_name, message] pairs. Queues whose circuit
176
+ # breaker has tripped are skipped so a poison queue is left to cool down
177
+ # instead of being hammered every tick (mirrors Worker#fetch_messages).
178
+ def fetch_messages(qty)
179
+ active_queues = @queue_names.reject { |q| @circuit_breaker.paused?(q) }
180
+ return [] if active_queues.empty?
181
+
182
+ if active_queues.size == 1
183
+ queue = active_queues.first
184
+ (Pgbus.client.read_batch(queue, qty: qty) || []).map { |m| [queue, m] }
185
+ else
186
+ fetch_multi_consumer(active_queues, qty)
187
+ end
188
+ rescue Pgbus::ConnectionCircuitOpenError
189
+ # The client-level connection breaker is open: the database has failed
190
+ # enough consecutive connection attempts that reads fail fast. Idle this
191
+ # poll without an ErrorReporter call so the whole consumer pool doesn't
192
+ # flood the error tracker for the duration of a database outage. The
193
+ # open/close transitions are logged once by the client, not per poll.
194
+ []
195
+ rescue StandardError => e
196
+ ErrorReporter.report(e, { action: "fetch_messages", queues: active_queues })
197
+ []
198
+ end
199
+
77
200
  def handle_message(message, queue_name)
201
+ execution_start = monotonic_now
202
+
78
203
  if message.read_ct.to_i > config.max_retries
79
204
  Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
80
205
  Pgbus.client.move_to_dead_letter(queue_name, message)
206
+ record_stat(message, queue_name, "dead_lettered", execution_start)
81
207
  return
82
208
  end
83
209
 
@@ -91,23 +217,58 @@ module Pgbus
91
217
  end
92
218
 
93
219
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
220
+ @circuit_breaker.record_success(queue_name)
221
+ record_stat(message, queue_name, "success", execution_start)
94
222
  rescue StandardError => e
95
223
  Pgbus.logger.error { "[Pgbus] Consumer error: #{e.class}: #{e.message}" }
96
224
  # Message stays in queue; VT will expire and it becomes available again.
97
225
  # read_ct tracks delivery attempts — when it exceeds max_retries,
98
226
  # the next read will route to DLQ above.
227
+ @circuit_breaker.record_failure(queue_name)
228
+ record_stat(message, queue_name, "failed", execution_start)
229
+ ensure
230
+ # Count every message the consumer handles — success, DLQ-routed, AND
231
+ # rescued failure — mirroring Worker#process_message, which increments
232
+ # unconditionally. Counting only successes would let max_jobs recycling
233
+ # never trip on a poison/all-failing queue: the exact unbounded-memory
234
+ # scenario recycling exists to bound.
235
+ @jobs_processed.increment
236
+ end
237
+
238
+ # Record a job stat for the handled message, mirroring the shape the
239
+ # executor pushes (Executor#record_stat) so consumer and worker throughput
240
+ # land in the same pgbus_job_stats table. No-op unless stats are enabled.
241
+ def record_stat(message, queue_name, status, start_time)
242
+ return unless config.stats_enabled
243
+
244
+ attrs = {
245
+ job_class: "EventConsumer",
246
+ queue_name: queue_name,
247
+ status: status,
248
+ duration_ms: ((monotonic_now - start_time) * 1000).round,
249
+ enqueue_latency_ms: nil,
250
+ retry_count: [message.read_ct.to_i - 1, 0].max
251
+ }
252
+
253
+ if @stat_buffer
254
+ @stat_buffer.push(attrs)
255
+ else
256
+ JobStat.record!(**attrs)
257
+ end
258
+ rescue StandardError => e
259
+ Pgbus.logger.debug { "[Pgbus] Consumer stat recording failed: #{e.message}" }
99
260
  end
100
261
 
101
262
  # `qty` is the total pool capacity. pgmq-ruby treats `qty:` as per-queue,
102
263
  # so we also pass `limit: qty` to cap the total across all queues —
103
264
  # otherwise we get `queue_count * qty` messages and overflow the
104
265
  # execution pool, crashing the consumer fork (issue #123).
105
- def fetch_multi_consumer(qty)
106
- messages = Pgbus.client.read_multi(@queue_names, qty: qty, limit: qty) || []
266
+ def fetch_multi_consumer(active_queues, qty)
267
+ messages = Pgbus.client.read_multi(active_queues, qty: qty, limit: qty) || []
107
268
  prefix = "#{config.queue_prefix}_"
108
269
 
109
270
  messages.map do |m|
110
- logical = m.queue_name&.delete_prefix(prefix) || @queue_names.first
271
+ logical = m.queue_name&.delete_prefix(prefix) || active_queues.first
111
272
  [logical, m]
112
273
  end
113
274
  end
@@ -119,20 +280,195 @@ module Pgbus
119
280
  subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
120
281
  end
121
282
 
283
+ # Signal the loop to exit cleanly once a recycle limit is hit. The clean
284
+ # exit gets an immediate supervisor restart (supervisor.rb:305-307), so a
285
+ # fresh fork replaces this one before its memory grows unbounded — the
286
+ # same fix Worker#check_recycle provides. Guarded by @recycling so the
287
+ # per-iteration call instruments and logs exactly once.
288
+ def check_recycle
289
+ return if @recycling
290
+
291
+ reason = recycle_reason
292
+ return unless reason
293
+
294
+ @recycling = true
295
+ @shutting_down = true
296
+ # Flush buffered stats on recycle-triggered drain for the same reason as
297
+ # graceful_shutdown: shrink the SIGKILL loss window. Same-thread, safe.
298
+ @stat_buffer&.flush
299
+ Pgbus::Instrumentation.instrument(
300
+ "pgbus.consumer.recycle",
301
+ reason: reason,
302
+ jobs_processed: @jobs_processed.value,
303
+ memory_mb: current_memory_mb,
304
+ lifetime_seconds: monotonic_now - @started_at_monotonic
305
+ )
306
+ @wake_signal.notify!
307
+ end
308
+
309
+ def recycle_reason
310
+ return :max_jobs if exceeded_max_jobs?
311
+ return :max_memory if exceeded_max_memory?
312
+ return :max_lifetime if exceeded_max_lifetime?
313
+
314
+ nil
315
+ end
316
+
317
+ def recycle_needed?
318
+ !recycle_reason.nil?
319
+ end
320
+
321
+ def exceeded_max_jobs?
322
+ return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
323
+
324
+ Pgbus.logger.info { "[Pgbus] Consumer recycling: max_jobs reached (#{@jobs_processed.value})" }
325
+ true
326
+ end
327
+
328
+ def exceeded_max_memory?
329
+ return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
330
+
331
+ Pgbus.logger.info do
332
+ "[Pgbus] Consumer recycling: memory limit (#{current_memory_mb}MB > #{config.max_memory_mb}MB)"
333
+ end
334
+ true
335
+ end
336
+
337
+ def exceeded_max_lifetime?
338
+ return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
339
+
340
+ Pgbus.logger.info { "[Pgbus] Consumer recycling: lifetime exceeded" }
341
+ true
342
+ end
343
+
344
+ # Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
345
+ def current_memory_mb
346
+ MemoryUsage.current_mb
347
+ end
348
+
349
+ def notify_wakeup?
350
+ config.respond_to?(:worker_notify_wakeup?) && config.worker_notify_wakeup?
351
+ end
352
+
353
+ def wake_timeout
354
+ # A dead listener (running? false) will never wake the loop, so treat it
355
+ # as absent and keep polling at the short interval until
356
+ # ensure_notify_listener restarts it. Only a live listener earns the
357
+ # long NOTIFY-mode ceiling.
358
+ return config.polling_interval unless notify_wakeup? && @notify_listener&.running?
359
+
360
+ [config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
361
+ end
362
+
363
+ def start_notify_listener
364
+ return unless notify_wakeup?
365
+
366
+ @notify_listener = NotifyListener.new(
367
+ physical_queues: physical_queue_names,
368
+ on_wake: -> { @wake_signal.notify! },
369
+ connection_options: config.worker_notify_connection_options,
370
+ health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
371
+ logger: Pgbus.logger
372
+ ).start
373
+ rescue StandardError => e
374
+ @notify_listener = nil
375
+ Pgbus.logger.error do
376
+ "[Pgbus] Consumer NotifyListener failed to start, falling back to polling: #{e.class}: #{e.message}"
377
+ end
378
+ end
379
+
380
+ # Self-heal a NotifyListener that never started or whose thread died.
381
+ # Called each loop iteration but gated by a monotonic backoff timestamp so
382
+ # a persistent outage retries on 5s→…→300s intervals, not every tick
383
+ # (mirrors Worker#ensure_notify_listener).
384
+ def ensure_notify_listener
385
+ return unless notify_wakeup?
386
+ return if @notify_listener&.running?
387
+ return if monotonic_now < @notify_retry_at
388
+
389
+ stop_dead_notify_listener
390
+ start_notify_listener
391
+
392
+ @notify_retry_backoff = if @notify_listener&.running?
393
+ NOTIFY_RETRY_BASE_SECONDS
394
+ else
395
+ [@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
396
+ end
397
+ @notify_retry_at = monotonic_now + @notify_retry_backoff
398
+ end
399
+
400
+ # Stop a listener whose thread died so its dedicated PG connection is
401
+ # released before start_notify_listener allocates a fresh one.
402
+ def stop_dead_notify_listener
403
+ return if @notify_listener.nil?
404
+
405
+ @notify_listener.stop
406
+ rescue StandardError => e
407
+ Pgbus.logger.warn { "[Pgbus] Consumer failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
408
+ ensure
409
+ @notify_listener = nil
410
+ end
411
+
412
+ def physical_queue_names
413
+ prefix = "#{config.queue_prefix}_"
414
+ @queue_names.map { |q| "#{prefix}#{q}" }
415
+ end
416
+
122
417
  def start_heartbeat
123
418
  @heartbeat = Heartbeat.new(
124
419
  kind: "consumer",
125
- metadata: { topics: topics, threads: threads, pid: ::Process.pid }
420
+ metadata: { topics: topics, threads: threads, pid: ::Process.pid },
421
+ on_beat: -> { on_heartbeat },
422
+ loop_tick_supplier: -> { @loop_tick_at.get }
126
423
  )
127
424
  @heartbeat.start
128
425
  end
129
426
 
427
+ # Runs once per heartbeat interval (not per message), so it's the right
428
+ # place to emit connection-pool observability without touching any per-job
429
+ # hot path. Reading the pool must never crash the beat — pool_stats already
430
+ # rescues to {}, and this whole method is guarded so an unexpected error
431
+ # can't take down the heartbeat thread (mirrors Worker#on_heartbeat).
432
+ def on_heartbeat
433
+ emit_pool_stats
434
+ rescue StandardError => e
435
+ Pgbus.logger.debug { "[Pgbus] Consumer heartbeat hook error: #{e.class}: #{e.message}" }
436
+ end
437
+
438
+ def emit_pool_stats
439
+ stats = Pgbus.client.pool_stats
440
+ return if stats.empty?
441
+
442
+ Pgbus::Instrumentation.instrument("pgbus.client.pool", stats)
443
+ end
444
+
130
445
  def shutdown
446
+ @notify_listener&.stop
131
447
  @pool.shutdown
132
448
  @pool.wait_for_termination(30)
449
+ @stat_buffer&.stop
133
450
  @heartbeat&.stop
134
451
  restore_signals
135
- Pgbus.logger.info { "[Pgbus] Consumer stopped" }
452
+ Pgbus.logger.info { "[Pgbus] Consumer stopped. Processed: #{@jobs_processed.value}" }
453
+ end
454
+
455
+ def monotonic_now
456
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
457
+ end
458
+
459
+ # Stamp the loop-progress beacon with a wall-clock timestamp (required
460
+ # because the supervisor watchdog reads it cross-fork and the dashboard
461
+ # reads it cross-host). Also pokes the OS-level liveness pipe when the
462
+ # supervisor forked us with one, giving the watchdog a database-independent
463
+ # signal. The write is non-blocking and never raises in the hot path
464
+ # (mirrors Worker#stamp_loop_tick).
465
+ def stamp_loop_tick
466
+ @loop_tick_at.set(Time.now.to_f)
467
+ return unless @liveness_pipe
468
+
469
+ @liveness_pipe.write_nonblock("\0", exception: false)
470
+ rescue Errno::EPIPE, IOError, Errno::EBADF
471
+ nil
136
472
  end
137
473
  end
138
474
  end
@@ -11,6 +11,15 @@ module Pgbus
11
11
  # consumers are served first and lower-priority consumers wait
12
12
  # until all higher-priority consumers are at their prefetch limit.
13
13
  module ConsumerPriority
14
+ # Time-to-live (seconds) for cached max_active_priority lookups.
15
+ # The underlying data only changes on heartbeat cadence, so a short
16
+ # TTL avoids ~10 queries/second per prioritized worker at the default
17
+ # 0.1s polling interval.
18
+ CACHE_TTL = 5
19
+
20
+ @cache = {}
21
+ @cache_mutex = Mutex.new
22
+
14
23
  # Check if this worker should yield to a higher-priority worker.
15
24
  # Returns true if a higher-priority healthy worker exists for
16
25
  # any of the given queues.
@@ -26,7 +35,31 @@ module Pgbus
26
35
  # Returns the highest consumer_priority among healthy workers
27
36
  # that share at least one queue with the given queue list,
28
37
  # excluding the current worker (by PID).
38
+ #
39
+ # Results are cached per (sorted queues, pid) for CACHE_TTL seconds.
40
+ # On a miss the query runs outside the lock (a duplicate query on a
41
+ # race is harmless), then the value is stored under the mutex.
29
42
  def self.max_active_priority(queues, my_pid)
43
+ key = [queues.sort, my_pid]
44
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
45
+
46
+ cached = @cache_mutex.synchronize { @cache[key] }
47
+ return cached[:value] if cached && (now - cached[:at]) < CACHE_TTL
48
+
49
+ value = compute_max_active_priority(queues, my_pid)
50
+ @cache_mutex.synchronize { @cache[key] = { value: value, at: now } }
51
+ value
52
+ end
53
+
54
+ # Clears all cached max_active_priority entries. Intended for spec
55
+ # isolation; also safe to call at runtime.
56
+ def self.reset_cache!
57
+ @cache_mutex.synchronize { @cache.clear }
58
+ end
59
+
60
+ # Runs the actual query and reduces worker rows to the highest
61
+ # consumer_priority sharing a queue with the given list.
62
+ def self.compute_max_active_priority(queues, my_pid)
30
63
  conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
31
64
  rows = conn.select_all(
32
65
  "SELECT metadata FROM pgbus_processes WHERE kind = 'worker' AND pid != $1 AND last_heartbeat_at > $2",
@@ -49,6 +82,7 @@ module Pgbus
49
82
 
50
83
  max_priority
51
84
  end
85
+ private_class_method :compute_max_active_priority
52
86
 
53
87
  # Calculate the effective polling interval for this worker.
54
88
  # Higher-priority workers use the base interval.