pgbus 0.9.7 → 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 (63) 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 +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/pgbus/active_job/executor.rb +25 -4
  20. data/lib/pgbus/cli/dlq.rb +164 -0
  21. data/lib/pgbus/cli.rb +18 -1
  22. data/lib/pgbus/client/connection_health.rb +194 -0
  23. data/lib/pgbus/client.rb +592 -73
  24. data/lib/pgbus/config_loader.rb +23 -4
  25. data/lib/pgbus/configuration.rb +98 -12
  26. data/lib/pgbus/dedup_cache.rb +8 -0
  27. data/lib/pgbus/doctor.rb +250 -0
  28. data/lib/pgbus/engine.rb +15 -0
  29. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  30. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  31. data/lib/pgbus/instrumentation.rb +1 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  33. data/lib/pgbus/metrics/backend.rb +38 -0
  34. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  35. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  36. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  37. data/lib/pgbus/metrics/subscriber.rb +190 -0
  38. data/lib/pgbus/metrics.rb +42 -0
  39. data/lib/pgbus/process/consumer.rb +215 -8
  40. data/lib/pgbus/process/consumer_priority.rb +34 -0
  41. data/lib/pgbus/process/dispatcher.rb +265 -41
  42. data/lib/pgbus/process/heartbeat.rb +18 -5
  43. data/lib/pgbus/process/memory_usage.rb +48 -0
  44. data/lib/pgbus/process/notify_listener.rb +26 -7
  45. data/lib/pgbus/process/notify_probe.rb +96 -0
  46. data/lib/pgbus/process/primary_validator.rb +53 -0
  47. data/lib/pgbus/process/signal_handler.rb +6 -0
  48. data/lib/pgbus/process/supervisor.rb +396 -46
  49. data/lib/pgbus/process/worker.rb +298 -35
  50. data/lib/pgbus/recurring/scheduler.rb +15 -1
  51. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  52. data/lib/pgbus/table_maintenance.rb +13 -2
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +20 -4
  55. data/lib/pgbus/web/health_app.rb +102 -0
  56. data/lib/pgbus/web/health_server.rb +144 -0
  57. data/lib/pgbus/web/streamer/instance.rb +55 -1
  58. data/lib/pgbus/web/streamer/listener.rb +72 -9
  59. data/lib/pgbus.rb +37 -0
  60. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  61. data/lib/rubocop/pgbus.rb +5 -0
  62. data/lib/tasks/pgbus_doctor.rake +12 -0
  63. metadata +18 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent"
4
+
3
5
  module Pgbus
4
6
  module Process
5
7
  class Dispatcher
@@ -24,23 +26,106 @@ module Pgbus
24
26
  # too-small value just delays cleanup, never breaks anything.
25
27
  ARCHIVE_COMPACTION_BATCH_SIZE = 1000
26
28
 
27
- attr_reader :config
29
+ # Maintenance backoff during systemic outages. When every attempted
30
+ # maintenance task fails for two consecutive cycles (e.g. the database
31
+ # is down), skip maintenance entirely for an exponentially growing
32
+ # window instead of retrying ~12 tasks every dispatch interval and
33
+ # flooding logs and error trackers. The window doubles per consecutive
34
+ # failed cycle and is capped. Constants (not config) per the precedent
35
+ # above: the values rarely need tuning and only affect noise, never
36
+ # correctness. Backoff exits the moment any task succeeds again.
37
+ MAINTENANCE_BACKOFF_BASE = 30 # seconds; first backoff window
38
+ MAINTENANCE_BACKOFF_MAX = 600 # seconds; window ceiling (10 minutes)
39
+
40
+ # The per-task monotonic timestamps run_maintenance consults via run_if_due.
41
+ # Enumerated so set_maintenance_timestamp can validate its argument.
42
+ MAINTENANCE_TIMESTAMPS = %i[
43
+ @last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
44
+ @last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
45
+ @last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
46
+ @last_orphan_stream_sweep_at @last_table_maintenance_at
47
+ ].freeze
48
+
49
+ # Outcome of a single maintenance task in a cycle. Lets run_maintenance
50
+ # distinguish success/failed/skipped and, on failure, carry the error
51
+ # and task name for reporting or summarizing.
52
+ MaintenanceResult = ::Struct.new(:status, :task, :error) do
53
+ def success? = status == :success
54
+ def skipped? = status == :skipped
55
+ end
56
+
57
+ # Accumulates the per-item outcomes of a task that loops over queues.
58
+ # A task should only be considered failed when it attempted work and
59
+ # *every* attempt failed — a partial failure (some items succeed) must
60
+ # not trip maintenance backoff. #result returns the first error in the
61
+ # total-failure case and nil otherwise, matching the "return e on
62
+ # failure" contract run_if_due expects.
63
+ class LoopOutcome
64
+ def initialize
65
+ @successes = 0
66
+ @failures = 0
67
+ @first_error = nil
68
+ end
69
+
70
+ def record_success
71
+ @successes += 1
72
+ end
73
+
74
+ def record_failure(error)
75
+ @failures += 1
76
+ @first_error = error if @first_error.nil?
77
+ end
78
+
79
+ def result
80
+ @first_error if @failures.positive? && @successes.zero?
81
+ end
82
+ end
83
+
84
+ attr_reader :config, :maintenance_failure_streak, :maintenance_backoff_until
85
+
86
+ def shutting_down?
87
+ @shutting_down
88
+ end
89
+
90
+ # The last wall-clock timestamp (Time.now.to_f) stamped by the run loop
91
+ # via the heartbeat's loop_tick_supplier. Wall-clock — NOT monotonic — so
92
+ # it stays comparable across the parent/child process boundary the
93
+ # supervisor watchdog reads it over. nil until the first stamp_loop_tick.
94
+ def last_loop_tick
95
+ @loop_tick_at.get
96
+ end
97
+
98
+ # Test seam: set one of the @last_*_at maintenance timestamps so a task
99
+ # becomes (or stops being) due on the next run_maintenance. The timestamps
100
+ # are per-task monotonic clocks with no constructor injection point (all 12
101
+ # default to monotonic_now at construction), so a post-construction setter
102
+ # is the minimal way to drive the due/not-due logic deterministically.
103
+ # ivar must be one of the recognized @last_*_at names.
104
+ def set_maintenance_timestamp(ivar, monotonic_value)
105
+ raise ArgumentError, "unknown maintenance timestamp #{ivar}" unless MAINTENANCE_TIMESTAMPS.include?(ivar.to_sym)
106
+
107
+ instance_variable_set(ivar, monotonic_value)
108
+ end
109
+
110
+ # Read one of the @last_*_at maintenance timestamps (companion to
111
+ # set_maintenance_timestamp) so a test can assert a timestamp did or did
112
+ # not advance without reaching into the ivar directly.
113
+ def maintenance_timestamp(ivar)
114
+ raise ArgumentError, "unknown maintenance timestamp #{ivar}" unless MAINTENANCE_TIMESTAMPS.include?(ivar.to_sym)
115
+
116
+ instance_variable_get(ivar)
117
+ end
28
118
 
29
119
  def initialize(config: Pgbus.configuration)
30
120
  @config = config
31
121
  @shutting_down = false
32
- @last_cleanup_at = monotonic_now
33
- @last_reap_at = monotonic_now
34
- @last_concurrency_at = monotonic_now
35
- @last_batch_cleanup_at = monotonic_now
36
- @last_recurring_cleanup_at = monotonic_now
37
- @last_archive_compaction_at = monotonic_now
38
- @last_stream_archive_compaction_at = monotonic_now
39
- @last_outbox_cleanup_at = monotonic_now
40
- @last_job_lock_cleanup_at = monotonic_now
41
- @last_stats_cleanup_at = monotonic_now
42
- @last_orphan_stream_sweep_at = monotonic_now
43
- @last_table_maintenance_at = monotonic_now
122
+ # Seed every per-task timestamp from the single source of truth so the
123
+ # list can never drift from set_maintenance_timestamp's allow-list.
124
+ now = monotonic_now
125
+ MAINTENANCE_TIMESTAMPS.each { |ivar| instance_variable_set(ivar, now) }
126
+ @maintenance_failure_streak = 0
127
+ @maintenance_backoff_until = nil
128
+ @loop_tick_at = Concurrent::AtomicReference.new(nil)
44
129
  end
45
130
 
46
131
  def run
@@ -51,6 +136,7 @@ module Pgbus
51
136
  end
52
137
 
53
138
  loop do
139
+ stamp_loop_tick
54
140
  break if @shutting_down
55
141
 
56
142
  process_signals
@@ -75,33 +161,120 @@ module Pgbus
75
161
 
76
162
  private
77
163
 
78
- def run_maintenance
79
- now = monotonic_now
164
+ def run_maintenance(now = monotonic_now)
165
+ return if in_backoff?(now)
166
+
167
+ results = run_maintenance_tasks(now)
168
+ update_maintenance_backoff(now, results)
169
+ end
80
170
 
81
- run_if_due(now, :@last_cleanup_at, CLEANUP_INTERVAL) { cleanup_processed_events }
82
- run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
83
- run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
84
- run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
85
- run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
86
- run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
87
- run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
88
- run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
89
- run_if_due(now, :@last_job_lock_cleanup_at, JOB_LOCK_CLEANUP_INTERVAL) { cleanup_job_locks }
90
- run_if_due(now, :@last_stats_cleanup_at, STATS_CLEANUP_INTERVAL) { cleanup_stats }
171
+ # Runs every due maintenance task and returns their results. Each entry
172
+ # is a MaintenanceResult carrying the outcome (:success/:failed/:skipped)
173
+ # and, on failure, the exception and task name so run_maintenance can
174
+ # decide whether to report per-task or summarize.
175
+ def run_maintenance_tasks(now)
176
+ results = []
177
+ results << run_if_due(now, :@last_cleanup_at, CLEANUP_INTERVAL) { cleanup_processed_events }
178
+ results << run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
179
+ results << run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
180
+ results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
181
+ results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
182
+ results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
183
+ results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
184
+ results << run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
185
+ results << run_if_due(now, :@last_job_lock_cleanup_at, JOB_LOCK_CLEANUP_INTERVAL) { cleanup_job_locks }
186
+ results << run_if_due(now, :@last_stats_cleanup_at, STATS_CLEANUP_INTERVAL) { cleanup_stats }
91
187
  sweep_interval = config.streams_orphan_sweep_interval
92
- run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
93
- run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
188
+ results << run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
189
+ results << run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
190
+ results
94
191
  end
95
192
 
96
- # Only update the timestamp when the block succeeds.
97
- # On failure, the next tick retries instead of waiting the full interval.
193
+ # Runs the block when the interval has elapsed and reports the outcome.
194
+ # Only updates the timestamp when the block succeeds on failure the
195
+ # next tick retries instead of waiting the full interval. Errors are NOT
196
+ # reported here; run_maintenance decides whether to report per-task or
197
+ # summarize into a single backoff warning.
198
+ #
199
+ # A task fails one of two ways: it raises (a few tasks let errors
200
+ # propagate), or its own rescue returns the exception (most tasks catch
201
+ # StandardError to log a descriptive warning, then hand the error back
202
+ # here via `return e` so the cycle can still see the failure). Both are
203
+ # normalized to a :failed result carrying the exception.
98
204
  def run_if_due(now, ivar, interval)
99
- return unless now - instance_variable_get(ivar) >= interval
205
+ return MaintenanceResult.new(:skipped, task_name(ivar), nil) unless now - instance_variable_get(ivar) >= interval
206
+
207
+ outcome = yield
208
+ return MaintenanceResult.new(:failed, task_name(ivar), outcome) if outcome.is_a?(StandardError)
100
209
 
101
- yield
102
210
  instance_variable_set(ivar, now)
211
+ MaintenanceResult.new(:success, task_name(ivar), nil)
103
212
  rescue StandardError => e
104
- ErrorReporter.report(e, { action: "dispatcher_maintenance", task: ivar.to_s.delete_prefix("@last_").delete_suffix("_at") })
213
+ MaintenanceResult.new(:failed, task_name(ivar), e)
214
+ end
215
+
216
+ def task_name(ivar)
217
+ ivar.to_s.delete_prefix("@last_").delete_suffix("_at")
218
+ end
219
+
220
+ def in_backoff?(now)
221
+ @maintenance_backoff_until && now < @maintenance_backoff_until
222
+ end
223
+
224
+ # Given this cycle's results, advance or reset the failure streak and
225
+ # the backoff window. Any success restores normal cadence. An all-failed
226
+ # cycle (at least one task attempted, none succeeded) advances the
227
+ # streak; the first such cycle reports every error individually, and the
228
+ # cycle that reaches the streak threshold enters backoff with a single
229
+ # summary warning instead.
230
+ def update_maintenance_backoff(now, results)
231
+ attempted = results.reject(&:skipped?)
232
+ return if attempted.empty?
233
+
234
+ if attempted.any?(&:success?)
235
+ reset_maintenance_backoff
236
+ return
237
+ end
238
+
239
+ enter_or_advance_backoff(now, attempted)
240
+ end
241
+
242
+ def reset_maintenance_backoff
243
+ @maintenance_failure_streak = 0
244
+ @maintenance_backoff_until = nil
245
+ end
246
+
247
+ def enter_or_advance_backoff(now, failures)
248
+ first_failing_cycle = @maintenance_failure_streak.zero?
249
+ @maintenance_failure_streak += 1
250
+
251
+ if first_failing_cycle
252
+ report_maintenance_failures(failures)
253
+ elsif @maintenance_failure_streak >= 2
254
+ begin_backoff(now, failures)
255
+ end
256
+ end
257
+
258
+ def report_maintenance_failures(failures)
259
+ failures.each do |result|
260
+ ErrorReporter.report(result.error, { action: "dispatcher_maintenance", task: result.task })
261
+ end
262
+ end
263
+
264
+ def begin_backoff(now, failures)
265
+ delay = backoff_delay(@maintenance_failure_streak)
266
+ @maintenance_backoff_until = now + delay
267
+ first = failures.first
268
+ Pgbus.logger.warn do
269
+ "[Pgbus] Dispatcher maintenance backoff: #{failures.size} task(s) failing " \
270
+ "(#{first.error.class}: #{first.error.message}); skipping maintenance for #{delay}s"
271
+ end
272
+ end
273
+
274
+ # Exponential window: BASE * 2**(streak - 2), capped at MAX. Streak 2 is
275
+ # the first backoff (2**0 == 1 == BASE), streak 3 doubles it, and so on.
276
+ def backoff_delay(streak)
277
+ [MAINTENANCE_BACKOFF_BASE * (2**(streak - 2)), MAINTENANCE_BACKOFF_MAX].min
105
278
  end
106
279
 
107
280
  def cleanup_processed_events
@@ -112,6 +285,7 @@ module Pgbus
112
285
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
113
286
  rescue StandardError => e
114
287
  Pgbus.logger.warn { "[Pgbus] Idempotency cleanup failed: #{e.message}" }
288
+ e
115
289
  end
116
290
 
117
291
  def reap_stale_processes
@@ -120,6 +294,7 @@ module Pgbus
120
294
  Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
121
295
  rescue StandardError => e
122
296
  Pgbus.logger.warn { "[Pgbus] Stale process reaping failed: #{e.message}" }
297
+ e
123
298
  end
124
299
 
125
300
  def cleanup_concurrency
@@ -132,6 +307,7 @@ module Pgbus
132
307
  Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
133
308
  rescue StandardError => e
134
309
  Pgbus.logger.warn { "[Pgbus] Concurrency cleanup failed: #{e.message}" }
310
+ e
135
311
  end
136
312
 
137
313
  def release_blocked_for_key(key)
@@ -146,6 +322,7 @@ module Pgbus
146
322
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
147
323
  rescue StandardError => e
148
324
  Pgbus.logger.warn { "[Pgbus] Batch cleanup failed: #{e.message}" }
325
+ e
149
326
  end
150
327
 
151
328
  def cleanup_stats
@@ -171,11 +348,25 @@ module Pgbus
171
348
  maintained = TableMaintenance.run_maintenance(
172
349
  raw_conn,
173
350
  threshold: TableMaintenance::BLOAT_THRESHOLD,
174
- reindex: true
351
+ reindex: true,
352
+ stop_check: -> { @shutting_down }
175
353
  )
176
354
  Pgbus.logger.info { "[Pgbus] Table maintenance completed: #{maintained} table(s) vacuumed" } if maintained.positive?
177
355
  rescue StandardError => e
178
356
  Pgbus.logger.warn { "[Pgbus] Table maintenance failed: #{e.message}" }
357
+ e
358
+ end
359
+
360
+ # Polled at the top of each per-queue maintenance loop. Returns true when
361
+ # a shutdown has begun, logging one summary line so the interruption is
362
+ # visible. `index` is how many queues were already processed, so the
363
+ # message reads "after N of M". An interrupted (early-returned) task
364
+ # counts as success in run_if_due and simply runs again next interval.
365
+ def interrupted?(label, index, total)
366
+ return false unless @shutting_down
367
+
368
+ Pgbus.logger.info { "[Pgbus] #{label} interrupted by shutdown after #{index} of #{total}" }
369
+ true
179
370
  end
180
371
 
181
372
  def cleanup_job_locks
@@ -186,6 +377,9 @@ module Pgbus
186
377
  # that fail and retry can hold locks for hours.
187
378
  reaped = reap_orphaned_uniqueness_keys
188
379
  Pgbus.logger.info { "[Pgbus] Reaped #{reaped} orphaned uniqueness keys" } if reaped.positive?
380
+ rescue StandardError => e
381
+ Pgbus.logger.warn { "[Pgbus] Job lock cleanup failed: #{e.message}" }
382
+ e
189
383
  end
190
384
 
191
385
  def reap_orphaned_uniqueness_keys
@@ -208,9 +402,6 @@ module Pgbus
208
402
  return 0 if orphaned.empty?
209
403
 
210
404
  UniquenessKey.where(lock_key: orphaned.map(&:lock_key)).delete_all
211
- rescue StandardError => e
212
- Pgbus.logger.warn { "[Pgbus] Uniqueness key cleanup failed: #{e.message}" }
213
- 0
214
405
  end
215
406
 
216
407
  # Returns true if the message referenced by this lock is definitely gone
@@ -244,6 +435,7 @@ module Pgbus
244
435
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} published outbox entries" } if deleted.positive?
245
436
  rescue StandardError => e
246
437
  Pgbus.logger.warn { "[Pgbus] Outbox cleanup failed: #{e.message}" }
438
+ e
247
439
  end
248
440
 
249
441
  def compact_archives
@@ -257,17 +449,23 @@ module Pgbus
257
449
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
258
450
  queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
259
451
 
260
- queue_names.each do |full_name|
452
+ outcome = LoopOutcome.new
453
+ queue_names.each_with_index do |full_name, index|
454
+ break if interrupted?("Archive compaction", index, queue_names.size)
261
455
  next unless full_name.start_with?("#{prefix}_")
262
456
 
263
457
  stripped = full_name.delete_prefix("#{prefix}_")
264
458
  deleted = Pgbus.client.purge_archive(stripped, older_than: cutoff, batch_size: batch_size)
265
459
  Pgbus.logger.debug { "[Pgbus] Compacted #{deleted} archive entries from #{full_name}" } if deleted.positive?
460
+ outcome.record_success
266
461
  rescue StandardError => e
267
462
  Pgbus.logger.warn { "[Pgbus] Archive compaction failed for #{full_name}: #{e.message}" }
463
+ outcome.record_failure(e)
268
464
  end
465
+ outcome.result
269
466
  rescue StandardError => e
270
467
  Pgbus.logger.warn { "[Pgbus] Archive compaction failed: #{e.message}" }
468
+ e
271
469
  end
272
470
 
273
471
  # Prunes per-stream archive tables. Unlike compact_archives (which
@@ -280,11 +478,13 @@ module Pgbus
280
478
  prefix = config.streams_queue_prefix
281
479
  return if prefix.nil? || prefix.empty?
282
480
 
283
- batch_size = config.archive_compaction_batch_size || 1000
481
+ batch_size = ARCHIVE_COMPACTION_BATCH_SIZE
284
482
  conn = config.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
285
483
  queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
286
484
 
287
- queue_names.each do |full_name|
485
+ outcome = LoopOutcome.new
486
+ queue_names.each_with_index do |full_name, index|
487
+ break if interrupted?("Stream archive compaction", index, queue_names.size)
288
488
  next unless full_name.start_with?("#{prefix}_")
289
489
 
290
490
  retention = retention_for_stream_queue(full_name)
@@ -298,11 +498,15 @@ module Pgbus
298
498
  "[Pgbus] Compacted #{deleted} stream archive entries from #{full_name}"
299
499
  end
300
500
  end
501
+ outcome.record_success
301
502
  rescue StandardError => e
302
503
  Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed for #{full_name}: #{e.message}" }
504
+ outcome.record_failure(e)
303
505
  end
506
+ outcome.result
304
507
  rescue StandardError => e
305
508
  Pgbus.logger.warn { "[Pgbus] Stream archive compaction failed: #{e.message}" }
509
+ e
306
510
  end
307
511
 
308
512
  def retention_for_stream_queue(full_name)
@@ -330,7 +534,9 @@ module Pgbus
330
534
  queue_names = conn.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
331
535
 
332
536
  dropped = 0
333
- queue_names.each do |full_name|
537
+ outcome = LoopOutcome.new
538
+ queue_names.each_with_index do |full_name, index|
539
+ break if interrupted?("Orphan stream sweep", index, queue_names.size)
334
540
  next unless full_name.start_with?("#{prefix}_")
335
541
 
336
542
  row = conn.select_one(<<~SQL, "Pgbus Orphan Check")
@@ -344,13 +550,17 @@ module Pgbus
344
550
  Pgbus.client.drop_queue(full_name, prefixed: false)
345
551
  dropped += 1
346
552
  Pgbus.logger.info { "[Pgbus] Dropped orphan stream queue: #{full_name}" }
553
+ outcome.record_success
347
554
  rescue StandardError => e
348
555
  Pgbus.logger.warn { "[Pgbus] Orphan stream sweep failed for #{full_name}: #{e.message}" }
556
+ outcome.record_failure(e)
349
557
  end
350
558
 
351
559
  Pgbus.logger.debug { "[Pgbus] Orphan stream sweep complete: dropped #{dropped} queue(s)" } if dropped.positive?
560
+ outcome.result
352
561
  rescue StandardError => e
353
562
  Pgbus.logger.warn { "[Pgbus] Orphan stream sweep failed: #{e.message}" }
563
+ e
354
564
  end
355
565
 
356
566
  def cleanup_recurring_executions
@@ -361,14 +571,28 @@ module Pgbus
361
571
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
362
572
  rescue StandardError => e
363
573
  Pgbus.logger.warn { "[Pgbus] Recurring execution cleanup failed: #{e.message}" }
574
+ e
364
575
  end
365
576
 
366
577
  def monotonic_now
367
578
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
368
579
  end
369
580
 
581
+ # Wall clock (not monotonic) so the dashboard can compare the beacon
582
+ # against Time.now across processes; the heartbeat timer thread reads it
583
+ # via loop_tick_supplier. A dispatcher wedged inside run_maintenance stops
584
+ # advancing this while heartbeats keep firing, so the beacon ages and the
585
+ # processes page can surface the stall.
586
+ def stamp_loop_tick
587
+ @loop_tick_at.set(Time.now.to_f)
588
+ end
589
+
370
590
  def start_heartbeat
371
- @heartbeat = Heartbeat.new(kind: "dispatcher", metadata: { pid: ::Process.pid })
591
+ @heartbeat = Heartbeat.new(
592
+ kind: "dispatcher",
593
+ metadata: { pid: ::Process.pid },
594
+ loop_tick_supplier: -> { @loop_tick_at.get }
595
+ )
372
596
  @heartbeat.start
373
597
  end
374
598
 
@@ -11,11 +11,12 @@ module Pgbus
11
11
 
12
12
  attr_reader :process_entry
13
13
 
14
- def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil)
14
+ def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil)
15
15
  @kind = kind
16
16
  @metadata = metadata
17
17
  @on_beat = on_beat
18
18
  @loop_tick_supplier = loop_tick_supplier
19
+ @metadata_supplier = metadata_supplier
19
20
  @timer = nil
20
21
  end
21
22
 
@@ -35,10 +36,8 @@ module Pgbus
35
36
 
36
37
  @on_beat&.call
37
38
  updates = { last_heartbeat_at: Time.current }
38
- if @loop_tick_supplier
39
- tick = @loop_tick_supplier.call
40
- updates[:metadata] = @metadata.merge("loop_tick_at" => tick&.to_f)
41
- end
39
+ metadata = beat_metadata
40
+ updates[:metadata] = metadata unless metadata.nil?
42
41
  ProcessEntry.where(id: @process_id).update_all(updates)
43
42
  rescue StandardError => e
44
43
  Pgbus.logger.warn { "[Pgbus] Heartbeat failed: #{e.message}" }
@@ -46,6 +45,20 @@ module Pgbus
46
45
 
47
46
  private
48
47
 
48
+ # Build the metadata hash to persist on this beat. Returns nil when no
49
+ # supplier is configured so heartbeats without dynamic metadata
50
+ # (supervisor/dispatcher/scheduler) leave the column untouched, exactly
51
+ # as before. The loop_tick and metadata suppliers are called here — after
52
+ # @on_beat has run — so any snapshot!/refresh in on_beat is reflected.
53
+ def beat_metadata
54
+ return nil unless @loop_tick_supplier || @metadata_supplier
55
+
56
+ metadata = @metadata.dup
57
+ metadata["loop_tick_at"] = @loop_tick_supplier.call&.to_f if @loop_tick_supplier
58
+ metadata.merge!(@metadata_supplier.call) if @metadata_supplier
59
+ metadata
60
+ end
61
+
49
62
  def register_process
50
63
  record = ProcessEntry.create!(
51
64
  kind: @kind,
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Process
5
+ module MemoryUsage
6
+ MEMORY_CHECK_TTL = 5
7
+
8
+ @mutex = Mutex.new
9
+ @cached_value = nil
10
+ @cached_at = nil
11
+
12
+ class << self
13
+ def current_mb
14
+ @mutex.synchronize do
15
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
16
+
17
+ return @cached_value if @cached_value && @cached_at && (now - @cached_at) < MEMORY_CHECK_TTL
18
+
19
+ @cached_value = read_memory
20
+ @cached_at = now
21
+ @cached_value
22
+ end
23
+ end
24
+
25
+ def reset!
26
+ @mutex.synchronize do
27
+ @cached_value = nil
28
+ @cached_at = nil
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def read_memory
35
+ if RUBY_PLATFORM.include?("darwin")
36
+ `ps -o rss= -p #{::Process.pid}`.to_i / 1024
37
+ else
38
+ begin
39
+ File.read("/proc/#{::Process.pid}/statm").split[1].to_i * 4096 / (1024 * 1024)
40
+ rescue Errno::ENOENT
41
+ 0
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -95,14 +95,29 @@ module Pgbus
95
95
  @commands << [:unlisten, physical_queue]
96
96
  end
97
97
 
98
- private
99
-
98
+ # Public so the owning worker can detect a listener whose thread died
99
+ # (run_loop hit a fatal error and cleared @running in its ensure) and
100
+ # restart it. Guarded by @state_mutex like every other @running access.
100
101
  def running?
101
102
  @state_mutex.synchronize { @running }
102
103
  end
103
104
 
105
+ private
106
+
104
107
  def run_loop
105
108
  conn = build_connection
109
+ # Reject a connection that landed on a read-only replica before doing
110
+ # anything else. After a failover, stale DNS can point this fresh
111
+ # connection at the demoted master; NOTIFY fires only on the primary,
112
+ # so we'd sit deaf forever. A replica here raises ReplicaConnectionError
113
+ # and the rescue below runs the fatal/ensure path (worker-level startup
114
+ # retry is a separate concern); a reconnect converges on the primary.
115
+ PrimaryValidator.validate_primary!(conn)
116
+ # One-shot delivery self-probe on the initial connection only. A pooler
117
+ # or replica that silently breaks LISTEN/NOTIFY is surfaced here with an
118
+ # actionable error; the listener still runs and degrades to polling.
119
+ # Reconnects skip the probe to stay cheap.
120
+ NotifyProbe.probe_notify_delivery!(conn, logger: @logger)
106
121
  @state_mutex.synchronize { @conn = conn }
107
122
  drain_commands
108
123
 
@@ -200,12 +215,16 @@ module Pgbus
200
215
  new_conn = nil
201
216
  begin
202
217
  new_conn = build_connection
218
+ # Reject a replica before re-LISTENing. A fresh PG.connect re-resolves
219
+ # DNS, so backing off and retrying converges on the promoted master
220
+ # once DNS catches up after a failover.
221
+ PrimaryValidator.validate_primary!(new_conn)
203
222
  channels.each { |channel| new_conn.exec(%(LISTEN "#{channel}")) }
204
- rescue PG::Error => e
205
- # build_connection may have succeeded before a later LISTEN raised.
206
- # Without this close, the partially-built conn is orphaned and the
207
- # next retry just allocates another one leaking PG connections on
208
- # repeated failures.
223
+ rescue PG::Error, ReplicaConnectionError => e
224
+ # build_connection may have succeeded before a later LISTEN raised,
225
+ # or validate_primary! rejected a replica. Without this close, the
226
+ # partially-built conn is orphaned and the next retry just allocates
227
+ # another one — leaking PG connections on repeated failures.
209
228
  close_quietly(new_conn)
210
229
  @logger.error { "[Pgbus::NotifyListener] reconnect failed: #{e.class}: #{e.message}" }
211
230
  sleep RECONNECT_BACKOFF_SECONDS