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
|
@@ -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
|
|
@@ -77,6 +77,8 @@ module Pgbus
|
|
|
77
77
|
@claim_buffer = ClaimBuffer.new(config: config)
|
|
78
78
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
79
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
|
|
80
82
|
@wake_signal = WakeSignal.new
|
|
81
83
|
@pool = ExecutionPools.build(
|
|
82
84
|
mode: @execution_mode,
|
|
@@ -246,7 +248,7 @@ module Pgbus
|
|
|
246
248
|
|
|
247
249
|
if message.read_ct.to_i > config.max_retries
|
|
248
250
|
Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
|
|
249
|
-
|
|
251
|
+
dead_letter(message, queue_name)
|
|
250
252
|
record_stat(message, queue_name, "dead_lettered", execution_start)
|
|
251
253
|
return
|
|
252
254
|
end
|
|
@@ -263,6 +265,9 @@ module Pgbus
|
|
|
263
265
|
end
|
|
264
266
|
|
|
265
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
|
|
266
271
|
@circuit_breaker.record_success(queue_name)
|
|
267
272
|
record_stat(message, queue_name, "success", execution_start)
|
|
268
273
|
rescue StandardError => e
|
|
@@ -270,6 +275,9 @@ module Pgbus
|
|
|
270
275
|
# Message stays in queue; VT will expire and it becomes available again.
|
|
271
276
|
# read_ct tracks delivery attempts — when it exceeds max_retries,
|
|
272
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
|
|
273
281
|
@circuit_breaker.record_failure(queue_name)
|
|
274
282
|
record_stat(message, queue_name, "failed", execution_start)
|
|
275
283
|
ensure
|
|
@@ -281,6 +289,31 @@ module Pgbus
|
|
|
281
289
|
@jobs_processed.increment
|
|
282
290
|
end
|
|
283
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
|
+
|
|
284
317
|
# Run every handler that owns this message, with the message's visibility
|
|
285
318
|
# timeout held open for as long as they take (issue #470).
|
|
286
319
|
#
|
|
@@ -582,13 +615,30 @@ module Pgbus
|
|
|
582
615
|
def start_heartbeat
|
|
583
616
|
@heartbeat = Heartbeat.new(
|
|
584
617
|
kind: "consumer",
|
|
585
|
-
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
|
+
},
|
|
586
622
|
on_beat: -> { on_heartbeat },
|
|
587
|
-
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
623
|
+
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
624
|
+
metadata_supplier: -> { throughput_metadata }
|
|
588
625
|
)
|
|
589
626
|
@heartbeat.start
|
|
590
627
|
end
|
|
591
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
|
+
|
|
592
642
|
# Runs once per heartbeat interval (not per message), so it's the right
|
|
593
643
|
# place to emit connection-pool observability without touching any per-job
|
|
594
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
|
|
@@ -195,9 +195,11 @@ module Pgbus
|
|
|
195
195
|
name = worker_config[:name] || worker_config["name"] || "anonymous"
|
|
196
196
|
queues = worker_config[:queues] || worker_config["queues"] || [config.default_queue]
|
|
197
197
|
threads = worker_config[:threads] || worker_config["threads"] || 5
|
|
198
|
+
processes = config.processes_for(worker_config)
|
|
198
199
|
mode = banner_field { config.execution_mode_for(worker_config) }
|
|
199
200
|
Pgbus.logger.info do
|
|
200
|
-
"[Pgbus] boot: capsule=#{name} queues=#{Array(queues).join(",")} threads=#{threads}
|
|
201
|
+
"[Pgbus] boot: capsule=#{name} queues=#{Array(queues).join(",")} threads=#{threads} " \
|
|
202
|
+
"processes=#{processes} mode=#{mode}"
|
|
201
203
|
end
|
|
202
204
|
end
|
|
203
205
|
end
|
|
@@ -284,12 +286,7 @@ module Pgbus
|
|
|
284
286
|
# --dispatcher-only CLI flags). Each role is gated by
|
|
285
287
|
# config.role_enabled?, which returns true unless +config.roles+ has
|
|
286
288
|
# been narrowed.
|
|
287
|
-
if config.role_enabled?(:workers)
|
|
288
|
-
# slot is the child's position in the config array — it keys the
|
|
289
|
-
# crash-streak tracking so identically-configured siblings don't
|
|
290
|
-
# share (and reset) each other's restart backoff.
|
|
291
|
-
Array(config.workers).each_with_index { |worker_config, slot| fork_worker(worker_config, slot: slot) }
|
|
292
|
-
end
|
|
289
|
+
boot_workers if config.role_enabled?(:workers)
|
|
293
290
|
|
|
294
291
|
fork_dispatcher if config.role_enabled?(:dispatcher)
|
|
295
292
|
boot_scheduler if config.role_enabled?(:scheduler)
|
|
@@ -297,7 +294,22 @@ module Pgbus
|
|
|
297
294
|
boot_outbox_poller if config.role_enabled?(:outbox)
|
|
298
295
|
end
|
|
299
296
|
|
|
300
|
-
|
|
297
|
+
# A capsule with processes: N forks N identical workers (issue #503).
|
|
298
|
+
# slot numbers every worker fork across all capsules — it keys the
|
|
299
|
+
# crash-streak tracking so identically-configured siblings (in one
|
|
300
|
+
# capsule or in several) don't share or reset each other's restart
|
|
301
|
+
# backoff. process_index is the fork's position within its capsule.
|
|
302
|
+
def boot_workers
|
|
303
|
+
slot = 0
|
|
304
|
+
Array(config.workers).each do |worker_config|
|
|
305
|
+
config.processes_for(worker_config).times do |process_index|
|
|
306
|
+
fork_worker(worker_config, slot: slot, process_index: process_index)
|
|
307
|
+
slot += 1
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def fork_worker(worker_config, slot: nil, process_index: 0)
|
|
301
313
|
note_intended_child
|
|
302
314
|
queues = worker_config[:queues] || [config.default_queue]
|
|
303
315
|
threads = worker_config[:threads] || 5
|
|
@@ -331,6 +343,8 @@ module Pgbus
|
|
|
331
343
|
single_active_consumer: single_active, consumer_priority: priority,
|
|
332
344
|
execution_mode: exec_mode, group_mode: grp_mode,
|
|
333
345
|
read_ahead: config.read_ahead_for(worker_config),
|
|
346
|
+
recycle_limits: worker_config.slice(*Configuration::RECYCLE_LIMIT_KEYS),
|
|
347
|
+
capsule: worker_config[:name], process: process_label(worker_config, process_index),
|
|
334
348
|
liveness_pipe: liveness_writer, wake_pipe: wake_reader
|
|
335
349
|
)
|
|
336
350
|
worker.run
|
|
@@ -352,7 +366,7 @@ module Pgbus
|
|
|
352
366
|
close_pipe(wake_reader)
|
|
353
367
|
register_fork_with_hub(pid, wake_writer, queues)
|
|
354
368
|
@forks[pid] = {
|
|
355
|
-
type: :worker, config: worker_config, slot: slot, spawned_at: monotonic_now,
|
|
369
|
+
type: :worker, config: worker_config, slot: slot, process_index: process_index, spawned_at: monotonic_now,
|
|
356
370
|
liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
|
|
357
371
|
wake_writer: wake_writer
|
|
358
372
|
}
|
|
@@ -365,6 +379,13 @@ module Pgbus
|
|
|
365
379
|
ErrorReporter.report(e, { action: "fork_worker", queues: queues })
|
|
366
380
|
end
|
|
367
381
|
|
|
382
|
+
# "2/4" for the second of a capsule's four processes; nil for a
|
|
383
|
+
# single-process capsule, where it would only add noise.
|
|
384
|
+
def process_label(worker_config, process_index)
|
|
385
|
+
total = config.processes_for(worker_config)
|
|
386
|
+
"#{process_index + 1}/#{total}" if total > 1
|
|
387
|
+
end
|
|
388
|
+
|
|
368
389
|
# Hand the hub a worker fork's routing entry: explicit queues as
|
|
369
390
|
# physical names, "*" as the unconditional wildcard flag (the hub wakes
|
|
370
391
|
# wildcard forks for every channel, so the fork's own resolved set never
|
|
@@ -682,7 +703,7 @@ module Pgbus
|
|
|
682
703
|
def restart_child(info)
|
|
683
704
|
case info[:type]
|
|
684
705
|
when :worker
|
|
685
|
-
fork_worker(info[:config], slot: info[:slot])
|
|
706
|
+
fork_worker(info[:config], slot: info[:slot], process_index: info[:process_index] || 0)
|
|
686
707
|
when :dispatcher
|
|
687
708
|
fork_dispatcher
|
|
688
709
|
when :scheduler
|
|
@@ -882,7 +903,7 @@ module Pgbus
|
|
|
882
903
|
def start_heartbeat
|
|
883
904
|
@heartbeat = Heartbeat.new(
|
|
884
905
|
kind: "supervisor",
|
|
885
|
-
metadata: { pid: ::Process.pid, hostname: Socket.gethostname }
|
|
906
|
+
metadata: { pid: ::Process.pid, hostname: Socket.gethostname, started_at: Time.now.to_f }
|
|
886
907
|
)
|
|
887
908
|
@heartbeat.start
|
|
888
909
|
end
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -35,7 +35,8 @@ module Pgbus
|
|
|
35
35
|
rate_counter: nil, wake_signal: nil, stat_buffer: :default,
|
|
36
36
|
notify_listener: nil, notify_retry_at: 0.0,
|
|
37
37
|
notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
|
|
38
|
-
started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil
|
|
38
|
+
started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil,
|
|
39
|
+
recycle_limits: {}, capsule: nil, process: nil)
|
|
39
40
|
@queues = Array(queues)
|
|
40
41
|
@initial_queues = @queues.dup.freeze
|
|
41
42
|
@wildcard = @queues.include?("*")
|
|
@@ -68,6 +69,11 @@ module Pgbus
|
|
|
68
69
|
# in_flight counts every claimed message: buffered and running.
|
|
69
70
|
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
70
71
|
@read_ahead = read_ahead || config.read_ahead
|
|
72
|
+
# Capsule-level recycle limits (issue #503); each unset key falls back
|
|
73
|
+
# to the global value at check time, so a config change still applies.
|
|
74
|
+
@recycle_limits = recycle_limits
|
|
75
|
+
@capsule = capsule
|
|
76
|
+
@process = process
|
|
71
77
|
@claim_buffer = ClaimBuffer.new(config: config)
|
|
72
78
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
73
79
|
@rate_counter = rate_counter || RateCounter.new(:processed, :failed, :dequeued)
|
|
@@ -644,26 +650,33 @@ module Pgbus
|
|
|
644
650
|
end
|
|
645
651
|
|
|
646
652
|
def exceeded_max_jobs?
|
|
647
|
-
|
|
653
|
+
limit = recycle_limit(:max_jobs_per_worker)
|
|
654
|
+
return false unless limit && @jobs_processed.value >= limit
|
|
648
655
|
|
|
649
656
|
Pgbus.logger.info { "[Pgbus] Worker recycling: max_jobs reached (#{@jobs_processed.value})" }
|
|
650
657
|
true
|
|
651
658
|
end
|
|
652
659
|
|
|
653
660
|
def exceeded_max_memory?
|
|
654
|
-
|
|
661
|
+
limit = recycle_limit(:max_memory_mb)
|
|
662
|
+
return false unless limit && current_memory_mb > limit
|
|
655
663
|
|
|
656
|
-
Pgbus.logger.info { "[Pgbus] Worker recycling: memory limit (#{current_memory_mb}MB > #{
|
|
664
|
+
Pgbus.logger.info { "[Pgbus] Worker recycling: memory limit (#{current_memory_mb}MB > #{limit}MB)" }
|
|
657
665
|
true
|
|
658
666
|
end
|
|
659
667
|
|
|
660
668
|
def exceeded_max_lifetime?
|
|
661
|
-
|
|
669
|
+
limit = recycle_limit(:max_worker_lifetime)
|
|
670
|
+
return false unless limit && (monotonic_now - @started_at_monotonic) > limit
|
|
662
671
|
|
|
663
672
|
Pgbus.logger.info { "[Pgbus] Worker recycling: lifetime exceeded" }
|
|
664
673
|
true
|
|
665
674
|
end
|
|
666
675
|
|
|
676
|
+
def recycle_limit(key)
|
|
677
|
+
config.recycle_limit_for(@recycle_limits, key)
|
|
678
|
+
end
|
|
679
|
+
|
|
667
680
|
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
668
681
|
def current_memory_mb
|
|
669
682
|
MemoryUsage.current_mb
|
|
@@ -816,10 +829,13 @@ module Pgbus
|
|
|
816
829
|
def start_heartbeat
|
|
817
830
|
@heartbeat = Heartbeat.new(
|
|
818
831
|
kind: "worker",
|
|
832
|
+
# capsule/process lead: the dashboard's metadata cell truncates.
|
|
819
833
|
metadata: {
|
|
834
|
+
capsule: @capsule, process: @process,
|
|
820
835
|
queues: queues, threads: threads, pid: ::Process.pid,
|
|
821
|
-
execution_mode: @execution_mode, consumer_priority: @consumer_priority
|
|
822
|
-
|
|
836
|
+
execution_mode: @execution_mode, consumer_priority: @consumer_priority,
|
|
837
|
+
**lifecycle_metadata
|
|
838
|
+
}.compact,
|
|
823
839
|
on_beat: -> { on_heartbeat },
|
|
824
840
|
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
825
841
|
metadata_supplier: -> { throughput_metadata }
|
|
@@ -860,10 +876,31 @@ module Pgbus
|
|
|
860
876
|
"jobs_processed" => @jobs_processed.value,
|
|
861
877
|
"jobs_failed" => @jobs_failed.value,
|
|
862
878
|
"in_flight" => @in_flight.value,
|
|
863
|
-
"buffered" => @claim_buffer.size
|
|
879
|
+
"buffered" => @claim_buffer.size,
|
|
880
|
+
**pool_metadata
|
|
864
881
|
}
|
|
865
882
|
end
|
|
866
883
|
|
|
884
|
+
# Facts the Processes page states (issue #493), fixed for the process's
|
|
885
|
+
# life: when it started and which supervisor forked it (ppid; the page
|
|
886
|
+
# only groups under it when that pid is a supervisor row on this host).
|
|
887
|
+
def lifecycle_metadata
|
|
888
|
+
{ started_at: @started_at.to_f, supervisor_pid: ::Process.ppid }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# Read once per beat: one atomic (threads) or one mutex (async) for the
|
|
892
|
+
# pool, MemoryUsage's 5s cache for RSS (a ps subprocess on darwin), and
|
|
893
|
+
# the recycle limits resolved as the recycle check resolves them (a
|
|
894
|
+
# capsule's own when set), so the page follows a config change.
|
|
895
|
+
def pool_metadata
|
|
896
|
+
pool = @pool.metadata
|
|
897
|
+
{
|
|
898
|
+
"capacity" => pool[:capacity], "busy" => pool[:busy], "memory_mb" => current_memory_mb,
|
|
899
|
+
"max_jobs" => recycle_limit(:max_jobs_per_worker), "max_memory_mb" => recycle_limit(:max_memory_mb),
|
|
900
|
+
"max_lifetime" => recycle_limit(:max_worker_lifetime)
|
|
901
|
+
}.compact
|
|
902
|
+
end
|
|
903
|
+
|
|
867
904
|
def shutdown
|
|
868
905
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
869
906
|
stop_wake_source
|
|
@@ -116,7 +116,8 @@ module Pgbus
|
|
|
116
116
|
def start_heartbeat
|
|
117
117
|
@heartbeat = Process::Heartbeat.new(
|
|
118
118
|
kind: "scheduler",
|
|
119
|
-
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size
|
|
119
|
+
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size, started_at: Time.now.to_f,
|
|
120
|
+
supervisor_pid: ::Process.ppid },
|
|
120
121
|
loop_tick_supplier: -> { @loop_tick_at.get }
|
|
121
122
|
)
|
|
122
123
|
@heartbeat.start
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -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
|