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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +83 -13
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
- data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
- data/app/controllers/pgbus/application_controller.rb +5 -2
- data/app/controllers/pgbus/batches_controller.rb +4 -1
- data/app/controllers/pgbus/dashboard_controller.rb +20 -6
- data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
- data/app/controllers/pgbus/events_controller.rb +52 -3
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/controllers/pgbus/insights_controller.rb +19 -1
- data/app/controllers/pgbus/jobs_controller.rb +45 -26
- data/app/controllers/pgbus/locks_controller.rb +7 -2
- data/app/controllers/pgbus/outbox_controller.rb +4 -1
- data/app/controllers/pgbus/processes_controller.rb +3 -1
- data/app/controllers/pgbus/queues_controller.rb +16 -4
- data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +137 -77
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/helpers/pgbus/application_helper.rb +155 -113
- data/app/helpers/pgbus/button_helper.rb +88 -0
- data/app/helpers/pgbus/dashboard_helper.rb +157 -0
- data/app/helpers/pgbus/events_helper.rb +40 -0
- data/app/helpers/pgbus/insights_helper.rb +39 -0
- data/app/helpers/pgbus/processes_helper.rb +79 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
- data/app/views/pgbus/batches/show.html.erb +3 -4
- data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
- data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
- data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
- data/app/views/pgbus/dashboard/show.html.erb +1 -1
- data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
- data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
- data/app/views/pgbus/dead_letter/index.html.erb +6 -9
- data/app/views/pgbus/dead_letter/show.html.erb +37 -21
- data/app/views/pgbus/events/_list.html.erb +55 -0
- data/app/views/pgbus/events/_processed_table.html.erb +45 -0
- data/app/views/pgbus/events/_replay.html.erb +10 -0
- data/app/views/pgbus/events/_row.html.erb +162 -0
- data/app/views/pgbus/events/_tabs.html.erb +13 -0
- data/app/views/pgbus/events/index.html.erb +27 -48
- data/app/views/pgbus/events/show.html.erb +25 -7
- data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
- data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
- data/app/views/pgbus/insights/_status_table.html.erb +19 -0
- data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
- data/app/views/pgbus/insights/show.html.erb +83 -100
- data/app/views/pgbus/jobs/_list.html.erb +64 -0
- data/app/views/pgbus/jobs/_row.html.erb +107 -0
- data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
- data/app/views/pgbus/jobs/index.html.erb +10 -10
- data/app/views/pgbus/jobs/show.html.erb +18 -20
- data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
- data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
- data/app/views/pgbus/outbox/index.html.erb +30 -27
- data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
- data/app/views/pgbus/processes/_row.html.erb +48 -0
- data/app/views/pgbus/processes/_status.html.erb +4 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
- data/app/views/pgbus/queues/show.html.erb +69 -118
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
- data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
- data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
- data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
- data/app/views/pgbus/shared/_pager.html.erb +34 -0
- data/config/i18n-tasks.yml +11 -2
- data/config/locales/da.yml +405 -113
- data/config/locales/de.yml +405 -113
- data/config/locales/en.yml +405 -113
- data/config/locales/es.yml +404 -112
- data/config/locales/fi.yml +404 -112
- data/config/locales/fr.yml +406 -114
- data/config/locales/it.yml +405 -113
- data/config/locales/ja.yml +405 -113
- data/config/locales/nb.yml +405 -113
- data/config/locales/nl.yml +405 -113
- data/config/locales/pt.yml +406 -114
- data/config/locales/sv.yml +404 -112
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
- data/lib/pgbus/active_job/executor.rb +16 -6
- data/lib/pgbus/cli/dlq.rb +32 -5
- data/lib/pgbus/client.rb +4 -2
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration.rb +51 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/event_bus/handler.rb +18 -11
- data/lib/pgbus/event_bus/registry.rb +6 -0
- data/lib/pgbus/failed_event_recorder.rb +34 -0
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/instrumentation.rb +2 -1
- data/lib/pgbus/mcp/base_tool.rb +25 -0
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
- data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
- data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
- data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
- data/lib/pgbus/outbox/poller.rb +1 -1
- data/lib/pgbus/process/consumer.rb +53 -3
- data/lib/pgbus/process/dispatcher.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +32 -11
- data/lib/pgbus/process/worker.rb +45 -8
- data/lib/pgbus/recurring/scheduler.rb +2 -1
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
- data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
- data/lib/pgbus/web/data_source/event_list.rb +161 -0
- data/lib/pgbus/web/data_source/job_list.rb +323 -0
- data/lib/pgbus/web/data_source/list_counts.rb +80 -0
- data/lib/pgbus/web/data_source/process_list.rb +69 -0
- data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
- data/lib/pgbus/web/data_source.rb +132 -254
- data/lib/pgbus/web/dead_letter_reason.rb +81 -0
- data/lib/pgbus/web/event_state.rb +71 -0
- data/lib/pgbus/web/health_app.rb +7 -3
- data/lib/pgbus/web/health_verdict.rb +78 -0
- data/lib/pgbus/web/insights_charts.rb +84 -0
- data/lib/pgbus/web/job_state.rb +111 -0
- data/lib/pgbus/web/process_activity.rb +97 -0
- data/lib/pgbus/web/process_state.rb +187 -0
- data/lib/pgbus/web/queue_summary.rb +104 -0
- data/lib/pgbus/web/stream_app.rb +18 -6
- data/lib/pgbus/web/streamer/connection.rb +7 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +2 -1
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
- data/lib/pgbus/web/time_format.rb +128 -0
- metadata +55 -4
- data/app/views/pgbus/events/_pending_table.html.erb +0 -169
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
- 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
|