pgbus 0.17.1 → 0.17.2

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +48 -13
  4. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  5. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  6. data/app/controllers/pgbus/application_controller.rb +5 -2
  7. data/app/controllers/pgbus/batches_controller.rb +4 -1
  8. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  9. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  10. data/app/controllers/pgbus/events_controller.rb +52 -3
  11. data/app/controllers/pgbus/insights_controller.rb +19 -1
  12. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  13. data/app/controllers/pgbus/locks_controller.rb +7 -2
  14. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  15. data/app/controllers/pgbus/processes_controller.rb +3 -1
  16. data/app/controllers/pgbus/queues_controller.rb +16 -4
  17. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  18. data/app/frontend/pgbus/modules/charts.js +119 -77
  19. data/app/frontend/pgbus/style.css +1 -1
  20. data/app/frontend/pgbus/tailwind.css +18 -0
  21. data/app/helpers/pgbus/application_helper.rb +155 -113
  22. data/app/helpers/pgbus/button_helper.rb +88 -0
  23. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  24. data/app/helpers/pgbus/events_helper.rb +40 -0
  25. data/app/helpers/pgbus/insights_helper.rb +39 -0
  26. data/app/helpers/pgbus/processes_helper.rb +79 -0
  27. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  28. data/app/views/pgbus/batches/show.html.erb +3 -4
  29. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  30. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  31. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  32. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  33. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  34. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  35. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  36. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  37. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  38. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  39. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  40. data/app/views/pgbus/events/_list.html.erb +55 -0
  41. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  42. data/app/views/pgbus/events/_replay.html.erb +10 -0
  43. data/app/views/pgbus/events/_row.html.erb +162 -0
  44. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  45. data/app/views/pgbus/events/index.html.erb +27 -48
  46. data/app/views/pgbus/events/show.html.erb +25 -7
  47. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  48. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  49. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  50. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  51. data/app/views/pgbus/insights/show.html.erb +83 -100
  52. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  53. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  54. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  55. data/app/views/pgbus/jobs/index.html.erb +10 -10
  56. data/app/views/pgbus/jobs/show.html.erb +18 -20
  57. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  58. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  59. data/app/views/pgbus/outbox/index.html.erb +30 -27
  60. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  61. data/app/views/pgbus/processes/_row.html.erb +48 -0
  62. data/app/views/pgbus/processes/_status.html.erb +4 -0
  63. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  64. data/app/views/pgbus/queues/show.html.erb +69 -118
  65. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  66. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  67. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  68. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  69. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  70. data/config/i18n-tasks.yml +11 -2
  71. data/config/locales/da.yml +405 -113
  72. data/config/locales/de.yml +405 -113
  73. data/config/locales/en.yml +405 -113
  74. data/config/locales/es.yml +404 -112
  75. data/config/locales/fi.yml +404 -112
  76. data/config/locales/fr.yml +406 -114
  77. data/config/locales/it.yml +405 -113
  78. data/config/locales/ja.yml +405 -113
  79. data/config/locales/nb.yml +405 -113
  80. data/config/locales/nl.yml +405 -113
  81. data/config/locales/pt.yml +406 -114
  82. data/config/locales/sv.yml +404 -112
  83. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  84. data/lib/pgbus/active_job/executor.rb +16 -6
  85. data/lib/pgbus/cli/dlq.rb +32 -5
  86. data/lib/pgbus/client.rb +4 -2
  87. data/lib/pgbus/configuration.rb +47 -2
  88. data/lib/pgbus/dead_letter_header.rb +129 -0
  89. data/lib/pgbus/doctor.rb +8 -4
  90. data/lib/pgbus/event_bus/handler.rb +18 -11
  91. data/lib/pgbus/event_bus/registry.rb +6 -0
  92. data/lib/pgbus/failed_event_recorder.rb +34 -0
  93. data/lib/pgbus/instrumentation.rb +2 -1
  94. data/lib/pgbus/mcp/base_tool.rb +25 -0
  95. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  96. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  97. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  98. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  99. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  100. data/lib/pgbus/outbox/poller.rb +1 -1
  101. data/lib/pgbus/process/consumer.rb +53 -3
  102. data/lib/pgbus/process/dispatcher.rb +1 -1
  103. data/lib/pgbus/process/supervisor.rb +32 -11
  104. data/lib/pgbus/process/worker.rb +45 -8
  105. data/lib/pgbus/recurring/scheduler.rb +2 -1
  106. data/lib/pgbus/version.rb +1 -1
  107. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  108. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  109. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  110. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  111. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  112. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  113. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  114. data/lib/pgbus/web/data_source.rb +132 -254
  115. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  116. data/lib/pgbus/web/event_state.rb +71 -0
  117. data/lib/pgbus/web/health_app.rb +7 -3
  118. data/lib/pgbus/web/health_verdict.rb +78 -0
  119. data/lib/pgbus/web/insights_charts.rb +84 -0
  120. data/lib/pgbus/web/job_state.rb +111 -0
  121. data/lib/pgbus/web/process_activity.rb +97 -0
  122. data/lib/pgbus/web/process_state.rb +187 -0
  123. data/lib/pgbus/web/queue_summary.rb +104 -0
  124. data/lib/pgbus/web/time_format.rb +128 -0
  125. metadata +43 -4
  126. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  127. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  128. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -12,15 +12,33 @@ module Pgbus
12
12
  description <<~DESC
13
13
  List active job uniqueness locks with their lock_key, queue_name,
14
14
  msg_id, and age in seconds. A lock that outlives its message
15
- indicates a leaked uniqueness key blocking re-enqueues. Returns the
16
- 100 most recent locks.
15
+ indicates a leaked uniqueness key blocking re-enqueues. Paginated
16
+ (default 100 per page, capped at 100); the response carries page,
17
+ per_page, total (capped at 10000) and has_more.
17
18
  DESC
18
19
 
19
- input_schema(properties: {}, required: [])
20
+ MAX_PER_PAGE = 100
21
+ MAX_PAGE = 1_000
20
22
 
21
- def self.call(server_context: nil)
23
+ input_schema(
24
+ properties: {
25
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
26
+ per_page: { type: "integer", description: "Rows per page (default 100, max 100).", minimum: 1 }
27
+ },
28
+ required: []
29
+ )
30
+
31
+ def self.call(page: 1, per_page: MAX_PER_PAGE, server_context: nil)
22
32
  data_source = data_source_from(server_context)
23
- json_response({ locks: data_source.job_locks })
33
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
34
+ page = page.to_i.clamp(1, MAX_PAGE)
35
+ locks = data_source.job_locks(page: page, per_page: per_page)
36
+ count = data_source.list_count(:job_locks)
37
+
38
+ json_response(
39
+ { locks: locks, page: page, per_page: per_page, total: count.total,
40
+ has_more: more_pages?(count, page: page, per_page: per_page, shown: locks.size) }
41
+ )
24
42
  end
25
43
  end
26
44
  end
@@ -12,15 +12,44 @@ module Pgbus
12
12
  description <<~DESC
13
13
  List recurring (cron) tasks with their schedule, human-readable
14
14
  schedule, queue, enabled state, last_run_at and next_run_at. Use this
15
- to confirm scheduled work is firing on time.
15
+ to confirm scheduled work is firing on time. Returns every task unless
16
+ page or per_page is given; then it is paginated (per_page defaults to
17
+ 100, capped at 100). The response carries page, per_page, total
18
+ (capped at 10000 when paginated; the exact row count when not) and
19
+ has_more; per_page is null when unpaginated.
16
20
  DESC
17
21
 
18
- input_schema(properties: {}, required: [])
22
+ MAX_PER_PAGE = 100
23
+ MAX_PAGE = 1_000
19
24
 
20
- def self.call(server_context: nil)
25
+ input_schema(
26
+ properties: {
27
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
28
+ per_page: { type: "integer", description: "Rows per page (default 100 once paginating, max 100).", minimum: 1 }
29
+ },
30
+ required: []
31
+ )
32
+
33
+ def self.call(page: nil, per_page: nil, server_context: nil)
21
34
  data_source = data_source_from(server_context)
22
- json_response({ recurring_tasks: data_source.recurring_tasks })
35
+ return unpaged(data_source) if page.nil? && per_page.nil?
36
+
37
+ per_page = (per_page || MAX_PER_PAGE).to_i.clamp(1, MAX_PER_PAGE)
38
+ page = (page || 1).to_i.clamp(1, MAX_PAGE)
39
+ tasks = data_source.recurring_tasks(page: page, per_page: per_page)
40
+ count = data_source.list_count(:recurring_tasks)
41
+
42
+ json_response(
43
+ { recurring_tasks: tasks, page: page, per_page: per_page, total: count.total,
44
+ has_more: more_pages?(count, page: page, per_page: per_page, shown: tasks.size) }
45
+ )
46
+ end
47
+
48
+ def self.unpaged(data_source)
49
+ tasks = data_source.recurring_tasks
50
+ json_response({ recurring_tasks: tasks, page: 1, per_page: nil, total: tasks.size, has_more: false })
23
51
  end
52
+ private_class_method :unpaged
24
53
  end
25
54
  end
26
55
  end
@@ -139,7 +139,7 @@ module Pgbus
139
139
  def start_heartbeat
140
140
  @heartbeat = Process::Heartbeat.new(
141
141
  kind: "outbox_poller",
142
- metadata: { pid: ::Process.pid }
142
+ metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid }
143
143
  )
144
144
  @heartbeat.start
145
145
  end
@@ -77,6 +77,8 @@ module Pgbus
77
77
  @claim_buffer = ClaimBuffer.new(config: config)
78
78
  @loop_tick_at = Concurrent::AtomicReference.new(nil)
79
79
  @started_at_monotonic = started_at_monotonic || monotonic_now
80
+ # Wall clock for the Processes page's uptime (issue #493).
81
+ @started_at = Time.current
80
82
  @wake_signal = WakeSignal.new
81
83
  @pool = ExecutionPools.build(
82
84
  mode: @execution_mode,
@@ -246,7 +248,7 @@ module Pgbus
246
248
 
247
249
  if message.read_ct.to_i > config.max_retries
248
250
  Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
249
- Pgbus.client.move_to_dead_letter(queue_name, message)
251
+ dead_letter(message, queue_name)
250
252
  record_stat(message, queue_name, "dead_lettered", execution_start)
251
253
  return
252
254
  end
@@ -263,6 +265,9 @@ module Pgbus
263
265
  end
264
266
 
265
267
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
268
+ # A redelivery that succeeds leaves no stale failure behind. First
269
+ # deliveries never had one: no query on the hot path (#484's gate).
270
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i) if message.read_ct.to_i > 1
266
271
  @circuit_breaker.record_success(queue_name)
267
272
  record_stat(message, queue_name, "success", execution_start)
268
273
  rescue StandardError => e
@@ -270,6 +275,9 @@ module Pgbus
270
275
  # Message stays in queue; VT will expire and it becomes available again.
271
276
  # read_ct tracks delivery attempts — when it exceeds max_retries,
272
277
  # the next read will route to DLQ above.
278
+ # A dead-letter read fails only in the move: keep the handler's error
279
+ # on the row for the next attempt's DLQ block instead of the move's.
280
+ record_failed_event(message, queue_name, e) unless message.read_ct.to_i > config.max_retries
273
281
  @circuit_breaker.record_failure(queue_name)
274
282
  record_stat(message, queue_name, "failed", execution_start)
275
283
  ensure
@@ -281,6 +289,31 @@ module Pgbus
281
289
  @jobs_processed.increment
282
290
  end
283
291
 
292
+ # Order: read the last error, move with the dead-letter block, then
293
+ # clear the failed-event row. A move that raises keeps the row, so the
294
+ # next attempt still carries the reason.
295
+ def dead_letter(message, queue_name)
296
+ last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
297
+ headers = DeadLetterHeader.build(
298
+ existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "consumer",
299
+ source_queue: config.queue_name(queue_name), attempts: message.read_ct.to_i,
300
+ max_retries: config.max_retries, error: last_error
301
+ )
302
+ Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)
303
+ FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
304
+ end
305
+
306
+ # The executor's handle_failure, for events (issue #494): one upsert per
307
+ # failure, under the LOGICAL queue name, so the dashboard reads the event
308
+ # as retrying with its error. No ErrorReporter call: Handler#process!
309
+ # already instruments pgbus.event_failed, which feeds the reporters.
310
+ def record_failed_event(message, queue_name, error)
311
+ FailedEventRecorder.record!(
312
+ queue_name: queue_name, msg_id: message.msg_id.to_i, payload: message.message,
313
+ headers: message.headers, error: error, retry_count: [message.read_ct.to_i - 1, 0].max
314
+ )
315
+ end
316
+
284
317
  # Run every handler that owns this message, with the message's visibility
285
318
  # timeout held open for as long as they take (issue #470).
286
319
  #
@@ -582,13 +615,30 @@ module Pgbus
582
615
  def start_heartbeat
583
616
  @heartbeat = Heartbeat.new(
584
617
  kind: "consumer",
585
- metadata: { topics: topics, threads: threads, pid: ::Process.pid },
618
+ metadata: {
619
+ topics: topics, threads: threads, pid: ::Process.pid, execution_mode: @execution_mode,
620
+ started_at: @started_at.to_f, supervisor_pid: ::Process.ppid
621
+ },
586
622
  on_beat: -> { on_heartbeat },
587
- loop_tick_supplier: -> { @loop_tick_at.get }
623
+ loop_tick_supplier: -> { @loop_tick_at.get },
624
+ metadata_supplier: -> { throughput_metadata }
588
625
  )
589
626
  @heartbeat.start
590
627
  end
591
628
 
629
+ # What the Processes page states about a consumer (issue #493), read
630
+ # once per beat, never per message. String keys: the value round-trips
631
+ # through JSON in the metadata column.
632
+ def throughput_metadata
633
+ pool = @pool.metadata
634
+ {
635
+ "jobs_processed" => @jobs_processed.value, "buffered" => @claim_buffer.size,
636
+ "capacity" => pool[:capacity], "busy" => pool[:busy], "memory_mb" => current_memory_mb,
637
+ "max_jobs" => config.max_jobs_per_worker, "max_memory_mb" => config.max_memory_mb,
638
+ "max_lifetime" => config.max_worker_lifetime
639
+ }.compact
640
+ end
641
+
592
642
  # Runs once per heartbeat interval (not per message), so it's the right
593
643
  # place to emit connection-pool observability without touching any per-job
594
644
  # hot path. Reading the pool must never crash the beat — pool_stats already
@@ -697,7 +697,7 @@ module Pgbus
697
697
  def start_heartbeat
698
698
  @heartbeat = Heartbeat.new(
699
699
  kind: "dispatcher",
700
- metadata: { pid: ::Process.pid },
700
+ metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid },
701
701
  loop_tick_supplier: -> { @loop_tick_at.get }
702
702
  )
703
703
  @heartbeat.start
@@ -195,9 +195,11 @@ module Pgbus
195
195
  name = worker_config[:name] || worker_config["name"] || "anonymous"
196
196
  queues = worker_config[:queues] || worker_config["queues"] || [config.default_queue]
197
197
  threads = worker_config[:threads] || worker_config["threads"] || 5
198
+ processes = config.processes_for(worker_config)
198
199
  mode = banner_field { config.execution_mode_for(worker_config) }
199
200
  Pgbus.logger.info do
200
- "[Pgbus] boot: capsule=#{name} queues=#{Array(queues).join(",")} threads=#{threads} mode=#{mode}"
201
+ "[Pgbus] boot: capsule=#{name} queues=#{Array(queues).join(",")} threads=#{threads} " \
202
+ "processes=#{processes} mode=#{mode}"
201
203
  end
202
204
  end
203
205
  end
@@ -284,12 +286,7 @@ module Pgbus
284
286
  # --dispatcher-only CLI flags). Each role is gated by
285
287
  # config.role_enabled?, which returns true unless +config.roles+ has
286
288
  # been narrowed.
287
- if config.role_enabled?(:workers)
288
- # slot is the child's position in the config array — it keys the
289
- # crash-streak tracking so identically-configured siblings don't
290
- # share (and reset) each other's restart backoff.
291
- Array(config.workers).each_with_index { |worker_config, slot| fork_worker(worker_config, slot: slot) }
292
- end
289
+ boot_workers if config.role_enabled?(:workers)
293
290
 
294
291
  fork_dispatcher if config.role_enabled?(:dispatcher)
295
292
  boot_scheduler if config.role_enabled?(:scheduler)
@@ -297,7 +294,22 @@ module Pgbus
297
294
  boot_outbox_poller if config.role_enabled?(:outbox)
298
295
  end
299
296
 
300
- def fork_worker(worker_config, slot: nil)
297
+ # A capsule with processes: N forks N identical workers (issue #503).
298
+ # slot numbers every worker fork across all capsules — it keys the
299
+ # crash-streak tracking so identically-configured siblings (in one
300
+ # capsule or in several) don't share or reset each other's restart
301
+ # backoff. process_index is the fork's position within its capsule.
302
+ def boot_workers
303
+ slot = 0
304
+ Array(config.workers).each do |worker_config|
305
+ config.processes_for(worker_config).times do |process_index|
306
+ fork_worker(worker_config, slot: slot, process_index: process_index)
307
+ slot += 1
308
+ end
309
+ end
310
+ end
311
+
312
+ def fork_worker(worker_config, slot: nil, process_index: 0)
301
313
  note_intended_child
302
314
  queues = worker_config[:queues] || [config.default_queue]
303
315
  threads = worker_config[:threads] || 5
@@ -331,6 +343,8 @@ module Pgbus
331
343
  single_active_consumer: single_active, consumer_priority: priority,
332
344
  execution_mode: exec_mode, group_mode: grp_mode,
333
345
  read_ahead: config.read_ahead_for(worker_config),
346
+ recycle_limits: worker_config.slice(*Configuration::RECYCLE_LIMIT_KEYS),
347
+ capsule: worker_config[:name], process: process_label(worker_config, process_index),
334
348
  liveness_pipe: liveness_writer, wake_pipe: wake_reader
335
349
  )
336
350
  worker.run
@@ -352,7 +366,7 @@ module Pgbus
352
366
  close_pipe(wake_reader)
353
367
  register_fork_with_hub(pid, wake_writer, queues)
354
368
  @forks[pid] = {
355
- type: :worker, config: worker_config, slot: slot, spawned_at: monotonic_now,
369
+ type: :worker, config: worker_config, slot: slot, process_index: process_index, spawned_at: monotonic_now,
356
370
  liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
357
371
  wake_writer: wake_writer
358
372
  }
@@ -365,6 +379,13 @@ module Pgbus
365
379
  ErrorReporter.report(e, { action: "fork_worker", queues: queues })
366
380
  end
367
381
 
382
+ # "2/4" for the second of a capsule's four processes; nil for a
383
+ # single-process capsule, where it would only add noise.
384
+ def process_label(worker_config, process_index)
385
+ total = config.processes_for(worker_config)
386
+ "#{process_index + 1}/#{total}" if total > 1
387
+ end
388
+
368
389
  # Hand the hub a worker fork's routing entry: explicit queues as
369
390
  # physical names, "*" as the unconditional wildcard flag (the hub wakes
370
391
  # wildcard forks for every channel, so the fork's own resolved set never
@@ -682,7 +703,7 @@ module Pgbus
682
703
  def restart_child(info)
683
704
  case info[:type]
684
705
  when :worker
685
- fork_worker(info[:config], slot: info[:slot])
706
+ fork_worker(info[:config], slot: info[:slot], process_index: info[:process_index] || 0)
686
707
  when :dispatcher
687
708
  fork_dispatcher
688
709
  when :scheduler
@@ -882,7 +903,7 @@ module Pgbus
882
903
  def start_heartbeat
883
904
  @heartbeat = Heartbeat.new(
884
905
  kind: "supervisor",
885
- metadata: { pid: ::Process.pid, hostname: Socket.gethostname }
906
+ metadata: { pid: ::Process.pid, hostname: Socket.gethostname, started_at: Time.now.to_f }
886
907
  )
887
908
  @heartbeat.start
888
909
  end
@@ -35,7 +35,8 @@ module Pgbus
35
35
  rate_counter: nil, wake_signal: nil, stat_buffer: :default,
36
36
  notify_listener: nil, notify_retry_at: 0.0,
37
37
  notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
38
- started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil)
38
+ started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil,
39
+ recycle_limits: {}, capsule: nil, process: nil)
39
40
  @queues = Array(queues)
40
41
  @initial_queues = @queues.dup.freeze
41
42
  @wildcard = @queues.include?("*")
@@ -68,6 +69,11 @@ module Pgbus
68
69
  # in_flight counts every claimed message: buffered and running.
69
70
  @in_flight = Concurrent::AtomicFixnum.new(0)
70
71
  @read_ahead = read_ahead || config.read_ahead
72
+ # Capsule-level recycle limits (issue #503); each unset key falls back
73
+ # to the global value at check time, so a config change still applies.
74
+ @recycle_limits = recycle_limits
75
+ @capsule = capsule
76
+ @process = process
71
77
  @claim_buffer = ClaimBuffer.new(config: config)
72
78
  @loop_tick_at = Concurrent::AtomicReference.new(nil)
73
79
  @rate_counter = rate_counter || RateCounter.new(:processed, :failed, :dequeued)
@@ -644,26 +650,33 @@ module Pgbus
644
650
  end
645
651
 
646
652
  def exceeded_max_jobs?
647
- return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
653
+ limit = recycle_limit(:max_jobs_per_worker)
654
+ return false unless limit && @jobs_processed.value >= limit
648
655
 
649
656
  Pgbus.logger.info { "[Pgbus] Worker recycling: max_jobs reached (#{@jobs_processed.value})" }
650
657
  true
651
658
  end
652
659
 
653
660
  def exceeded_max_memory?
654
- return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
661
+ limit = recycle_limit(:max_memory_mb)
662
+ return false unless limit && current_memory_mb > limit
655
663
 
656
- Pgbus.logger.info { "[Pgbus] Worker recycling: memory limit (#{current_memory_mb}MB > #{config.max_memory_mb}MB)" }
664
+ Pgbus.logger.info { "[Pgbus] Worker recycling: memory limit (#{current_memory_mb}MB > #{limit}MB)" }
657
665
  true
658
666
  end
659
667
 
660
668
  def exceeded_max_lifetime?
661
- return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
669
+ limit = recycle_limit(:max_worker_lifetime)
670
+ return false unless limit && (monotonic_now - @started_at_monotonic) > limit
662
671
 
663
672
  Pgbus.logger.info { "[Pgbus] Worker recycling: lifetime exceeded" }
664
673
  true
665
674
  end
666
675
 
676
+ def recycle_limit(key)
677
+ config.recycle_limit_for(@recycle_limits, key)
678
+ end
679
+
667
680
  # Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
668
681
  def current_memory_mb
669
682
  MemoryUsage.current_mb
@@ -816,10 +829,13 @@ module Pgbus
816
829
  def start_heartbeat
817
830
  @heartbeat = Heartbeat.new(
818
831
  kind: "worker",
832
+ # capsule/process lead: the dashboard's metadata cell truncates.
819
833
  metadata: {
834
+ capsule: @capsule, process: @process,
820
835
  queues: queues, threads: threads, pid: ::Process.pid,
821
- execution_mode: @execution_mode, consumer_priority: @consumer_priority
822
- },
836
+ execution_mode: @execution_mode, consumer_priority: @consumer_priority,
837
+ **lifecycle_metadata
838
+ }.compact,
823
839
  on_beat: -> { on_heartbeat },
824
840
  loop_tick_supplier: -> { @loop_tick_at.get },
825
841
  metadata_supplier: -> { throughput_metadata }
@@ -860,10 +876,31 @@ module Pgbus
860
876
  "jobs_processed" => @jobs_processed.value,
861
877
  "jobs_failed" => @jobs_failed.value,
862
878
  "in_flight" => @in_flight.value,
863
- "buffered" => @claim_buffer.size
879
+ "buffered" => @claim_buffer.size,
880
+ **pool_metadata
864
881
  }
865
882
  end
866
883
 
884
+ # Facts the Processes page states (issue #493), fixed for the process's
885
+ # life: when it started and which supervisor forked it (ppid; the page
886
+ # only groups under it when that pid is a supervisor row on this host).
887
+ def lifecycle_metadata
888
+ { started_at: @started_at.to_f, supervisor_pid: ::Process.ppid }
889
+ end
890
+
891
+ # Read once per beat: one atomic (threads) or one mutex (async) for the
892
+ # pool, MemoryUsage's 5s cache for RSS (a ps subprocess on darwin), and
893
+ # the recycle limits resolved as the recycle check resolves them (a
894
+ # capsule's own when set), so the page follows a config change.
895
+ def pool_metadata
896
+ pool = @pool.metadata
897
+ {
898
+ "capacity" => pool[:capacity], "busy" => pool[:busy], "memory_mb" => current_memory_mb,
899
+ "max_jobs" => recycle_limit(:max_jobs_per_worker), "max_memory_mb" => recycle_limit(:max_memory_mb),
900
+ "max_lifetime" => recycle_limit(:max_worker_lifetime)
901
+ }.compact
902
+ end
903
+
867
904
  def shutdown
868
905
  Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
869
906
  stop_wake_source
@@ -116,7 +116,8 @@ module Pgbus
116
116
  def start_heartbeat
117
117
  @heartbeat = Process::Heartbeat.new(
118
118
  kind: "scheduler",
119
- metadata: { pid: ::Process.pid, tasks: schedule.tasks.size },
119
+ metadata: { pid: ::Process.pid, tasks: schedule.tasks.size, started_at: Time.now.to_f,
120
+ supervisor_pid: ::Process.ppid },
120
121
  loop_tick_supplier: -> { @loop_tick_at.get }
121
122
  )
122
123
  @heartbeat.start
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.17.1"
4
+ VERSION = "0.17.2"
5
5
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The facts behind the home page's words (issue #492): what is retrying
7
+ # and when it runs next, what died last and why, how the processes and
8
+ # queues stand, the job rate, and the health verdict /readyz serves.
9
+ #
10
+ # summary_stats keeps its shape (the stats API, MetricsSerializer and the
11
+ # AppSignal probe read it); everything here is home-page only. Each signal
12
+ # degrades to its empty shape and logs, so one failing query never blanks
13
+ # the page.
14
+ module DashboardSignals
15
+ THROUGHPUT_WINDOW_MINUTES = 5
16
+
17
+ EMPTY_RETRYING = { count: 0, capped: false, next_attempt_at: nil, due_now_count: 0,
18
+ newest_error_class: nil }.freeze
19
+
20
+ def dashboard_signals
21
+ {
22
+ retrying: retrying_signal, dlq: dlq_signal, processes: process_signal,
23
+ queues: queue_signal, throughput: throughput_signal, verdict: verdict_signal
24
+ }
25
+ end
26
+
27
+ # The Retrying tab of the Jobs page (handler queues left to the Events
28
+ # page), plus when the next backoff ends, how many are due now and the
29
+ # newest error among those rows. Each queue contributes its earliest
30
+ # COUNT_CAP + 1 rows by vt (the vt index), so the next attempt is exact
31
+ # and the due count is a lower bound past the cap, like the tab counts
32
+ # (capped: marks both).
33
+ def retrying_signal
34
+ counts = job_state_counts(exclude: handler_queue_physical_names)
35
+ return EMPTY_RETRYING if counts["retrying"].zero?
36
+
37
+ schedule = retry_schedule
38
+ {
39
+ count: counts["retrying"], capped: counts.capped?("retrying"),
40
+ next_attempt_at: JobState.time(schedule&.dig("next_at")), due_now_count: schedule&.dig("due").to_i,
41
+ newest_error_class: schedule&.dig("newest_error")
42
+ }
43
+ rescue StandardError => e
44
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the retry signal: #{e.class}: #{e.message}" }
45
+ EMPTY_RETRYING
46
+ end
47
+
48
+ # Depth from the cached metrics; the newest message is read from each
49
+ # non-empty DLQ's tail, so the query stays one index-backed row per DLQ.
50
+ def dlq_signal
51
+ counts = dlq_counts_by_queue
52
+ depth = counts.values.sum
53
+ { depth: depth, newest: depth.zero? ? nil : newest_dead_letter(counts.select { |_, n| n.positive? }.keys) }
54
+ rescue StandardError => e
55
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the dead-letter signal: #{e.class}: #{e.message}" }
56
+ { depth: depth.to_i, newest: nil }
57
+ end
58
+
59
+ def process_signal
60
+ rows = processes
61
+ by_status = rows.group_by { |p| p[:status].to_s }.transform_values(&:size)
62
+ by_kind = rows.group_by { |p| p[:kind].to_s }.transform_values(&:size)
63
+ { total: rows.size, healthy: by_status.fetch("healthy", 0), stalled: by_status.fetch("stalled", 0),
64
+ stale: by_status.fetch("stale", 0), workers: by_kind.fetch("worker", 0),
65
+ consumers: by_kind.fetch("consumer", 0), dispatchers: by_kind.fetch("dispatcher", 0) }
66
+ end
67
+
68
+ # undrained follows the health verdict: an active (unpaused) backlog on
69
+ # a job queue no capsule or handler drains. A wildcard drains everything.
70
+ # depth, claimable and waiting leave the DLQs out (their card counts
71
+ # them) and the paused queues (paused_depth), whose visible messages
72
+ # no worker claims.
73
+ def queue_signal
74
+ queues = queues_with_metrics
75
+ dlq, live = queues.partition { |q| q[:name].to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
76
+ paused, active = live.partition { |q| q[:paused] }
77
+ depth = ->(list) { list.sum { |q| q[:queue_length].to_i } }
78
+ claimable = active.sum { |q| q[:queue_visible_length].to_i }
79
+ { total: queues.size, paused: paused.size, dlq: dlq.size, undrained: undrained_count(live),
80
+ depth: depth.call(live), claimable: claimable, waiting: [depth.call(active) - claimable, 0].max,
81
+ paused_depth: depth.call(paused) }
82
+ end
83
+
84
+ def throughput_signal
85
+ window = THROUGHPUT_WINDOW_MINUTES
86
+ return { available: false, jobs_per_minute: nil, window_minutes: window } unless JobStat.table_exists?
87
+
88
+ total = JobStat.summary(minutes: window)[:total].to_i
89
+ { available: true, jobs_per_minute: (total.to_f / window).round(1), window_minutes: window }
90
+ rescue StandardError => e
91
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the throughput signal: #{e.class}: #{e.message}" }
92
+ { available: false, jobs_per_minute: nil, window_minutes: THROUGHPUT_WINDOW_MINUTES }
93
+ end
94
+
95
+ # The verdict /readyz and the MCP serve. The analyzer lives outside
96
+ # Zeitwerk (pgbus/mcp is ignored), as in HealthApp.
97
+ def verdict_signal
98
+ require "pgbus/mcp/health_analyzer" unless defined?(Pgbus::MCP::HealthAnalyzer)
99
+ Pgbus::MCP::HealthAnalyzer.new(self).verdict
100
+ rescue StandardError => e
101
+ Pgbus.logger.error { "[Pgbus::Web] Error computing the health verdict: #{e.class}: #{e.message}" }
102
+ nil
103
+ end
104
+
105
+ # { physical name => QueueSummary::Line } for the home queues table:
106
+ # the facts QueueSummary needs for every row, loaded once per render
107
+ # (pause states in one query, processes and stream names once).
108
+ def queue_status_lines
109
+ queues = queues_with_metrics
110
+ pauses = queue_pause_states(queues.map { |q| q[:name] })
111
+ healthy = processes.select { |p| p[:healthy] }
112
+ streams = stream_queue_names
113
+ max_retries = Pgbus.configuration.max_retries
114
+ queues.to_h do |q|
115
+ drainers = queue_drainers(q[:name], healthy: healthy, streams: streams)
116
+ lines = Web::QueueSummary.present(q, pauses.fetch(q[:name]), drainers, max_retries: max_retries)
117
+ [q[:name], Web::QueueSummary.headline(lines)]
118
+ end
119
+ rescue StandardError => e
120
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the queue status lines: #{e.class}: #{e.message}" }
121
+ {}
122
+ end
123
+
124
+ private
125
+
126
+ def retry_schedule
127
+ scope = job_list_scope(nil, exclude: handler_queue_physical_names)
128
+ return unless scope && scope[:queues].any?
129
+
130
+ fragments = scope[:queues].map do |(qtable, logical)|
131
+ <<~SQL.strip
132
+ (SELECT m.vt, f.error_class, f.failed_at FROM pgmq.q_#{qtable} m #{failed_join(qtable, logical)}
133
+ WHERE #{JobList::STATE_PREFILTER["retrying"]} AND (#{JobList::STATE_CASE}) = 'retrying'
134
+ ORDER BY m.vt LIMIT #{JobList::COUNT_CAP + 1})
135
+ SQL
136
+ end
137
+ connection.select_one(<<~SQL, "Pgbus Retry Schedule")
138
+ SELECT MIN(vt) FILTER (WHERE vt > now()) AS next_at, COUNT(*) FILTER (WHERE vt <= now()) AS due,
139
+ (array_agg(error_class ORDER BY failed_at DESC))[1] AS newest_error
140
+ FROM (#{fragments.join("\nUNION ALL\n")}) AS retrying
141
+ SQL
142
+ end
143
+
144
+ def newest_dead_letter(names)
145
+ fragments = names.map do |name|
146
+ "(SELECT msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers, '#{sanitize_name(name)}' " \
147
+ "AS queue_name FROM pgmq.q_#{sanitize_name(name)} ORDER BY msg_id DESC LIMIT 1)"
148
+ end
149
+ row = connection.select_one(
150
+ "SELECT * FROM (#{fragments.join(" UNION ALL ")}) AS newest ORDER BY enqueued_at DESC LIMIT 1",
151
+ "Pgbus Newest Dead Letter"
152
+ )
153
+ row && format_message(row, row["queue_name"])
154
+ end
155
+
156
+ def undrained_count(live)
157
+ drained = drained_queue_names
158
+ return 0 if drained.nil?
159
+
160
+ backlog = live.reject { |q| q[:paused] || drained.include?(q[:name].to_s) }
161
+ .select { |q| q[:queue_visible_length].to_i.positive? }
162
+ return 0 if backlog.empty?
163
+
164
+ streams = stream_queue_names
165
+ backlog.count { |q| !streams.include?(q[:name].to_s) }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end