pgbus 0.17.0 → 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 +50 -0
- data/README.md +69 -14
- data/Rakefile +7 -1
- 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 +36 -17
- data/lib/pgbus/cli/dlq.rb +32 -5
- data/lib/pgbus/client.rb +7 -2
- data/lib/pgbus/configuration.rb +93 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +37 -5
- 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 +51 -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/claim_buffer.rb +115 -0
- data/lib/pgbus/process/consumer.rb +94 -17
- data/lib/pgbus/process/dispatcher.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +35 -12
- data/lib/pgbus/process/worker.rb +91 -25
- data/lib/pgbus/recurring/scheduler.rb +2 -1
- data/lib/pgbus/ruby_jit.rb +21 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +38 -3
- 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 +45 -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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
# Records job failures to pgbus_failed_events for dashboard visibility.
|
|
5
7
|
# Uses upsert (INSERT ON CONFLICT UPDATE) keyed on (queue_name, msg_id)
|
|
@@ -47,6 +49,48 @@ module Pgbus
|
|
|
47
49
|
false
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# The message's latest recorded failure, read by a mover just before it
|
|
53
|
+
# dead-letters the message and clears the row (issue #495). failed_at
|
|
54
|
+
# comes back as an ISO 8601 UTC string whatever the adapter decodes it
|
|
55
|
+
# to. A failed read returns nil: it must never stop a dead-letter.
|
|
56
|
+
def last_error(queue_name:, msg_id:)
|
|
57
|
+
row = connection.select_one(
|
|
58
|
+
"SELECT error_class, error_message, backtrace, retry_count, failed_at " \
|
|
59
|
+
"FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
|
|
60
|
+
"FailedEvent Last Error",
|
|
61
|
+
[queue_name, msg_id.to_i]
|
|
62
|
+
)
|
|
63
|
+
return unless row
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
error_class: row["error_class"],
|
|
67
|
+
error_message: row["error_message"],
|
|
68
|
+
backtrace: row["backtrace"],
|
|
69
|
+
retry_count: row["retry_count"].to_i,
|
|
70
|
+
failed_at: iso_utc(row["failed_at"])
|
|
71
|
+
}
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Pgbus.logger.debug { "[Pgbus] FailedEvent last_error read failed: #{e.class}: #{e.message}" }
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Drops every row recorded under any of `queue_names`. Called when a
|
|
78
|
+
# queue is dropped: PGMQ restarts msg_ids on recreate, so a surviving
|
|
79
|
+
# row would aim the dashboard's retry/discard at an unrelated message.
|
|
80
|
+
# Queue names are validated word characters, so the array literal is safe.
|
|
81
|
+
def clear_queue!(queue_names)
|
|
82
|
+
connection.exec_delete(
|
|
83
|
+
"DELETE FROM pgbus_failed_events WHERE queue_name = ANY($1::text[])",
|
|
84
|
+
"FailedEvent Clear Queue",
|
|
85
|
+
["{#{queue_names.join(",")}}"]
|
|
86
|
+
)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Pgbus.logger.error do
|
|
89
|
+
"[Pgbus] Failed to clear failed events for dropped queue(s) #{queue_names.join(", ")}: " \
|
|
90
|
+
"#{e.class}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
50
94
|
def clear!(queue_name:, msg_id:)
|
|
51
95
|
connection.exec_delete(
|
|
52
96
|
"DELETE FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2",
|
|
@@ -65,6 +109,13 @@ module Pgbus
|
|
|
65
109
|
|
|
66
110
|
private
|
|
67
111
|
|
|
112
|
+
def iso_utc(value)
|
|
113
|
+
time = value.is_a?(String) ? Time.parse(value) : value
|
|
114
|
+
time&.utc&.iso8601(6)
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
value.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
68
119
|
def connection
|
|
69
120
|
if defined?(BusRecord) && BusRecord.connected?
|
|
70
121
|
BusRecord.connection
|
|
@@ -15,7 +15,8 @@ module Pgbus
|
|
|
15
15
|
# pgbus.executor.execute — full job execution (deserialize + perform + archive)
|
|
16
16
|
# pgbus.job_completed — job archived successfully
|
|
17
17
|
# pgbus.job_failed — job raised; carries :exception_object
|
|
18
|
-
# pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed
|
|
18
|
+
# pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed;
|
|
19
|
+
# :error is the last recorded error class (or nil)
|
|
19
20
|
# pgbus.job_visibility_extended — heartbeat re-armed a running job's visibility timeout
|
|
20
21
|
# payload: queue, job_class, msg_id, vt, extensions
|
|
21
22
|
# pgbus.event_processed — event handler succeeded
|
data/lib/pgbus/mcp/base_tool.rb
CHANGED
|
@@ -70,6 +70,31 @@ module Pgbus
|
|
|
70
70
|
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(redacted) }])
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Past the count cap the total is a lower bound, so only a full page
|
|
74
|
+
# can have another one behind it. At the page ceiling the next call
|
|
75
|
+
# would clamp back to this page, so there is no "more" to fetch.
|
|
76
|
+
def more_pages?(count, page:, per_page:, shown:)
|
|
77
|
+
return false if page >= self::MAX_PAGE
|
|
78
|
+
|
|
79
|
+
count.capped? ? shown == per_page : page * per_page < count.total
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
DEAD_LETTER_SUMMARY_KEYS = %w[reason source source_queue attempts max_retries dead_lettered_at
|
|
83
|
+
retries_from_dlq error_class error_attempt error_recorded_at].freeze
|
|
84
|
+
DEAD_LETTER_ERROR_KEYS = %w[error_message backtrace].freeze
|
|
85
|
+
|
|
86
|
+
# Why a DLQ row died, from its DeadLetterHeader block (issue #495), or
|
|
87
|
+
# nil for a row dead-lettered before the block existed. The error
|
|
88
|
+
# message and backtrace can quote payload values, and the Redactor
|
|
89
|
+
# does not know those keys, so they are added only with include_error.
|
|
90
|
+
def dead_letter_summary(row, include_error:)
|
|
91
|
+
block = Pgbus::DeadLetterHeader.parse(row[:headers])
|
|
92
|
+
return unless block
|
|
93
|
+
|
|
94
|
+
keys = include_error ? DEAD_LETTER_SUMMARY_KEYS + DEAD_LETTER_ERROR_KEYS : DEAD_LETTER_SUMMARY_KEYS
|
|
95
|
+
block.slice(*keys)
|
|
96
|
+
end
|
|
97
|
+
|
|
73
98
|
# Wrap an error message as an MCP error response (isError: true) so the
|
|
74
99
|
# client surfaces it as a tool failure rather than a normal result.
|
|
75
100
|
def error_response(message)
|
|
@@ -17,14 +17,33 @@ module Pgbus
|
|
|
17
17
|
their limit — plus up to 100 key rows with value/limit, lease expiry,
|
|
18
18
|
whether the lease is still fresh, parked count and oldest wait. A key
|
|
19
19
|
with a stale lease and a parked backlog is a stuck pipeline: its holder
|
|
20
|
-
died before releasing the slot.
|
|
20
|
+
died before releasing the slot. The key rows are paginated (default
|
|
21
|
+
100 per page, capped at 100); the response also carries page,
|
|
22
|
+
per_page, total (number of keys, capped at 10000) and has_more.
|
|
21
23
|
DESC
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
MAX_PER_PAGE = 100
|
|
26
|
+
MAX_PAGE = 1_000
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
input_schema(
|
|
29
|
+
properties: {
|
|
30
|
+
page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
|
|
31
|
+
per_page: { type: "integer", description: "Rows per page (default 100, max 100).", minimum: 1 }
|
|
32
|
+
},
|
|
33
|
+
required: []
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def self.call(page: 1, per_page: MAX_PER_PAGE, server_context: nil)
|
|
26
37
|
data_source = data_source_from(server_context)
|
|
27
|
-
|
|
38
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
39
|
+
page = page.to_i.clamp(1, MAX_PAGE)
|
|
40
|
+
stats = data_source.concurrency_stats(page: page, per_page: per_page)
|
|
41
|
+
count = data_source.list_count(:concurrency_keys)
|
|
42
|
+
|
|
43
|
+
json_response(
|
|
44
|
+
stats.merge(page: page, per_page: per_page, total: count.total,
|
|
45
|
+
has_more: more_pages?(count, page: page, per_page: per_page, shown: stats[:keys].size))
|
|
46
|
+
)
|
|
28
47
|
end
|
|
29
48
|
end
|
|
30
49
|
end
|
|
@@ -12,7 +12,7 @@ module Pgbus
|
|
|
12
12
|
title "Pgbus Dead-Letter Detail"
|
|
13
13
|
description <<~DESC
|
|
14
14
|
Inspect one dead-letter message: read_ct, vt, enqueued_at, source
|
|
15
|
-
queue. Pass the full physical DLQ queue name (e.g.
|
|
15
|
+
queue, and a dead_letter summary of why it died. Pass the full physical DLQ queue name (e.g.
|
|
16
16
|
"pgbus_default_dlq") in +queue+ to disambiguate — msg_ids are only
|
|
17
17
|
unique within a single DLQ table, so the same id can exist in more
|
|
18
18
|
than one. Without +queue+ the tool scans every "_dlq" table and
|
|
@@ -48,7 +48,9 @@ module Pgbus
|
|
|
48
48
|
return error_response("Dead-letter message #{msg_id} not found") unless detail
|
|
49
49
|
return detail if detail.is_a?(::MCP::Tool::Response)
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
summary = dead_letter_summary(detail, include_error: payloads_allowed?(server_context, include_payloads))
|
|
52
|
+
json_response({ message: detail.merge(dead_letter: summary) },
|
|
53
|
+
server_context: server_context, include_payloads: include_payloads)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# When the caller didn't specify a queue, scan every DLQ for the id and
|
|
@@ -10,9 +10,12 @@ module Pgbus
|
|
|
10
10
|
title "Pgbus Dead-Letter Queue"
|
|
11
11
|
description <<~DESC
|
|
12
12
|
List messages sitting in dead-letter queues (queues whose name ends
|
|
13
|
-
in "_dlq") with read_ct, vt and enqueued_at
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
in "_dlq") with read_ct, vt and enqueued_at, and a dead_letter
|
|
14
|
+
summary of why each died (reason, source queue, attempts, error
|
|
15
|
+
class; null for messages dead-lettered before pgbus recorded it).
|
|
16
|
+
Paginated (default 25, max 100). Message bodies, headers and the
|
|
17
|
+
error message/backtrace are redacted unless the server allows
|
|
18
|
+
payloads and include_payloads is set.
|
|
16
19
|
DESC
|
|
17
20
|
|
|
18
21
|
MAX_PER_PAGE = 100
|
|
@@ -34,7 +37,10 @@ module Pgbus
|
|
|
34
37
|
data_source = data_source_from(server_context)
|
|
35
38
|
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
36
39
|
page = page.to_i.clamp(1, MAX_PAGE)
|
|
37
|
-
|
|
40
|
+
include_error = payloads_allowed?(server_context, include_payloads)
|
|
41
|
+
rows = data_source.dlq_messages(page: page, per_page: per_page).map do |row|
|
|
42
|
+
row.merge(dead_letter: dead_letter_summary(row, include_error: include_error))
|
|
43
|
+
end
|
|
38
44
|
|
|
39
45
|
json_response(
|
|
40
46
|
{ page: page, per_page: per_page, messages: rows },
|
|
@@ -12,15 +12,33 @@ module Pgbus
|
|
|
12
12
|
description <<~DESC
|
|
13
13
|
List active job uniqueness locks with their lock_key, queue_name,
|
|
14
14
|
msg_id, and age in seconds. A lock that outlives its message
|
|
15
|
-
indicates a leaked uniqueness key blocking re-enqueues.
|
|
16
|
-
100
|
|
15
|
+
indicates a leaked uniqueness key blocking re-enqueues. Paginated
|
|
16
|
+
(default 100 per page, capped at 100); the response carries page,
|
|
17
|
+
per_page, total (capped at 10000) and has_more.
|
|
17
18
|
DESC
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
MAX_PER_PAGE = 100
|
|
21
|
+
MAX_PAGE = 1_000
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
input_schema(
|
|
24
|
+
properties: {
|
|
25
|
+
page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
|
|
26
|
+
per_page: { type: "integer", description: "Rows per page (default 100, max 100).", minimum: 1 }
|
|
27
|
+
},
|
|
28
|
+
required: []
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def self.call(page: 1, per_page: MAX_PER_PAGE, server_context: nil)
|
|
22
32
|
data_source = data_source_from(server_context)
|
|
23
|
-
|
|
33
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
34
|
+
page = page.to_i.clamp(1, MAX_PAGE)
|
|
35
|
+
locks = data_source.job_locks(page: page, per_page: per_page)
|
|
36
|
+
count = data_source.list_count(:job_locks)
|
|
37
|
+
|
|
38
|
+
json_response(
|
|
39
|
+
{ locks: locks, page: page, per_page: per_page, total: count.total,
|
|
40
|
+
has_more: more_pages?(count, page: page, per_page: per_page, shown: locks.size) }
|
|
41
|
+
)
|
|
24
42
|
end
|
|
25
43
|
end
|
|
26
44
|
end
|
|
@@ -12,15 +12,44 @@ module Pgbus
|
|
|
12
12
|
description <<~DESC
|
|
13
13
|
List recurring (cron) tasks with their schedule, human-readable
|
|
14
14
|
schedule, queue, enabled state, last_run_at and next_run_at. Use this
|
|
15
|
-
to confirm scheduled work is firing on time.
|
|
15
|
+
to confirm scheduled work is firing on time. Returns every task unless
|
|
16
|
+
page or per_page is given; then it is paginated (per_page defaults to
|
|
17
|
+
100, capped at 100). The response carries page, per_page, total
|
|
18
|
+
(capped at 10000 when paginated; the exact row count when not) and
|
|
19
|
+
has_more; per_page is null when unpaginated.
|
|
16
20
|
DESC
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
MAX_PER_PAGE = 100
|
|
23
|
+
MAX_PAGE = 1_000
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
input_schema(
|
|
26
|
+
properties: {
|
|
27
|
+
page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
|
|
28
|
+
per_page: { type: "integer", description: "Rows per page (default 100 once paginating, max 100).", minimum: 1 }
|
|
29
|
+
},
|
|
30
|
+
required: []
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def self.call(page: nil, per_page: nil, server_context: nil)
|
|
21
34
|
data_source = data_source_from(server_context)
|
|
22
|
-
|
|
35
|
+
return unpaged(data_source) if page.nil? && per_page.nil?
|
|
36
|
+
|
|
37
|
+
per_page = (per_page || MAX_PER_PAGE).to_i.clamp(1, MAX_PER_PAGE)
|
|
38
|
+
page = (page || 1).to_i.clamp(1, MAX_PAGE)
|
|
39
|
+
tasks = data_source.recurring_tasks(page: page, per_page: per_page)
|
|
40
|
+
count = data_source.list_count(:recurring_tasks)
|
|
41
|
+
|
|
42
|
+
json_response(
|
|
43
|
+
{ recurring_tasks: tasks, page: page, per_page: per_page, total: count.total,
|
|
44
|
+
has_more: more_pages?(count, page: page, per_page: per_page, shown: tasks.size) }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.unpaged(data_source)
|
|
49
|
+
tasks = data_source.recurring_tasks
|
|
50
|
+
json_response({ recurring_tasks: tasks, page: 1, per_page: nil, total: tasks.size, has_more: false })
|
|
23
51
|
end
|
|
52
|
+
private_class_method :unpaged
|
|
24
53
|
end
|
|
25
54
|
end
|
|
26
55
|
end
|
data/lib/pgbus/outbox/poller.rb
CHANGED
|
@@ -139,7 +139,7 @@ module Pgbus
|
|
|
139
139
|
def start_heartbeat
|
|
140
140
|
@heartbeat = Process::Heartbeat.new(
|
|
141
141
|
kind: "outbox_poller",
|
|
142
|
-
metadata: { pid: ::Process.pid }
|
|
142
|
+
metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid }
|
|
143
143
|
)
|
|
144
144
|
@heartbeat.start
|
|
145
145
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# The read-ahead buffer a Worker or Consumer owns (issue #486).
|
|
6
|
+
#
|
|
7
|
+
# Under database latency the claim loop used to issue one read round trip
|
|
8
|
+
# per job: once the pool is full, slots free one at a time and each loop
|
|
9
|
+
# step read `qty = free slots` = 1. With `read_ahead = N` the loop claims
|
|
10
|
+
# up to N messages beyond its free slots and parks them here, so one read
|
|
11
|
+
# returns a batch and the pool never waits on the reader.
|
|
12
|
+
#
|
|
13
|
+
# Surplus claims cannot live inside the execution pool: AsyncPool#post
|
|
14
|
+
# raises at zero capacity and a queued block could not be handed back on
|
|
15
|
+
# drain. So the buffer is drained into the pool only while it has a free
|
|
16
|
+
# slot, and returned to the queue (vt: 0) on drain, recycle or shutdown.
|
|
17
|
+
#
|
|
18
|
+
# Every buffered message is kept invisible by a VisibilityHeartbeat hold
|
|
19
|
+
# from claim until the run starts, under the executor's key
|
|
20
|
+
# (`source_queue || queue_name`, `prefixed: source_queue.nil?`), so the
|
|
21
|
+
# run's own tracking takes over the same entry with no gap. The hold does
|
|
22
|
+
# not know the job class, so a job that opted out with
|
|
23
|
+
# `pgbus_visibility_heartbeat false` is still held while it waits here:
|
|
24
|
+
# the opt-out covers its run, not the wait. With the heartbeat disabled
|
|
25
|
+
# globally there is no hold at all (Configuration#validate! warns).
|
|
26
|
+
#
|
|
27
|
+
# Only ever touched from the owning process's loop thread, so no mutex.
|
|
28
|
+
class ClaimBuffer
|
|
29
|
+
Claim = Struct.new(:queue_name, :message, :source_queue, :hold)
|
|
30
|
+
|
|
31
|
+
HOLD_LABEL = "(read-ahead)"
|
|
32
|
+
|
|
33
|
+
def initialize(config: Pgbus.configuration)
|
|
34
|
+
@config = config
|
|
35
|
+
@claims = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def size
|
|
39
|
+
@claims.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty?
|
|
43
|
+
@claims.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def push(queue_name, message, source_queue = nil, client: Pgbus.client)
|
|
47
|
+
hold = VisibilityHeartbeat.hold(
|
|
48
|
+
client: client, queue_name: source_queue || queue_name, prefixed: source_queue.nil?,
|
|
49
|
+
msg_id: message.msg_id, job_class: HOLD_LABEL, config: @config
|
|
50
|
+
)
|
|
51
|
+
@claims << Claim.new(queue_name, message, source_queue, hold)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def shift
|
|
56
|
+
@claims.shift
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Yields the oldest claims, one per free pool slot, and returns how many.
|
|
60
|
+
# Capacity is re-read before every claim: the block's post takes a slot.
|
|
61
|
+
def drain_into(pool)
|
|
62
|
+
drained = 0
|
|
63
|
+
while !@claims.empty? && pool.available_capacity.positive?
|
|
64
|
+
yield @claims.shift
|
|
65
|
+
drained += 1
|
|
66
|
+
end
|
|
67
|
+
drained
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How many messages the next read should claim: enough to fill the free
|
|
71
|
+
# slots and top the buffer back up to `read_ahead`, never more than
|
|
72
|
+
# `prefetch_room` (prefetch_limit minus everything already claimed).
|
|
73
|
+
# With read_ahead 0 and an empty buffer this is `free_slots`: the
|
|
74
|
+
# pre-#486 qty.
|
|
75
|
+
def deficit(free_slots:, read_ahead:, prefetch_room: nil)
|
|
76
|
+
want = free_slots + read_ahead - @claims.size
|
|
77
|
+
want = [want, prefetch_room].min if prefetch_room
|
|
78
|
+
want.clamp(0..)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Hand every buffered message back to the queue right away instead of
|
|
82
|
+
# leaving it invisible until its timeout. Their read_ct has already been
|
|
83
|
+
# bumped, the same cost a crash or a stale claim pays. A failed return
|
|
84
|
+
# is logged and skipped: the hold is already dropped, so the message
|
|
85
|
+
# reappears when its current timeout runs out. Returns the count. One
|
|
86
|
+
# claim at a time, so #size stays true while the returns are written.
|
|
87
|
+
def return_all!(client: Pgbus.client)
|
|
88
|
+
returned = 0
|
|
89
|
+
while (claim = @claims.shift)
|
|
90
|
+
return_claim(claim, client)
|
|
91
|
+
returned += 1
|
|
92
|
+
end
|
|
93
|
+
returned
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# The hold goes first: a heartbeat tick that still held the entry could
|
|
99
|
+
# otherwise write its extension after the vt: 0 and hide the message
|
|
100
|
+
# again for a full visibility_timeout. (VisibilityHeartbeat#extend!
|
|
101
|
+
# re-checks registration, which shrinks the remaining window to the
|
|
102
|
+
# tick's own check-then-write.)
|
|
103
|
+
def return_claim(claim, client)
|
|
104
|
+
VisibilityHeartbeat.release(claim.hold)
|
|
105
|
+
queue = claim.source_queue || claim.queue_name
|
|
106
|
+
client.set_visibility_timeout(queue, claim.message.msg_id.to_i, vt: 0, prefixed: claim.source_queue.nil?)
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Pgbus.logger.warn do
|
|
109
|
+
"[Pgbus] Could not return read-ahead message msg_id=#{claim.message.msg_id} queue=#{queue}: " \
|
|
110
|
+
"#{e.class}: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -7,7 +7,7 @@ module Pgbus
|
|
|
7
7
|
class Consumer
|
|
8
8
|
include SignalHandler
|
|
9
9
|
|
|
10
|
-
attr_reader :topics, :threads, :config, :execution_mode,
|
|
10
|
+
attr_reader :topics, :threads, :config, :execution_mode, :read_ahead,
|
|
11
11
|
:queue_names, :wake_signal, :notify_retry_backoff, :circuit_breaker
|
|
12
12
|
# notify_listener is writable so tests can simulate a start_notify_listener
|
|
13
13
|
# success from inside a stub (production sets it in start_notify_listener).
|
|
@@ -30,6 +30,11 @@ module Pgbus
|
|
|
30
30
|
@jobs_processed.value
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Messages claimed by read-ahead and not yet handed to the pool (#486).
|
|
34
|
+
def buffered
|
|
35
|
+
@claim_buffer.size
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
# Seed the processed-job counter. Used by tests to drive the recycle
|
|
34
39
|
# thresholds without running thousands of real jobs; production only ever
|
|
35
40
|
# increments it via the AtomicFixnum during message handling.
|
|
@@ -60,7 +65,7 @@ module Pgbus
|
|
|
60
65
|
queue_names: nil, liveness_pipe: nil, stat_buffer: :default,
|
|
61
66
|
notify_listener: nil, notify_retry_at: 0.0,
|
|
62
67
|
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
63
|
-
started_at_monotonic: nil, wake_pipe: nil)
|
|
68
|
+
started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil)
|
|
64
69
|
@topics = Array(topics)
|
|
65
70
|
@threads = threads
|
|
66
71
|
@config = config
|
|
@@ -68,8 +73,12 @@ module Pgbus
|
|
|
68
73
|
@shutting_down = false
|
|
69
74
|
@recycling = false
|
|
70
75
|
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
76
|
+
@read_ahead = read_ahead || config.read_ahead
|
|
77
|
+
@claim_buffer = ClaimBuffer.new(config: config)
|
|
71
78
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
72
79
|
@started_at_monotonic = started_at_monotonic || monotonic_now
|
|
80
|
+
# Wall clock for the Processes page's uptime (issue #493).
|
|
81
|
+
@started_at = Time.current
|
|
73
82
|
@wake_signal = WakeSignal.new
|
|
74
83
|
@pool = ExecutionPools.build(
|
|
75
84
|
mode: @execution_mode,
|
|
@@ -134,7 +143,12 @@ module Pgbus
|
|
|
134
143
|
check_recycle
|
|
135
144
|
ensure_notify_listener
|
|
136
145
|
|
|
137
|
-
|
|
146
|
+
if @shutting_down
|
|
147
|
+
# No drain loop here: buffered claims go straight back to the
|
|
148
|
+
# queue; shutdown then waits for the ones already running.
|
|
149
|
+
@claim_buffer.return_all!(client: Pgbus.client) unless @claim_buffer.empty?
|
|
150
|
+
break
|
|
151
|
+
end
|
|
138
152
|
|
|
139
153
|
consume
|
|
140
154
|
@stat_buffer&.flush_if_due
|
|
@@ -171,20 +185,35 @@ module Pgbus
|
|
|
171
185
|
@queue_names = @registry.queue_names_for_topics(topics)
|
|
172
186
|
end
|
|
173
187
|
|
|
188
|
+
# Same loop step as Worker#claim_and_execute (issue #486): feed
|
|
189
|
+
# buffered claims to free slots, read the deficit, feed again, and wait
|
|
190
|
+
# only when the step moved nothing.
|
|
174
191
|
def consume
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
drained = drain_claim_buffer
|
|
193
|
+
claimed = claim_deficit
|
|
194
|
+
drained += drain_claim_buffer
|
|
195
|
+
@wake_signal.wait(timeout: wake_timeout) if drained.zero? && claimed.zero?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The hold is released before handle_message's own heartbeat tracking
|
|
199
|
+
# registers the same key, so the message is never untracked in between.
|
|
200
|
+
def drain_claim_buffer
|
|
201
|
+
@claim_buffer.drain_into(@pool) do |claim|
|
|
202
|
+
@pool.post do
|
|
203
|
+
VisibilityHeartbeat.release(claim.hold)
|
|
204
|
+
handle_message(claim.message, claim.queue_name)
|
|
205
|
+
end
|
|
183
206
|
end
|
|
207
|
+
end
|
|
184
208
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
def claim_deficit
|
|
210
|
+
want = @claim_buffer.deficit(free_slots: @pool.available_capacity, read_ahead: @read_ahead)
|
|
211
|
+
return 0 if want.zero?
|
|
212
|
+
|
|
213
|
+
tagged_messages = fetch_messages(want)
|
|
214
|
+
client = Pgbus.client
|
|
215
|
+
tagged_messages.each { |queue_name, message| @claim_buffer.push(queue_name, message, client: client) }
|
|
216
|
+
tagged_messages.size
|
|
188
217
|
end
|
|
189
218
|
|
|
190
219
|
# Returns an array of [queue_name, message] pairs. Queues whose circuit
|
|
@@ -219,7 +248,7 @@ module Pgbus
|
|
|
219
248
|
|
|
220
249
|
if message.read_ct.to_i > config.max_retries
|
|
221
250
|
Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
|
|
222
|
-
|
|
251
|
+
dead_letter(message, queue_name)
|
|
223
252
|
record_stat(message, queue_name, "dead_lettered", execution_start)
|
|
224
253
|
return
|
|
225
254
|
end
|
|
@@ -236,6 +265,9 @@ module Pgbus
|
|
|
236
265
|
end
|
|
237
266
|
|
|
238
267
|
Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
|
|
268
|
+
# A redelivery that succeeds leaves no stale failure behind. First
|
|
269
|
+
# deliveries never had one: no query on the hot path (#484's gate).
|
|
270
|
+
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i) if message.read_ct.to_i > 1
|
|
239
271
|
@circuit_breaker.record_success(queue_name)
|
|
240
272
|
record_stat(message, queue_name, "success", execution_start)
|
|
241
273
|
rescue StandardError => e
|
|
@@ -243,6 +275,9 @@ module Pgbus
|
|
|
243
275
|
# Message stays in queue; VT will expire and it becomes available again.
|
|
244
276
|
# read_ct tracks delivery attempts — when it exceeds max_retries,
|
|
245
277
|
# the next read will route to DLQ above.
|
|
278
|
+
# A dead-letter read fails only in the move: keep the handler's error
|
|
279
|
+
# on the row for the next attempt's DLQ block instead of the move's.
|
|
280
|
+
record_failed_event(message, queue_name, e) unless message.read_ct.to_i > config.max_retries
|
|
246
281
|
@circuit_breaker.record_failure(queue_name)
|
|
247
282
|
record_stat(message, queue_name, "failed", execution_start)
|
|
248
283
|
ensure
|
|
@@ -254,6 +289,31 @@ module Pgbus
|
|
|
254
289
|
@jobs_processed.increment
|
|
255
290
|
end
|
|
256
291
|
|
|
292
|
+
# Order: read the last error, move with the dead-letter block, then
|
|
293
|
+
# clear the failed-event row. A move that raises keeps the row, so the
|
|
294
|
+
# next attempt still carries the reason.
|
|
295
|
+
def dead_letter(message, queue_name)
|
|
296
|
+
last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
|
|
297
|
+
headers = DeadLetterHeader.build(
|
|
298
|
+
existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "consumer",
|
|
299
|
+
source_queue: config.queue_name(queue_name), attempts: message.read_ct.to_i,
|
|
300
|
+
max_retries: config.max_retries, error: last_error
|
|
301
|
+
)
|
|
302
|
+
Pgbus.client.move_to_dead_letter(queue_name, message, headers: headers)
|
|
303
|
+
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The executor's handle_failure, for events (issue #494): one upsert per
|
|
307
|
+
# failure, under the LOGICAL queue name, so the dashboard reads the event
|
|
308
|
+
# as retrying with its error. No ErrorReporter call: Handler#process!
|
|
309
|
+
# already instruments pgbus.event_failed, which feeds the reporters.
|
|
310
|
+
def record_failed_event(message, queue_name, error)
|
|
311
|
+
FailedEventRecorder.record!(
|
|
312
|
+
queue_name: queue_name, msg_id: message.msg_id.to_i, payload: message.message,
|
|
313
|
+
headers: message.headers, error: error, retry_count: [message.read_ct.to_i - 1, 0].max
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
257
317
|
# Run every handler that owns this message, with the message's visibility
|
|
258
318
|
# timeout held open for as long as they take (issue #470).
|
|
259
319
|
#
|
|
@@ -555,13 +615,30 @@ module Pgbus
|
|
|
555
615
|
def start_heartbeat
|
|
556
616
|
@heartbeat = Heartbeat.new(
|
|
557
617
|
kind: "consumer",
|
|
558
|
-
metadata: {
|
|
618
|
+
metadata: {
|
|
619
|
+
topics: topics, threads: threads, pid: ::Process.pid, execution_mode: @execution_mode,
|
|
620
|
+
started_at: @started_at.to_f, supervisor_pid: ::Process.ppid
|
|
621
|
+
},
|
|
559
622
|
on_beat: -> { on_heartbeat },
|
|
560
|
-
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
623
|
+
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
624
|
+
metadata_supplier: -> { throughput_metadata }
|
|
561
625
|
)
|
|
562
626
|
@heartbeat.start
|
|
563
627
|
end
|
|
564
628
|
|
|
629
|
+
# What the Processes page states about a consumer (issue #493), read
|
|
630
|
+
# once per beat, never per message. String keys: the value round-trips
|
|
631
|
+
# through JSON in the metadata column.
|
|
632
|
+
def throughput_metadata
|
|
633
|
+
pool = @pool.metadata
|
|
634
|
+
{
|
|
635
|
+
"jobs_processed" => @jobs_processed.value, "buffered" => @claim_buffer.size,
|
|
636
|
+
"capacity" => pool[:capacity], "busy" => pool[:busy], "memory_mb" => current_memory_mb,
|
|
637
|
+
"max_jobs" => config.max_jobs_per_worker, "max_memory_mb" => config.max_memory_mb,
|
|
638
|
+
"max_lifetime" => config.max_worker_lifetime
|
|
639
|
+
}.compact
|
|
640
|
+
end
|
|
641
|
+
|
|
565
642
|
# Runs once per heartbeat interval (not per message), so it's the right
|
|
566
643
|
# place to emit connection-pool observability without touching any per-job
|
|
567
644
|
# hot path. Reading the pool must never crash the beat — pool_stats already
|
|
@@ -697,7 +697,7 @@ module Pgbus
|
|
|
697
697
|
def start_heartbeat
|
|
698
698
|
@heartbeat = Heartbeat.new(
|
|
699
699
|
kind: "dispatcher",
|
|
700
|
-
metadata: { pid: ::Process.pid },
|
|
700
|
+
metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid },
|
|
701
701
|
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
702
702
|
)
|
|
703
703
|
@heartbeat.start
|