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
@@ -5,6 +5,18 @@ require "time"
5
5
  module Pgbus
6
6
  module Web
7
7
  class DataSource
8
+ include JobList
9
+ include DeadLetter
10
+ include ListCounts
11
+ include QueueSummary
12
+ include EventList
13
+ include ProcessList
14
+ include DashboardSignals
15
+
16
+ # pgbus_failed_events rows outside the EventBus handler queues; binds
17
+ # handler_queue_names_literal as $1.
18
+ NOT_HANDLER_QUEUE = "NOT (queue_name = ANY($1::text[]))"
19
+
8
20
  # Ceiling on how many parked jobs one dashboard release promotes, so a
9
21
  # key with thousands parked cannot hold the request open. The dispatcher
10
22
  # sweep picks up whatever is left on its next pass.
@@ -51,6 +63,9 @@ module Pgbus
51
63
  def reset_cache!
52
64
  @queues_with_metrics = nil
53
65
  @concurrency_summary = nil
66
+ @queue_health_stats = nil
67
+ @process_rows = nil
68
+ @stream_queue_names = nil
54
69
  self
55
70
  end
56
71
 
@@ -86,13 +101,18 @@ module Pgbus
86
101
  # archive msg_id index fingerprint (issue #366). Stream delivery is a
87
102
  # non-consuming peek, so stream messages sit visible with read_ct=0
88
103
  # forever — health verdicts must not read that as a wedge (issue #359).
89
- # Loaded fresh per call (reset + registry + fingerprint queries):
90
- # verdicts run on coarse intervals and a long-lived process must see
91
- # streams registered since the last check. Degrades to an empty Set
92
- # when the registry/pgmq schema is absent or unreadable.
104
+ # Loaded fresh per instance (reset + registry + fingerprint queries),
105
+ # then memoized until reset_cache!: the home page asks three times per
106
+ # render (issue #492). A long-lived holder (the MCP server, the AppSignal
107
+ # probe) calls reset_cache! per check and HealthApp builds a DataSource
108
+ # per check, so each verdict still sees streams registered since the
109
+ # last one. Degrades to an empty Set when the registry/pgmq schema is
110
+ # absent or unreadable.
93
111
  def stream_queue_names
94
- StreamQueue.reset_cache!
95
- StreamQueue.known_names
112
+ @stream_queue_names ||= begin
113
+ StreamQueue.reset_cache!
114
+ StreamQueue.known_names
115
+ end
96
116
  end
97
117
 
98
118
  # Fingerprint-matched stream queues missing from the registry (issue #366).
@@ -209,9 +229,12 @@ module Pgbus
209
229
  @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
210
230
  end
211
231
 
232
+ # Job queues only: the Events page owns the EventBus handler queues
233
+ # (issue #494).
212
234
  def discard_all_enqueued
213
235
  dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
214
- queues = queues_with_metrics.reject { |q| q[:name].end_with?(dlq_suffix) }
236
+ handlers = handler_queue_physical_names
237
+ queues = queues_with_metrics.reject { |q| q[:name].end_with?(dlq_suffix) || handlers.include?(q[:name]) }
215
238
  total = 0
216
239
 
217
240
  queues.each do |q|
@@ -307,11 +330,14 @@ module Pgbus
307
330
  false
308
331
  end
309
332
 
333
+ # Failed jobs only, like discard_all_failed: an event's message is still
334
+ # in its handler queue, so a re-sent copy would run the handler twice.
310
335
  def retry_all_failed
311
336
  count = 0
312
337
  loop do
313
338
  batch = connection.select_all(
314
- "SELECT * FROM pgbus_failed_events ORDER BY id LIMIT 100", "Pgbus Retry Batch"
339
+ "SELECT * FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE} ORDER BY id LIMIT 100", "Pgbus Retry Batch",
340
+ [handler_queue_names_literal]
315
341
  ).to_a
316
342
  break if batch.empty?
317
343
 
@@ -334,129 +360,21 @@ module Pgbus
334
360
  count
335
361
  end
336
362
 
363
+ # Failed jobs only: failed EventBus handler rows belong to the Events
364
+ # page, whose list no Jobs-page action may reach (issue #494).
337
365
  def discard_all_failed
338
366
  release_locks_for_failed_events
339
367
  archive_all_failed_messages
340
368
 
341
- result = connection.execute("DELETE FROM pgbus_failed_events")
342
- result.cmd_tuples
369
+ connection.exec_delete(
370
+ "DELETE FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE}", "Pgbus Discard All Failed",
371
+ [handler_queue_names_literal]
372
+ )
343
373
  rescue StandardError => e
344
374
  Pgbus.logger.debug { "[Pgbus::Web] Error discarding all failed events: #{e.message}" }
345
375
  0
346
376
  end
347
377
 
348
- # Dead letter queue
349
- # Note: DLQ queue names from queues_with_metrics are already fully qualified
350
- # (e.g., "pgbus_default_dlq"), so we use them directly without re-prefixing.
351
- def dlq_messages(page: 1, per_page: 25)
352
- dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
353
- queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
354
- offset = (page - 1) * per_page
355
-
356
- paginated_queue_messages(queues.map { |q| q[:name] }, per_page, offset)
357
- rescue StandardError => e
358
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ messages: #{e.message}" }
359
- []
360
- end
361
-
362
- def dlq_total_count
363
- dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
364
- queues_with_metrics
365
- .select { |q| q[:name].end_with?(dlq_suffix) }
366
- .sum { |q| q[:queue_length] }
367
- rescue StandardError => e
368
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ count: #{e.message}" }
369
- 0
370
- end
371
-
372
- def dlq_message_detail(msg_id)
373
- dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
374
- queues = queues_with_metrics.select { |q| q[:name].end_with?(dlq_suffix) }
375
- queues.each do |q|
376
- row = connection.select_one(
377
- "SELECT * FROM pgmq.q_#{sanitize_name(q[:name])} WHERE msg_id = $1",
378
- "Pgbus DLQ Detail",
379
- [msg_id.to_i]
380
- )
381
- return format_message(row, q[:name]) if row
382
- end
383
- nil
384
- rescue StandardError => e
385
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ message #{msg_id}: #{e.message}" }
386
- nil
387
- end
388
-
389
- def retry_dlq_message(queue_name, msg_id)
390
- # queue_name here is the full DLQ name (already prefixed)
391
- dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
392
- original_queue = queue_name.delete_suffix(dlq_suffix)
393
-
394
- row = connection.select_one(
395
- "SELECT * FROM pgmq.q_#{sanitize_name(queue_name)} WHERE msg_id = $1",
396
- "Pgbus DLQ Read",
397
- [msg_id.to_i]
398
- )
399
- return false unless row
400
-
401
- @client.transaction do |txn|
402
- txn.produce(original_queue, row["message"], headers: row["headers"])
403
- txn.delete(queue_name, msg_id.to_i)
404
- end
405
- true
406
- rescue StandardError => e
407
- Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{msg_id}: #{e.message}" }
408
- false
409
- end
410
-
411
- def discard_dlq_message(queue_name, msg_id)
412
- # queue_name here is the full DLQ name (already prefixed)
413
- release_lock_for_message(queue_name, msg_id)
414
- @client.delete_message(queue_name, msg_id.to_i, prefixed: false)
415
- true
416
- rescue StandardError => e
417
- Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{msg_id}: #{e.message}" }
418
- false
419
- end
420
-
421
- def retry_all_dlq
422
- messages = dlq_messages(page: 1, per_page: 1000)
423
- count = 0
424
- messages.each do |m|
425
- retry_dlq_message(m[:queue_name], m[:msg_id]) && count += 1
426
- rescue StandardError => e
427
- Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{m[:msg_id]}: #{e.message}" }
428
- next
429
- end
430
- count
431
- end
432
-
433
- def discard_all_dlq
434
- messages = dlq_messages(page: 1, per_page: 1000)
435
- return 0 if messages.empty?
436
-
437
- release_locks_for_messages(messages)
438
-
439
- # Group by queue for batch delete — one call per DLQ instead of N calls
440
- messages.group_by { |m| m[:queue_name] }.sum do |queue_name, msgs|
441
- ids = msgs.map { |m| m[:msg_id].to_i }
442
- @client.delete_batch(queue_name, ids, prefixed: false).size
443
- rescue StandardError => e
444
- Pgbus.logger.debug { "[Pgbus::Web] Error batch-discarding DLQ messages from #{queue_name}: #{e.message}" }
445
- 0
446
- end
447
- end
448
-
449
- # Processes
450
- def processes
451
- rows = connection.select_all(
452
- "SELECT * FROM pgbus_processes ORDER BY kind, created_at"
453
- )
454
- rows.to_a.map { |r| format_process(r) }
455
- rescue StandardError => e
456
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching processes: #{e.message}" }
457
- []
458
- end
459
-
460
378
  # Processed events (audit trail)
461
379
  def processed_events(page: 1, per_page: 25)
462
380
  offset = (page - 1) * per_page
@@ -490,21 +408,12 @@ module Pgbus
490
408
  0
491
409
  end
492
410
 
493
- def replay_event(event)
494
- # Re-publish the event payload to all matching subscribers
495
- routing_key = event["routing_key"] || event["handler_class"]
496
- return false unless routing_key
497
-
498
- @client.publish_to_topic(routing_key, event["payload"] || "{}")
499
- true
500
- rescue StandardError => e
501
- Pgbus.logger.debug { "[Pgbus::Web] Error replaying event: #{e.message}" }
502
- false
503
- end
504
-
505
411
  # Recurring tasks
506
- def recurring_tasks
507
- records = RecurringTask.order(:key).to_a
412
+ # page/per_page nil returns every task (the MCP tool's default).
413
+ def recurring_tasks(page: nil, per_page: nil)
414
+ scope = RecurringTask.order(:key)
415
+ scope = scope.limit(per_page).offset(((page || 1) - 1) * per_page) if per_page
416
+ records = scope.to_a
508
417
  last_runs = RecurringExecution
509
418
  .where(task_key: records.map(&:key))
510
419
  .select("task_key, MAX(run_at) AS run_at")
@@ -616,13 +525,6 @@ module Pgbus
616
525
  false
617
526
  end
618
527
 
619
- def recurring_tasks_count
620
- RecurringTask.count
621
- rescue StandardError => e
622
- Pgbus.logger.debug { "[Pgbus::Web] Error counting recurring tasks: #{e.message}" }
623
- 0
624
- end
625
-
626
528
  # Outbox
627
529
  def outbox_stats
628
530
  {
@@ -668,8 +570,8 @@ module Pgbus
668
570
  end
669
571
 
670
572
  # Job uniqueness keys
671
- def job_locks
672
- UniquenessKey.order(created_at: :desc).limit(100).map do |key|
573
+ def job_locks(page: 1, per_page: 100)
574
+ UniquenessKey.order(created_at: :desc, lock_key: :asc).limit(per_page).offset((page - 1) * per_page).map do |key|
673
575
  {
674
576
  lock_key: key.lock_key,
675
577
  queue_name: key.queue_name,
@@ -684,12 +586,11 @@ module Pgbus
684
586
  end
685
587
 
686
588
  # Concurrency keys — the `limits_concurrency` slot table and the jobs
687
- # parked behind it. Aggregate numbers plus up to 100 key rows, busiest
688
- # first. The two tables are joined FULL OUTER, not LEFT: a key can have
689
- # parked rows and no semaphore (its holder died and the sweep removed the
690
- # row) or a semaphore and nothing parked — an operator needs to see both.
691
- def concurrency_stats
692
- concurrency_summary.merge(keys: concurrency_keys)
589
+ # parked behind it. Aggregate numbers plus one page of key rows (100 by
590
+ # default), busiest first. The two tables are joined FULL OUTER, not LEFT
591
+ # (ListCounts::CONCURRENCY_KEYS_FROM) — an operator needs to see both.
592
+ def concurrency_stats(page: 1, per_page: 100)
593
+ concurrency_summary.merge(keys: concurrency_keys(page: page, per_page: per_page))
693
594
  end
694
595
 
695
596
  # Drop a key's semaphore row and promote whatever can now run.
@@ -761,8 +662,8 @@ module Pgbus
761
662
  end
762
663
 
763
664
  # Batches
764
- def batches(limit: 100)
765
- records = BatchEntry.order(created_at: :desc).limit(limit).to_a
665
+ def batches(page: 1, per_page: 25)
666
+ records = BatchEntry.order(created_at: :desc, id: :desc).limit(per_page).offset((page - 1) * per_page).to_a
766
667
  pending = pending_jobs_by_batch(records.map(&:batch_id))
767
668
  records.map { |r| format_batch(r, pending_jobs: pending[r.batch_id]) }
768
669
  rescue StandardError => e
@@ -788,13 +689,6 @@ module Pgbus
788
689
  nil
789
690
  end
790
691
 
791
- def batches_count
792
- BatchEntry.count
793
- rescue StandardError => e
794
- Pgbus.logger.debug { "[Pgbus::Web] Error counting batches: #{e.message}" }
795
- 0
796
- end
797
-
798
692
  def active_batches_count
799
693
  BatchEntry.where.not(status: "finished").count
800
694
  rescue StandardError => e
@@ -831,6 +725,11 @@ module Pgbus
831
725
  []
832
726
  end
833
727
 
728
+ # Whether job stats record enqueue latency (rails g pgbus:add_job_stats_latency).
729
+ def latency_available?
730
+ JobStat.latency_columns?
731
+ end
732
+
834
733
  def latency_trend(minutes: 60)
835
734
  JobStat.latency_trend(minutes: minutes)
836
735
  rescue StandardError => e
@@ -848,7 +747,13 @@ module Pgbus
848
747
  # Queue health — vacuum stats, dead tuples, bloat, MVCC horizon.
849
748
  # Returns aggregate health across all queue and archive tables, plus
850
749
  # the oldest open transaction age (MVCC horizon pinning risk).
750
+ # Memoized per instance like queues_with_metrics: the home page reads it
751
+ # for the stats, the Queue Health panel and the health verdict.
851
752
  def queue_health_stats
753
+ @queue_health_stats ||= fetch_queue_health_stats
754
+ end
755
+
756
+ def fetch_queue_health_stats
852
757
  tables = fetch_all_table_stats
853
758
 
854
759
  total_dead = tables.sum { |t| t[:dead_tuples] }
@@ -874,6 +779,7 @@ module Pgbus
874
779
  oldest_transaction_age_sec: nil, tables: []
875
780
  }
876
781
  end
782
+ private :fetch_queue_health_stats
877
783
 
878
784
  # Per-queue health stats for the queue detail view.
879
785
  def queue_health_detail(queue_name)
@@ -954,6 +860,14 @@ module Pgbus
954
860
  []
955
861
  end
956
862
 
863
+ # Logical and physical handler queue names as a text[] literal: failed
864
+ # rows carry the logical name, older ones the physical. Queue names are
865
+ # validated word characters, so the literal needs no quoting.
866
+ def handler_queue_names_literal
867
+ names = registered_subscribers.flat_map { |s| [s[:queue_name], s[:physical_queue_name]] }
868
+ "{#{names.compact.uniq.join(",")}}"
869
+ end
870
+
957
871
  # Physical queue names for all registered subscribers. Used for both
958
872
  # pending_events lookup and server-side validation of target queues
959
873
  # in reroute_event.
@@ -973,6 +887,7 @@ module Pgbus
973
887
  def discard_event(queue_name, msg_id)
974
888
  release_lock_for_message(queue_name, msg_id)
975
889
  @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
890
+ clear_event_failure(queue_name, msg_id)
976
891
  true
977
892
  rescue StandardError => e
978
893
  Pgbus.logger.debug { "[Pgbus::Web] Error discarding event #{msg_id}: #{e.message}" }
@@ -996,16 +911,14 @@ module Pgbus
996
911
  event_id = raw["event_id"]
997
912
  return false unless event_id
998
913
 
999
- ProcessedEvent.insert(
1000
- { event_id: event_id, handler_class: handler_class, processed_at: Time.now.utc },
1001
- unique_by: %i[event_id handler_class]
1002
- )
914
+ record_event_handled(event_id, handler_class)
1003
915
  # Release the uniqueness lock while we still hold the payload in
1004
916
  # memory — otherwise the message is archived but the lock row stays
1005
917
  # behind, blocking later publishes with the same key. Mirrors
1006
918
  # discard_event.
1007
919
  release_lock_for_payload(detail[:message])
1008
920
  @client.archive_message(queue_name, msg_id.to_i, prefixed: false)
921
+ clear_event_failure(queue_name, msg_id)
1009
922
  true
1010
923
  rescue StandardError => e
1011
924
  Pgbus.logger.debug { "[Pgbus::Web] Error marking event #{msg_id} handled: #{e.message}" }
@@ -1030,6 +943,7 @@ module Pgbus
1030
943
  txn.produce(queue_name, parsed.to_json, headers: detail[:headers])
1031
944
  txn.delete(queue_name, msg_id.to_i)
1032
945
  end
946
+ clear_event_failure(queue_name, msg_id)
1033
947
  true
1034
948
  rescue StandardError => e
1035
949
  Pgbus.logger.debug { "[Pgbus::Web] Error editing event #{msg_id}: #{e.message}" }
@@ -1047,6 +961,7 @@ module Pgbus
1047
961
  txn.produce(target_queue, detail[:message], headers: detail[:headers])
1048
962
  txn.delete(source_queue, msg_id.to_i)
1049
963
  end
964
+ clear_event_failure(source_queue, msg_id)
1050
965
  true
1051
966
  rescue StandardError => e
1052
967
  Pgbus.logger.debug { "[Pgbus::Web] Error rerouting event #{msg_id}: #{e.message}" }
@@ -1067,6 +982,33 @@ module Pgbus
1067
982
  count
1068
983
  end
1069
984
 
985
+ # A completed marker on a two-phase schema (completed_at): without it the
986
+ # audit reads the row as a claim that went silent, and a pending claim
987
+ # left by a failed attempt would stay pending (issue #494). The legacy
988
+ # schema keeps the single-phase insert.
989
+ def record_event_handled(event_id, handler_class)
990
+ now = Time.now.utc
991
+ row = { event_id: event_id, handler_class: handler_class, processed_at: now }
992
+ if ProcessedEvent.completion_column?
993
+ ProcessedEvent.upsert(row.merge(completed_at: now), unique_by: %i[event_id handler_class],
994
+ update_only: [:completed_at])
995
+ else
996
+ ProcessedEvent.insert(row, unique_by: %i[event_id handler_class])
997
+ end
998
+ end
999
+
1000
+ # The failed row of an event message an action just took out of its
1001
+ # handler queue (issue #494). The consumer records the logical name;
1002
+ # both spellings are cleared. A failure here never undoes the action.
1003
+ def clear_event_failure(queue_name, msg_id)
1004
+ connection.exec_delete(
1005
+ "DELETE FROM pgbus_failed_events WHERE queue_name IN ($1, $2) AND msg_id = $3", "Pgbus Clear Event Failure",
1006
+ [logical_queue_name(queue_name), queue_name, msg_id.to_i]
1007
+ )
1008
+ rescue StandardError => e
1009
+ Pgbus.logger.error { "[Pgbus::Web] Error clearing the failed row of event #{msg_id}: #{e.class}: #{e.message}" }
1010
+ end
1011
+
1070
1012
  # Subscriber registry. `queue_name` is the logical name the subscriber
1071
1013
  # registered with; `physical_queue_name` is what the queue is actually
1072
1014
  # called in `pgmq.meta` (e.g. logical "task_completion_handler" ->
@@ -1152,7 +1094,7 @@ module Pgbus
1152
1094
  # `lease_fresh` is the one fact an operator needs before releasing a key:
1153
1095
  # a live lease means a holder is probably still running, so releasing lets
1154
1096
  # another job start beside it.
1155
- def concurrency_keys(limit: 100)
1097
+ def concurrency_keys(page: 1, per_page: 100)
1156
1098
  rows = connection.select_all(<<~SQL, "Pgbus Concurrency Keys")
1157
1099
  SELECT COALESCE(s.key, b.concurrency_key) AS key,
1158
1100
  s.value AS value,
@@ -1161,14 +1103,9 @@ module Pgbus
1161
1103
  (s.value > 0 AND s.expires_at > now()) AS lease_fresh,
1162
1104
  COALESCE(b.parked_count, 0) AS parked_count,
1163
1105
  EXTRACT(EPOCH FROM (now() - b.oldest_parked_at))::bigint AS oldest_parked_age_sec
1164
- FROM pgbus_semaphores s
1165
- FULL OUTER JOIN (
1166
- SELECT concurrency_key, COUNT(*) AS parked_count, MIN(created_at) AS oldest_parked_at
1167
- FROM pgbus_blocked_executions
1168
- GROUP BY concurrency_key
1169
- ) b ON s.key = b.concurrency_key
1170
- ORDER BY COALESCE(b.parked_count, 0) DESC, s.expires_at ASC NULLS LAST
1171
- LIMIT #{limit.to_i}
1106
+ #{CONCURRENCY_KEYS_FROM}
1107
+ ORDER BY COALESCE(b.parked_count, 0) DESC, s.expires_at ASC NULLS LAST, COALESCE(s.key, b.concurrency_key)
1108
+ LIMIT #{per_page.to_i} OFFSET #{(page.to_i - 1) * per_page.to_i}
1172
1109
  SQL
1173
1110
 
1174
1111
  rows.to_a.map { |row| format_concurrency_key(row) }
@@ -1353,28 +1290,34 @@ module Pgbus
1353
1290
  # name goes through sanitize_name (which calls QueueNameValidator)
1354
1291
  # so it's safe to interpolate into both the schema-qualified table
1355
1292
  # and the literal column. limit/offset are bound parameters.
1356
- def paginated_queue_messages(queue_names, limit, offset)
1293
+ #
1294
+ # where: an optional condition appended inside every fragment, with its
1295
+ # values in binds (numbered from $3, after limit and offset). The
1296
+ # default call builds exactly the unfiltered query.
1297
+ def paginated_queue_messages(queue_names, limit, offset, where: nil, binds: [])
1357
1298
  return [] if queue_names.empty?
1358
1299
 
1359
- sanitized = queue_names.map { |name| [name, sanitize_name(name)] }
1360
- fragments = sanitized.map do |(name, qtable)|
1361
- <<~SQL.strip
1362
- SELECT msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers,
1363
- '#{name}' AS queue_name
1364
- FROM pgmq.q_#{qtable}
1365
- SQL
1366
- end
1367
-
1368
1300
  sql = <<~SQL
1369
- SELECT * FROM (#{fragments.join("\nUNION ALL\n")}) AS combined
1301
+ SELECT * FROM (#{queue_message_fragments(queue_names, where)}) AS combined
1370
1302
  ORDER BY msg_id DESC
1371
1303
  LIMIT $1 OFFSET $2
1372
1304
  SQL
1373
1305
 
1374
- rows = connection.select_all(sql, "Pgbus Paginated Queue Messages", [limit, offset])
1306
+ rows = connection.select_all(sql, "Pgbus Paginated Queue Messages", [limit, offset, *binds])
1375
1307
  rows.to_a.map { |r| format_message(r, r["queue_name"]) }
1376
1308
  end
1377
1309
 
1310
+ def queue_message_fragments(queue_names, where = nil)
1311
+ queue_names.map do |name|
1312
+ fragment = <<~SQL.strip
1313
+ SELECT msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers,
1314
+ '#{name}' AS queue_name
1315
+ FROM pgmq.q_#{sanitize_name(name)}
1316
+ SQL
1317
+ where ? "#{fragment}\nWHERE #{where}" : fragment
1318
+ end.join("\nUNION ALL\n")
1319
+ end
1320
+
1378
1321
  def batched_queue_metrics(queue_names)
1379
1322
  return [] if queue_names.empty?
1380
1323
 
@@ -1499,72 +1442,6 @@ module Pgbus
1499
1442
  }
1500
1443
  end
1501
1444
 
1502
- def format_process(row)
1503
- heartbeat = row["last_heartbeat_at"]
1504
- heartbeat_time = heartbeat.is_a?(String) ? Time.parse(heartbeat) : heartbeat
1505
- stale = heartbeat_time && (Time.now - heartbeat_time) > Process::Heartbeat::ALIVE_THRESHOLD
1506
- metadata = row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"]
1507
- status = derive_process_status(stale, metadata, row["kind"])
1508
-
1509
- {
1510
- id: row["id"].to_i,
1511
- kind: row["kind"],
1512
- hostname: row["hostname"],
1513
- pid: row["pid"].to_i,
1514
- metadata: metadata,
1515
- last_heartbeat_at: heartbeat_time,
1516
- healthy: status == :healthy,
1517
- status: status,
1518
- created_at: row["created_at"]
1519
- }
1520
- end
1521
-
1522
- def derive_process_status(stale, metadata, kind)
1523
- return :stale if stale
1524
- return :healthy unless metadata.is_a?(Hash)
1525
-
1526
- threshold = stall_threshold_for(kind)
1527
- return :healthy unless threshold
1528
-
1529
- loop_tick = metadata["loop_tick_at"]
1530
- return :healthy unless loop_tick
1531
-
1532
- age = Time.now.to_f - loop_tick.to_f
1533
- age > threshold ? :stalled : :healthy
1534
- end
1535
-
1536
- # Kind-aware stall threshold. Workers run a tight loop, so the base
1537
- # stall_threshold applies directly. The dispatcher and scheduler sleep
1538
- # between iterations, so their beacon is naturally up to one sleep
1539
- # interval stale even when healthy — widen the window by that interval to
1540
- # avoid false stalls. Consumers idle on an empty-read wait that can reach
1541
- # NOTIFY_FALLBACK_POLL_SECONDS (15s) when a live listener drives wake-up,
1542
- # so widen by that max wait when notify is on, or by the poll interval
1543
- # otherwise. Kinds with no loop beacon (nil) are never stalled.
1544
- def stall_threshold_for(kind)
1545
- config = Pgbus.configuration
1546
- base = config.stall_threshold
1547
- return nil unless base&.positive?
1548
-
1549
- case kind
1550
- when "worker" then base
1551
- when "dispatcher" then base + config.dispatch_interval
1552
- when "scheduler" then base + config.recurring_schedule_interval
1553
- when "consumer" then base + consumer_stall_slack(config)
1554
- end
1555
- end
1556
-
1557
- # Widen the consumer's stall window by its maximum empty-read wait. With
1558
- # notify wake-up live, that wait rises to NOTIFY_FALLBACK_POLL_SECONDS as a
1559
- # safety net; without it, the consumer polls at polling_interval.
1560
- def consumer_stall_slack(config)
1561
- if config.worker_notify_wakeup?
1562
- [config.polling_interval, Pgbus::Process::Consumer::NOTIFY_FALLBACK_POLL_SECONDS].max
1563
- else
1564
- config.polling_interval
1565
- end
1566
- end
1567
-
1568
1445
  def format_batch(record, pending_jobs: nil)
1569
1446
  total = record.total_jobs
1570
1447
  failed = failure_job_count(record)
@@ -1706,7 +1583,8 @@ module Pgbus
1706
1583
  # Collect uniqueness keys from all failed events and release their locks.
1707
1584
  def release_locks_for_failed_events
1708
1585
  rows = connection.select_all(
1709
- "SELECT payload FROM pgbus_failed_events", "Pgbus Collect Failed Keys"
1586
+ "SELECT payload FROM pgbus_failed_events WHERE #{NOT_HANDLER_QUEUE}", "Pgbus Collect Failed Keys",
1587
+ [handler_queue_names_literal]
1710
1588
  )
1711
1589
 
1712
1590
  keys = rows.to_a.filter_map { |row| extract_uniqueness_key_from_payload_str(row["payload"]) }
@@ -1750,8 +1628,8 @@ module Pgbus
1750
1628
  # so one bad queue can't block progress on the others.
1751
1629
  def archive_all_failed_messages
1752
1630
  rows = connection.select_all(
1753
- "SELECT id, queue_name, msg_id FROM pgbus_failed_events WHERE msg_id IS NOT NULL",
1754
- "Pgbus Collect Failed Messages"
1631
+ "SELECT id, queue_name, msg_id FROM pgbus_failed_events WHERE msg_id IS NOT NULL AND #{NOT_HANDLER_QUEUE}",
1632
+ "Pgbus Collect Failed Messages", [handler_queue_names_literal]
1755
1633
  )
1756
1634
 
1757
1635
  grouped = rows.to_a.group_by { |row| row["queue_name"] }
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Turns one dead-letter row into the words an operator reads: what killed
6
+ # the message and how hard pgbus tried (issue #495). Reads the
7
+ # DeadLetterHeader block the mover wrote; a row without one predates it.
8
+ # No I/O, like JobState: the view helper formats the args.
9
+ module DeadLetterReason
10
+ CELL_MESSAGE_LIMIT = 160
11
+
12
+ # reason is the block's own reason string ("max_retries_exceeded");
13
+ # reason_key picks the sentence the dashboard shows for it.
14
+ Result = Data.define(:reason, :reason_key, :reason_args, :legacy, :source, :source_queue, :attempts, :max_retries,
15
+ :error_class, :error_message, :backtrace, :error_attempt, :retried_before, :died_at) do
16
+ def legacy? = legacy
17
+
18
+ # The attempts after the recorded error that recorded nothing (a
19
+ # worker crashed mid-perform), or nil when the error is the last one.
20
+ def later_attempts
21
+ return unless error_attempt && attempts
22
+
23
+ last_run = attempts - 1
24
+ (error_attempt + 1)..last_run if error_attempt < last_run
25
+ end
26
+ end
27
+
28
+ module_function
29
+
30
+ def present(row)
31
+ block = DeadLetterHeader.parse(row[:headers])
32
+ return legacy(row) unless block
33
+
34
+ attempts = number(block["attempts"])
35
+ max = number(block["max_retries"])
36
+ key, args = reason_for(block, attempts, max)
37
+ Result.new(
38
+ reason: block["reason"], reason_key: key, reason_args: args, legacy: false, source: block["source"],
39
+ source_queue: block["source_queue"], attempts: attempts, max_retries: max,
40
+ error_class: block["error_class"], error_message: block["error_message"], backtrace: block["backtrace"],
41
+ error_attempt: number(block["error_attempt"]), retried_before: number(block["retries_from_dlq"]),
42
+ died_at: row[:enqueued_at]
43
+ )
44
+ end
45
+
46
+ def reason_for(block, attempts, max)
47
+ error_class = block["error_class"]
48
+ unless error_class
49
+ key = block["source"] == "consumer" ? "event_no_error" : "no_error_recorded"
50
+ return [key, { attempts: attempts, max: max }]
51
+ end
52
+
53
+ args = { error_class: error_class, error_message: cell_message(block["error_message"]),
54
+ attempts: attempts, max: max }
55
+ error_attempt = number(block["error_attempt"])
56
+ return ["error", args] unless error_attempt && attempts && error_attempt < attempts - 1
57
+
58
+ ["error_earlier_attempt", args.merge(error_attempt: error_attempt)]
59
+ end
60
+
61
+ def legacy(row)
62
+ Result.new(
63
+ reason: nil, reason_key: "not_recorded", reason_args: { version: DeadLetterHeader::SINCE }, legacy: true,
64
+ source: nil, source_queue: row[:queue_name].to_s.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX),
65
+ attempts: nil, max_retries: nil, error_class: nil, error_message: nil, backtrace: nil,
66
+ error_attempt: nil, retried_before: nil, died_at: row[:enqueued_at]
67
+ )
68
+ end
69
+
70
+ # Header values are untrusted: nil stays nil, a non-number counts as 0.
71
+ def number(value)
72
+ DeadLetterHeader.count(value) unless value.nil?
73
+ end
74
+
75
+ def cell_message(message)
76
+ text = message.to_s
77
+ text.length > CELL_MESSAGE_LIMIT ? "#{text[0, CELL_MESSAGE_LIMIT - 1]}…" : text
78
+ end
79
+ end
80
+ end
81
+ end