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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +48 -13
- 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/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/modules/charts.js +119 -77
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -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/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/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.rb +47 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- 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/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/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/time_format.rb +128 -0
- metadata +43 -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
|
@@ -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
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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(
|
|
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
|
|
688
|
-
# first. The two tables are joined FULL OUTER, not LEFT
|
|
689
|
-
#
|
|
690
|
-
|
|
691
|
-
|
|
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(
|
|
765
|
-
records = BatchEntry.order(created_at: :desc).limit(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
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 (#{
|
|
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
|