pgbus 0.17.1 → 0.17.3

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +83 -13
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  9. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  10. data/app/controllers/pgbus/application_controller.rb +5 -2
  11. data/app/controllers/pgbus/batches_controller.rb +4 -1
  12. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  13. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  14. data/app/controllers/pgbus/events_controller.rb +52 -3
  15. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  16. data/app/controllers/pgbus/insights_controller.rb +19 -1
  17. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  18. data/app/controllers/pgbus/locks_controller.rb +7 -2
  19. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  20. data/app/controllers/pgbus/processes_controller.rb +3 -1
  21. data/app/controllers/pgbus/queues_controller.rb +16 -4
  22. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  23. data/app/frontend/pgbus/application.min.js +2 -0
  24. data/app/frontend/pgbus/application.min.js.map +1 -0
  25. data/app/frontend/pgbus/modules/charts.js +137 -77
  26. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  27. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  28. data/app/frontend/pgbus/style.css +1 -1
  29. data/app/frontend/pgbus/tailwind.css +18 -0
  30. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  31. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  32. data/app/helpers/pgbus/application_helper.rb +155 -113
  33. data/app/helpers/pgbus/button_helper.rb +88 -0
  34. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  35. data/app/helpers/pgbus/events_helper.rb +40 -0
  36. data/app/helpers/pgbus/insights_helper.rb +39 -0
  37. data/app/helpers/pgbus/processes_helper.rb +79 -0
  38. data/app/views/layouts/pgbus/application.html.erb +0 -1
  39. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  40. data/app/views/pgbus/batches/show.html.erb +3 -4
  41. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  42. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  43. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  44. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  45. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  46. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  47. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  48. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  49. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  50. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  51. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  52. data/app/views/pgbus/events/_list.html.erb +55 -0
  53. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  54. data/app/views/pgbus/events/_replay.html.erb +10 -0
  55. data/app/views/pgbus/events/_row.html.erb +162 -0
  56. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  57. data/app/views/pgbus/events/index.html.erb +27 -48
  58. data/app/views/pgbus/events/show.html.erb +25 -7
  59. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  60. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  61. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  62. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  63. data/app/views/pgbus/insights/show.html.erb +83 -100
  64. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  65. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  66. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  67. data/app/views/pgbus/jobs/index.html.erb +10 -10
  68. data/app/views/pgbus/jobs/show.html.erb +18 -20
  69. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  70. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  71. data/app/views/pgbus/outbox/index.html.erb +30 -27
  72. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  73. data/app/views/pgbus/processes/_row.html.erb +48 -0
  74. data/app/views/pgbus/processes/_status.html.erb +4 -0
  75. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  76. data/app/views/pgbus/queues/show.html.erb +69 -118
  77. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  78. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  79. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  80. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  81. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  82. data/config/i18n-tasks.yml +11 -2
  83. data/config/locales/da.yml +405 -113
  84. data/config/locales/de.yml +405 -113
  85. data/config/locales/en.yml +405 -113
  86. data/config/locales/es.yml +404 -112
  87. data/config/locales/fi.yml +404 -112
  88. data/config/locales/fr.yml +406 -114
  89. data/config/locales/it.yml +405 -113
  90. data/config/locales/ja.yml +405 -113
  91. data/config/locales/nb.yml +405 -113
  92. data/config/locales/nl.yml +405 -113
  93. data/config/locales/pt.yml +406 -114
  94. data/config/locales/sv.yml +404 -112
  95. data/config/routes.rb +1 -1
  96. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  97. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  98. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  99. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  100. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  101. data/lib/pgbus/active_job/executor.rb +16 -6
  102. data/lib/pgbus/cli/dlq.rb +32 -5
  103. data/lib/pgbus/client.rb +4 -2
  104. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  105. data/lib/pgbus/configuration.rb +51 -2
  106. data/lib/pgbus/dead_letter_header.rb +129 -0
  107. data/lib/pgbus/doctor.rb +8 -4
  108. data/lib/pgbus/engine.rb +13 -7
  109. data/lib/pgbus/event_bus/handler.rb +18 -11
  110. data/lib/pgbus/event_bus/registry.rb +6 -0
  111. data/lib/pgbus/failed_event_recorder.rb +34 -0
  112. data/lib/pgbus/generators/migration_detector.rb +9 -1
  113. data/lib/pgbus/instrumentation.rb +2 -1
  114. data/lib/pgbus/mcp/base_tool.rb +25 -0
  115. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  116. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  117. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  118. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  119. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  120. data/lib/pgbus/outbox/poller.rb +1 -1
  121. data/lib/pgbus/process/consumer.rb +53 -3
  122. data/lib/pgbus/process/dispatcher.rb +1 -1
  123. data/lib/pgbus/process/supervisor.rb +32 -11
  124. data/lib/pgbus/process/worker.rb +45 -8
  125. data/lib/pgbus/recurring/scheduler.rb +2 -1
  126. data/lib/pgbus/streams/presence.rb +317 -81
  127. data/lib/pgbus/version.rb +1 -1
  128. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  129. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  130. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  131. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  132. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  133. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  134. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  135. data/lib/pgbus/web/data_source.rb +132 -254
  136. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  137. data/lib/pgbus/web/event_state.rb +71 -0
  138. data/lib/pgbus/web/health_app.rb +7 -3
  139. data/lib/pgbus/web/health_verdict.rb +78 -0
  140. data/lib/pgbus/web/insights_charts.rb +84 -0
  141. data/lib/pgbus/web/job_state.rb +111 -0
  142. data/lib/pgbus/web/process_activity.rb +97 -0
  143. data/lib/pgbus/web/process_state.rb +187 -0
  144. data/lib/pgbus/web/queue_summary.rb +104 -0
  145. data/lib/pgbus/web/stream_app.rb +18 -6
  146. data/lib/pgbus/web/streamer/connection.rb +7 -1
  147. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  148. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  149. data/lib/pgbus/web/streamer/instance.rb +2 -1
  150. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  151. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  152. data/lib/pgbus/web/time_format.rb +128 -0
  153. metadata +55 -4
  154. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  155. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  156. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The dead-letter queue pages (issue #495): list, count, detail, and the
7
+ # retry/discard actions. Moved out of data_source.rb unchanged.
8
+ module DeadLetter
9
+ # The header path the error-class filter reads (DeadLetterHeader::KEY).
10
+ ERROR_CLASS_SQL = "headers #>> '{#{Pgbus::DeadLetterHeader::KEY},error_class}'".freeze
11
+
12
+ # DLQ queue names from queues_with_metrics are already fully qualified
13
+ # (e.g., "pgbus_default_dlq"), so we use them directly without re-prefixing.
14
+ # dlq: one DLQ by its full name (unknown names list nothing);
15
+ # error_class: only messages whose dead-letter block names that class.
16
+ def dlq_messages(page: 1, per_page: 25, dlq: nil, error_class: nil)
17
+ offset = (page - 1) * per_page
18
+ names = dlq_queue_names(dlq)
19
+ return [] if names.empty?
20
+ return paginated_queue_messages(names, per_page, offset) unless error_class
21
+
22
+ paginated_queue_messages(names, per_page, offset, where: "#{ERROR_CLASS_SQL} = $3", binds: [error_class])
23
+ rescue StandardError => e
24
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ messages: #{e.message}" }
25
+ []
26
+ end
27
+
28
+ def dlq_total_count(dlq: nil, error_class: nil)
29
+ names = dlq_queue_names(dlq)
30
+ return 0 if names.empty?
31
+ return dlq_counts_by_queue.values_at(*names).sum unless error_class
32
+
33
+ connection.select_value(
34
+ "SELECT COUNT(*) FROM (#{queue_message_fragments(names, "#{ERROR_CLASS_SQL} = $1")}) AS combined",
35
+ "Pgbus DLQ Count",
36
+ [error_class]
37
+ ).to_i
38
+ rescue StandardError => e
39
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ count: #{e.message}" }
40
+ 0
41
+ end
42
+
43
+ # { "pgbus_default_dlq" => 12, ... } for the filter chips, from the
44
+ # cached metrics (no query of its own).
45
+ def dlq_counts_by_queue
46
+ queues_with_metrics
47
+ .select { |q| q[:name].end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
48
+ .to_h { |q| [q[:name], q[:queue_length].to_i] }
49
+ rescue StandardError => e
50
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ counts: #{e.message}" }
51
+ {}
52
+ end
53
+
54
+ # msg_ids are unique per DLQ table only: queue_name: picks the table;
55
+ # without it the first DLQ holding the id wins.
56
+ def dlq_message_detail(msg_id, queue_name: nil)
57
+ dlq_queue_names(queue_name).each do |name|
58
+ row = connection.select_one(
59
+ "SELECT * FROM pgmq.q_#{sanitize_name(name)} WHERE msg_id = $1",
60
+ "Pgbus DLQ Detail",
61
+ [msg_id.to_i]
62
+ )
63
+ return format_message(row, name) if row
64
+ end
65
+ nil
66
+ rescue StandardError => e
67
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ message #{msg_id}: #{e.message}" }
68
+ nil
69
+ end
70
+
71
+ def retry_dlq_message(queue_name, msg_id)
72
+ # queue_name here is the full DLQ name (already prefixed)
73
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
74
+ original_queue = queue_name.delete_suffix(dlq_suffix)
75
+
76
+ row = connection.select_one(
77
+ "SELECT * FROM pgmq.q_#{sanitize_name(queue_name)} WHERE msg_id = $1",
78
+ "Pgbus DLQ Read",
79
+ [msg_id.to_i]
80
+ )
81
+ return false unless row
82
+
83
+ @client.transaction do |txn|
84
+ # A live message must not claim to be dead: strip the block, count the trip.
85
+ txn.produce(original_queue, row["message"], headers: DeadLetterHeader.strip_for_retry(row["headers"]))
86
+ txn.delete(queue_name, msg_id.to_i)
87
+ end
88
+ true
89
+ rescue StandardError => e
90
+ Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{msg_id}: #{e.message}" }
91
+ false
92
+ end
93
+
94
+ def discard_dlq_message(queue_name, msg_id)
95
+ # queue_name here is the full DLQ name (already prefixed)
96
+ release_lock_for_message(queue_name, msg_id)
97
+ @client.delete_message(queue_name, msg_id.to_i, prefixed: false)
98
+ true
99
+ rescue StandardError => e
100
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{msg_id}: #{e.message}" }
101
+ false
102
+ end
103
+
104
+ def retry_all_dlq
105
+ messages = dlq_messages(page: 1, per_page: 1000)
106
+ count = 0
107
+ messages.each do |m|
108
+ retry_dlq_message(m[:queue_name], m[:msg_id]) && count += 1
109
+ rescue StandardError => e
110
+ Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{m[:msg_id]}: #{e.message}" }
111
+ next
112
+ end
113
+ count
114
+ end
115
+
116
+ def discard_all_dlq
117
+ messages = dlq_messages(page: 1, per_page: 1000)
118
+ return 0 if messages.empty?
119
+
120
+ release_locks_for_messages(messages)
121
+
122
+ # Group by queue for batch delete — one call per DLQ instead of N calls
123
+ messages.group_by { |m| m[:queue_name] }.sum do |queue_name, msgs|
124
+ ids = msgs.map { |m| m[:msg_id].to_i }
125
+ @client.delete_batch(queue_name, ids, prefixed: false).size
126
+ rescue StandardError => e
127
+ Pgbus.logger.debug { "[Pgbus::Web] Error batch-discarding DLQ messages from #{queue_name}: #{e.message}" }
128
+ 0
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Only names that are real DLQs ever reach sanitize_name and SQL.
135
+ def dlq_queue_names(dlq)
136
+ names = dlq_counts_by_queue.keys
137
+ dlq.nil? ? names : names & [dlq]
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Pgbus
6
+ module Web
7
+ class DataSource
8
+ # The Events page (issue #494): the unified job list scoped to the
9
+ # EventBus handler queues, the words EventState needs about consumers,
10
+ # and Replay of a processed event from its archived message.
11
+ #
12
+ # Handler names and patterns come from the subscriber registry, in Ruby:
13
+ # rows match on the physical queue (queue rows) or the logical one
14
+ # (orphaned failed rows, which carry the name the consumer recorded).
15
+ module EventList
16
+ # Lower bound on archived_at below the oldest claim on a page: the
17
+ # claim always precedes the archive, the margin absorbs clock skew.
18
+ ARCHIVE_PROBE_MARGIN = 60
19
+
20
+ # An archived envelope's routing key, where Publisher and the outbox
21
+ # put it. Without one the consumer would archive a replay unrouted.
22
+ ROUTING_KEY_SQL = "COALESCE(a.message->'headers'->>'routing_key', a.message->>'routing_key')"
23
+
24
+ def event_rows(state: nil, queue_name: nil, page: 1, per_page: 25)
25
+ queues = handler_queue_physical_names
26
+ return [] if queues.empty?
27
+
28
+ rows = job_rows(state: state, queue_name: queue_name, queues: queues, page: page, per_page: per_page)
29
+ by_queue = subscribers_by_queue_name
30
+ rows.map do |row|
31
+ sub = by_queue[row[:queue_name]]
32
+ row.merge(handler_class: sub&.dig(:handler_class), pattern: sub&.dig(:pattern))
33
+ end
34
+ end
35
+
36
+ def event_state_counts(queue_name: nil)
37
+ job_state_counts(queue_name: queue_name, queues: handler_queue_physical_names)
38
+ end
39
+
40
+ def events_ahead(rows) = jobs_ahead(rows)
41
+
42
+ def event_list_context(now: Time.now)
43
+ EventState::Context.new(jobs: job_list_context(now: now), covered_queues: covered_handler_queues,
44
+ claim_window: EventBus::Handler.claim_ownership_window)
45
+ end
46
+
47
+ # { processed_event id => :replayable | :not_archived | :no_handler }
48
+ # for one page of processed events: one bounded archive probe per
49
+ # handler queue, never one per row.
50
+ def event_replay_states(events)
51
+ subs = replay_subscribers
52
+ states = events.to_h { |e| [e["id"], subs[e["handler_class"]] ? :not_archived : :no_handler] }
53
+
54
+ events.select { |e| subs[e["handler_class"]] }.group_by { |e| subs[e["handler_class"]] }.each do |sub, rows|
55
+ keys = archived_routing_keys(sub[:physical_queue_name], rows)
56
+ rows.each { |e| states[e["id"]] = :replayable if replayable_key?(sub, keys[e["event_id"]]) }
57
+ end
58
+ states
59
+ end
60
+
61
+ # Re-deliver a processed event's archived envelope to its handler's
62
+ # queue, under a new event_id (so the idempotency claim is new) that
63
+ # links back through replayed_from. Only that handler runs again: a
64
+ # topic publish would re-run every subscriber of the routing key.
65
+ def replay_event(event)
66
+ sub = replay_subscribers[event["handler_class"]]
67
+ return false unless sub
68
+
69
+ archived = archived_event(sub[:physical_queue_name], event)
70
+ return false unless archived
71
+
72
+ message = JSON.parse(archived["message"])
73
+ return false unless replayable_key?(sub, event_routing_key(message))
74
+
75
+ replay = message.merge("event_id" => SecureRandom.uuid, "replayed_from" => event["event_id"])
76
+ @client.transaction do |txn|
77
+ txn.produce(sub[:physical_queue_name], JSON.generate(replay), headers: archived["headers"])
78
+ end
79
+ true
80
+ rescue StandardError => e
81
+ Pgbus.logger.error { "[Pgbus::Web] Error replaying event #{event["event_id"]}: #{e.class}: #{e.message}" }
82
+ false
83
+ end
84
+
85
+ private
86
+
87
+ def subscribers_by_queue_name
88
+ registered_subscribers.each_with_object({}) do |s, out|
89
+ out[s[:physical_queue_name]] ||= s
90
+ out[s[:queue_name]] ||= s
91
+ end
92
+ end
93
+
94
+ # Physical handler queues some healthy consumer reads: its topics
95
+ # overlap the subscriber's pattern (Registry#queue_names_for_topics'
96
+ # rule). nil when a healthy consumer does not report its topics.
97
+ def covered_handler_queues
98
+ consumers = processes.select { |p| p[:kind].to_s == "consumer" && p[:healthy] }
99
+ topics = consumers.map { |p| p[:metadata].is_a?(Hash) ? p[:metadata]["topics"] : nil }
100
+ return nil if topics.any?(&:nil?)
101
+
102
+ registry = EventBus::Registry.instance
103
+ topics = topics.flatten
104
+ registered_subscribers.select { |s| topics.any? { |t| registry.pattern_overlaps?(t, s[:pattern]) } }
105
+ .to_set { |s| s[:physical_queue_name] }
106
+ end
107
+
108
+ # { event_id => routing key } for the page's events still in the archive.
109
+ def archived_routing_keys(queue, events)
110
+ ids = events.map { |e| text_array_element(e["event_id"]) }
111
+ rows = connection.select_all(<<~SQL, "Pgbus Archived Events", ["{#{ids.join(",")}}", archive_floor(events)])
112
+ SELECT a.message->>'event_id' AS event_id, #{ROUTING_KEY_SQL} AS routing_key
113
+ FROM pgmq.a_#{sanitize_name(queue)} a
114
+ WHERE a.archived_at >= $2::timestamptz AND a.message->>'event_id' = ANY($1::text[])
115
+ AND #{ROUTING_KEY_SQL} IS NOT NULL
116
+ SQL
117
+ rows.to_a.to_h { |r| [r["event_id"], r["routing_key"]] }
118
+ rescue StandardError => e
119
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the archive of #{queue}: #{e.class}: #{e.message}" }
120
+ {}
121
+ end
122
+
123
+ # The registration Replay uses per handler class: the first, so the
124
+ # availability check and the action never disagree.
125
+ def replay_subscribers
126
+ registered_subscribers.each_with_object({}) { |s, out| out[s[:handler_class]] ||= s }
127
+ end
128
+
129
+ # A replay the consumer would archive unrouted is no replay: the key
130
+ # must still match the subscriber's pattern.
131
+ def replayable_key?(sub, routing_key)
132
+ !routing_key.nil? && EventBus::Registry.instance.pattern_matches?(sub[:pattern], routing_key)
133
+ end
134
+
135
+ # event_id is any string an envelope carried (Edit & Retry accepts any
136
+ # payload), so each element of the bound text[] literal is quoted.
137
+ def text_array_element(value)
138
+ %("#{value.to_s.gsub(/["\\]/) { |c| "\\#{c}" }}")
139
+ end
140
+
141
+ def archived_event(queue, event)
142
+ connection.select_one(<<~SQL, "Pgbus Archived Event", [event["event_id"].to_s, archive_floor([event])])
143
+ SELECT a.message::text AS message, a.headers::text AS headers FROM pgmq.a_#{sanitize_name(queue)} a
144
+ WHERE a.message->>'event_id' = $1 AND a.archived_at >= $2::timestamptz
145
+ ORDER BY a.archived_at DESC LIMIT 1
146
+ SQL
147
+ end
148
+
149
+ def archive_floor(events)
150
+ oldest = events.filter_map { |e| JobState.time(e["processed_at"]) }.min || Time.at(0)
151
+ (oldest - ARCHIVE_PROBE_MARGIN).utc.iso8601(6)
152
+ end
153
+
154
+ def event_routing_key(message)
155
+ headers = message["headers"]
156
+ (headers["routing_key"] if headers.is_a?(Hash)).presence || message["routing_key"].presence
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end