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,28 +7,90 @@ 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
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
11
17
 
12
- def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads)
18
+ def shutting_down?
19
+ @shutting_down
20
+ end
21
+
22
+ def jobs_processed
23
+ @jobs_processed.value
24
+ end
25
+
26
+ # Seed the processed-job counter. Used by tests to drive the recycle
27
+ # thresholds without running thousands of real jobs; production only ever
28
+ # increments it via the AtomicFixnum during message handling.
29
+ def jobs_processed=(count)
30
+ @jobs_processed.value = count
31
+ end
32
+
33
+ # Mirrors Worker::NOTIFY_FALLBACK_POLL_SECONDS. When a live NotifyListener
34
+ # drives wake-up latency, the empty-read wait is a safety net rather than
35
+ # the steady-state cadence, so it rises to this ceiling: one missed NOTIFY
36
+ # costs bounded latency, never a stuck queue.
37
+ NOTIFY_FALLBACK_POLL_SECONDS = 15
38
+
39
+ # NotifyListener startup can fail on a transient boot-time condition (DB
40
+ # restarting, failover, DNS blip) or its thread can die mid-run. The loop
41
+ # retries from ensure_notify_listener on an exponential backoff:
42
+ # NOTIFY_RETRY_BASE doubling up to NOTIFY_RETRY_MAX. Matches Worker.
43
+ NOTIFY_RETRY_BASE_SECONDS = 5
44
+ NOTIFY_RETRY_MAX_SECONDS = 300
45
+
46
+ # The notify-listener lifecycle state (`notify_listener`, `notify_retry_at`,
47
+ # `notify_retry_backoff`), the recycle clock (`started_at_monotonic`), and
48
+ # the resolved subscription set (`queue_names`) accept injected seeds so
49
+ # tests can drive the run loop from a known state without poking private
50
+ # ivars. All default to the values production initializes to; `queue_names`
51
+ # defaults to nil, meaning "derive from the registry in setup_subscriptions".
52
+ def initialize(topics:, threads: 3, config: Pgbus.configuration, execution_mode: :threads,
53
+ queue_names: nil, notify_listener: nil, notify_retry_at: 0.0,
54
+ notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
55
+ started_at_monotonic: nil)
13
56
  @topics = Array(topics)
14
57
  @threads = threads
15
58
  @config = config
16
59
  @execution_mode = ExecutionPools.normalize_mode(execution_mode)
17
60
  @shutting_down = false
18
- @pool = ExecutionPools.build(mode: @execution_mode, capacity: threads)
61
+ @recycling = false
62
+ @jobs_processed = Concurrent::AtomicFixnum.new(0)
63
+ @started_at_monotonic = started_at_monotonic || monotonic_now
64
+ @wake_signal = WakeSignal.new
65
+ @pool = ExecutionPools.build(
66
+ mode: @execution_mode,
67
+ capacity: threads,
68
+ on_state_change: -> { @wake_signal.notify! }
69
+ )
19
70
  @registry = EventBus::Registry.instance
71
+ @queue_names = queue_names
72
+ @notify_listener = notify_listener
73
+ @notify_retry_at = notify_retry_at
74
+ @notify_retry_backoff = notify_retry_backoff
20
75
  end
21
76
 
22
77
  def run
23
78
  setup_signals
24
79
  start_heartbeat
25
80
  setup_subscriptions
26
- Pgbus.logger.info { "[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads}" }
81
+ start_notify_listener
82
+ Pgbus.logger.info do
83
+ "[Pgbus] Consumer started: topics=#{topics.join(",")} threads=#{threads} " \
84
+ "notify_wakeup=#{notify_wakeup?} pid=#{::Process.pid}"
85
+ end
27
86
 
28
87
  loop do
88
+ process_signals
89
+ check_recycle
90
+ ensure_notify_listener
91
+
29
92
  break if @shutting_down
30
93
 
31
- process_signals
32
94
  consume
33
95
  end
34
96
 
@@ -37,10 +99,12 @@ module Pgbus
37
99
 
38
100
  def graceful_shutdown
39
101
  @shutting_down = true
102
+ @wake_signal.notify!
40
103
  end
41
104
 
42
105
  def immediate_shutdown
43
106
  @shutting_down = true
107
+ @wake_signal.notify!
44
108
  @pool.kill
45
109
  end
46
110
 
@@ -55,7 +119,7 @@ module Pgbus
55
119
 
56
120
  def consume
57
121
  idle = @pool.available_capacity
58
- return interruptible_sleep(config.polling_interval) if idle <= 0
122
+ return @wake_signal.wait(timeout: wake_timeout) if idle <= 0
59
123
 
60
124
  tagged_messages = if @queue_names.size == 1
61
125
  queue = @queue_names.first
@@ -65,7 +129,7 @@ module Pgbus
65
129
  end
66
130
 
67
131
  if tagged_messages.empty?
68
- interruptible_sleep(config.polling_interval)
132
+ @wake_signal.wait(timeout: wake_timeout)
69
133
  return
70
134
  end
71
135
 
@@ -96,6 +160,13 @@ module Pgbus
96
160
  # Message stays in queue; VT will expire and it becomes available again.
97
161
  # read_ct tracks delivery attempts — when it exceeds max_retries,
98
162
  # the next read will route to DLQ above.
163
+ ensure
164
+ # Count every message the consumer handles — success, DLQ-routed, AND
165
+ # rescued failure — mirroring Worker#process_message, which increments
166
+ # unconditionally. Counting only successes would let max_jobs recycling
167
+ # never trip on a poison/all-failing queue: the exact unbounded-memory
168
+ # scenario recycling exists to bound.
169
+ @jobs_processed.increment
99
170
  end
100
171
 
101
172
  # `qty` is the total pool capacity. pgmq-ruby treats `qty:` as per-queue,
@@ -119,6 +190,137 @@ module Pgbus
119
190
  subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
120
191
  end
121
192
 
193
+ # Signal the loop to exit cleanly once a recycle limit is hit. The clean
194
+ # exit gets an immediate supervisor restart (supervisor.rb:305-307), so a
195
+ # fresh fork replaces this one before its memory grows unbounded — the
196
+ # same fix Worker#check_recycle provides. Guarded by @recycling so the
197
+ # per-iteration call instruments and logs exactly once.
198
+ def check_recycle
199
+ return if @recycling
200
+
201
+ reason = recycle_reason
202
+ return unless reason
203
+
204
+ @recycling = true
205
+ @shutting_down = true
206
+ Pgbus::Instrumentation.instrument(
207
+ "pgbus.consumer.recycle",
208
+ reason: reason,
209
+ jobs_processed: @jobs_processed.value,
210
+ memory_mb: current_memory_mb,
211
+ lifetime_seconds: monotonic_now - @started_at_monotonic
212
+ )
213
+ @wake_signal.notify!
214
+ end
215
+
216
+ def recycle_reason
217
+ return :max_jobs if exceeded_max_jobs?
218
+ return :max_memory if exceeded_max_memory?
219
+ return :max_lifetime if exceeded_max_lifetime?
220
+
221
+ nil
222
+ end
223
+
224
+ def recycle_needed?
225
+ !recycle_reason.nil?
226
+ end
227
+
228
+ def exceeded_max_jobs?
229
+ return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
230
+
231
+ Pgbus.logger.info { "[Pgbus] Consumer recycling: max_jobs reached (#{@jobs_processed.value})" }
232
+ true
233
+ end
234
+
235
+ def exceeded_max_memory?
236
+ return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
237
+
238
+ Pgbus.logger.info do
239
+ "[Pgbus] Consumer recycling: memory limit (#{current_memory_mb}MB > #{config.max_memory_mb}MB)"
240
+ end
241
+ true
242
+ end
243
+
244
+ def exceeded_max_lifetime?
245
+ return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
246
+
247
+ Pgbus.logger.info { "[Pgbus] Consumer recycling: lifetime exceeded" }
248
+ true
249
+ end
250
+
251
+ # Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
252
+ def current_memory_mb
253
+ MemoryUsage.current_mb
254
+ end
255
+
256
+ def notify_wakeup?
257
+ config.respond_to?(:worker_notify_wakeup?) && config.worker_notify_wakeup?
258
+ end
259
+
260
+ def wake_timeout
261
+ # A dead listener (running? false) will never wake the loop, so treat it
262
+ # as absent and keep polling at the short interval until
263
+ # ensure_notify_listener restarts it. Only a live listener earns the
264
+ # long NOTIFY-mode ceiling.
265
+ return config.polling_interval unless notify_wakeup? && @notify_listener&.running?
266
+
267
+ [config.polling_interval, NOTIFY_FALLBACK_POLL_SECONDS].max
268
+ end
269
+
270
+ def start_notify_listener
271
+ return unless notify_wakeup?
272
+
273
+ @notify_listener = NotifyListener.new(
274
+ physical_queues: physical_queue_names,
275
+ on_wake: -> { @wake_signal.notify! },
276
+ connection_options: config.worker_notify_connection_options,
277
+ health_check_ms: (config.polling_interval * 1000).to_i.clamp(250, 5_000),
278
+ logger: Pgbus.logger
279
+ ).start
280
+ rescue StandardError => e
281
+ @notify_listener = nil
282
+ Pgbus.logger.error do
283
+ "[Pgbus] Consumer NotifyListener failed to start, falling back to polling: #{e.class}: #{e.message}"
284
+ end
285
+ end
286
+
287
+ # Self-heal a NotifyListener that never started or whose thread died.
288
+ # Called each loop iteration but gated by a monotonic backoff timestamp so
289
+ # a persistent outage retries on 5s→…→300s intervals, not every tick
290
+ # (mirrors Worker#ensure_notify_listener).
291
+ def ensure_notify_listener
292
+ return unless notify_wakeup?
293
+ return if @notify_listener&.running?
294
+ return if monotonic_now < @notify_retry_at
295
+
296
+ stop_dead_notify_listener
297
+ start_notify_listener
298
+
299
+ @notify_retry_backoff = if @notify_listener&.running?
300
+ NOTIFY_RETRY_BASE_SECONDS
301
+ else
302
+ [@notify_retry_backoff * 2, NOTIFY_RETRY_MAX_SECONDS].min
303
+ end
304
+ @notify_retry_at = monotonic_now + @notify_retry_backoff
305
+ end
306
+
307
+ # Stop a listener whose thread died so its dedicated PG connection is
308
+ # released before start_notify_listener allocates a fresh one.
309
+ def stop_dead_notify_listener
310
+ return if @notify_listener.nil?
311
+
312
+ @notify_listener.stop
313
+ rescue StandardError => e
314
+ Pgbus.logger.warn { "[Pgbus] Consumer failed to stop dead NotifyListener: #{e.class}: #{e.message}" }
315
+ ensure
316
+ @notify_listener = nil
317
+ end
318
+
319
+ def physical_queue_names
320
+ prefix = "#{config.queue_prefix}_"
321
+ @queue_names.map { |q| "#{prefix}#{q}" }
322
+ end
323
+
122
324
  def start_heartbeat
123
325
  @heartbeat = Heartbeat.new(
124
326
  kind: "consumer",
@@ -128,11 +330,16 @@ module Pgbus
128
330
  end
129
331
 
130
332
  def shutdown
333
+ @notify_listener&.stop
131
334
  @pool.shutdown
132
335
  @pool.wait_for_termination(30)
133
336
  @heartbeat&.stop
134
337
  restore_signals
135
- Pgbus.logger.info { "[Pgbus] Consumer stopped" }
338
+ Pgbus.logger.info { "[Pgbus] Consumer stopped. Processed: #{@jobs_processed.value}" }
339
+ end
340
+
341
+ def monotonic_now
342
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
136
343
  end
137
344
  end
138
345
  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.