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
|
@@ -171,7 +171,7 @@ module Pgbus
|
|
|
171
171
|
private_constant :ROLE_FLAGS
|
|
172
172
|
|
|
173
173
|
def log_boot_banner
|
|
174
|
-
Pgbus.logger.info { "[Pgbus] boot: pgbus #{Pgbus::VERSION} pid=#{::Process.pid}" }
|
|
174
|
+
Pgbus.logger.info { "[Pgbus] boot: pgbus #{Pgbus::VERSION} pid=#{::Process.pid} jit=#{RubyJit.label}" }
|
|
175
175
|
Pgbus.logger.info do
|
|
176
176
|
"[Pgbus] boot: connection=#{redacted_connection_target} pool=#{banner_field { config.resolved_pool_size }}"
|
|
177
177
|
end
|
|
@@ -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
|
|
@@ -330,6 +342,9 @@ module Pgbus
|
|
|
330
342
|
queues: queues, threads: threads, config: config,
|
|
331
343
|
single_active_consumer: single_active, consumer_priority: priority,
|
|
332
344
|
execution_mode: exec_mode, group_mode: grp_mode,
|
|
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),
|
|
333
348
|
liveness_pipe: liveness_writer, wake_pipe: wake_reader
|
|
334
349
|
)
|
|
335
350
|
worker.run
|
|
@@ -351,7 +366,7 @@ module Pgbus
|
|
|
351
366
|
close_pipe(wake_reader)
|
|
352
367
|
register_fork_with_hub(pid, wake_writer, queues)
|
|
353
368
|
@forks[pid] = {
|
|
354
|
-
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,
|
|
355
370
|
liveness_reader: liveness_reader, last_pipe_tick_at: monotonic_now, pipe_seen: false,
|
|
356
371
|
wake_writer: wake_writer
|
|
357
372
|
}
|
|
@@ -364,6 +379,13 @@ module Pgbus
|
|
|
364
379
|
ErrorReporter.report(e, { action: "fork_worker", queues: queues })
|
|
365
380
|
end
|
|
366
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
|
+
|
|
367
389
|
# Hand the hub a worker fork's routing entry: explicit queues as
|
|
368
390
|
# physical names, "*" as the unconditional wildcard flag (the hub wakes
|
|
369
391
|
# wildcard forks for every channel, so the fork's own resolved set never
|
|
@@ -498,6 +520,7 @@ module Pgbus
|
|
|
498
520
|
setup_child_process
|
|
499
521
|
load_rails_app
|
|
500
522
|
consumer = Consumer.new(topics: topics, threads: threads, config: config,
|
|
523
|
+
read_ahead: config.read_ahead_for(consumer_config),
|
|
501
524
|
liveness_pipe: liveness_writer, wake_pipe: wake_reader)
|
|
502
525
|
consumer.run
|
|
503
526
|
end
|
|
@@ -680,7 +703,7 @@ module Pgbus
|
|
|
680
703
|
def restart_child(info)
|
|
681
704
|
case info[:type]
|
|
682
705
|
when :worker
|
|
683
|
-
fork_worker(info[:config], slot: info[:slot])
|
|
706
|
+
fork_worker(info[:config], slot: info[:slot], process_index: info[:process_index] || 0)
|
|
684
707
|
when :dispatcher
|
|
685
708
|
fork_dispatcher
|
|
686
709
|
when :scheduler
|
|
@@ -880,7 +903,7 @@ module Pgbus
|
|
|
880
903
|
def start_heartbeat
|
|
881
904
|
@heartbeat = Heartbeat.new(
|
|
882
905
|
kind: "supervisor",
|
|
883
|
-
metadata: { pid: ::Process.pid, hostname: Socket.gethostname }
|
|
906
|
+
metadata: { pid: ::Process.pid, hostname: Socket.gethostname, started_at: Time.now.to_f }
|
|
884
907
|
)
|
|
885
908
|
@heartbeat.start
|
|
886
909
|
end
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Pgbus
|
|
|
7
7
|
class Worker
|
|
8
8
|
include SignalHandler
|
|
9
9
|
|
|
10
|
-
attr_reader :queues, :threads, :config, :execution_mode,
|
|
10
|
+
attr_reader :queues, :threads, :config, :execution_mode, :read_ahead,
|
|
11
11
|
:rate_counter, :wake_signal, :restore_streak, :lifecycle
|
|
12
12
|
# stat_buffer is writable so a test can swap in a buffer double after
|
|
13
13
|
# construction to assert graceful_shutdown / check_recycle flush it. The
|
|
@@ -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
|
|
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?("*")
|
|
@@ -65,7 +66,15 @@ module Pgbus
|
|
|
65
66
|
@last_wildcard_resolve = nil
|
|
66
67
|
@jobs_processed = Concurrent::AtomicFixnum.new(0)
|
|
67
68
|
@jobs_failed = Concurrent::AtomicFixnum.new(0)
|
|
69
|
+
# in_flight counts every claimed message: buffered and running.
|
|
68
70
|
@in_flight = Concurrent::AtomicFixnum.new(0)
|
|
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
|
|
77
|
+
@claim_buffer = ClaimBuffer.new(config: config)
|
|
69
78
|
@loop_tick_at = Concurrent::AtomicReference.new(nil)
|
|
70
79
|
@rate_counter = rate_counter || RateCounter.new(:processed, :failed, :dequeued)
|
|
71
80
|
@started_at = Time.current
|
|
@@ -119,6 +128,7 @@ module Pgbus
|
|
|
119
128
|
jobs_processed: @jobs_processed.value,
|
|
120
129
|
jobs_failed: @jobs_failed.value,
|
|
121
130
|
in_flight: @in_flight.value,
|
|
131
|
+
buffered: @claim_buffer.size,
|
|
122
132
|
state: @lifecycle.state,
|
|
123
133
|
execution_mode: @execution_mode,
|
|
124
134
|
consumer_priority: @consumer_priority,
|
|
@@ -184,6 +194,9 @@ module Pgbus
|
|
|
184
194
|
check_recycle
|
|
185
195
|
refresh_wildcard_queues
|
|
186
196
|
ensure_notify_listener
|
|
197
|
+
# Draining, paused or stopped: claims nobody will run here go back
|
|
198
|
+
# to the queue now rather than when their timeout runs out.
|
|
199
|
+
return_claims unless @lifecycle.can_process?
|
|
187
200
|
|
|
188
201
|
break if @lifecycle.stopped?
|
|
189
202
|
# quiesced? (all slots free), not idle? (any slot free) — exiting
|
|
@@ -240,32 +253,53 @@ module Pgbus
|
|
|
240
253
|
|
|
241
254
|
private
|
|
242
255
|
|
|
256
|
+
# One loop step (issue #486): feed buffered claims to free slots, read
|
|
257
|
+
# the deficit (free slots + read_ahead - buffered, capped by
|
|
258
|
+
# prefetch_limit), feed again. With read_ahead 0 the buffer never
|
|
259
|
+
# outlives the step and qty is the free slot count, as before. Waits
|
|
260
|
+
# only when the step moved nothing, so a full pool with a topped-up
|
|
261
|
+
# buffer sleeps until a slot frees (the pool's on_state_change wakes it).
|
|
243
262
|
def claim_and_execute
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
263
|
+
drained = drain_claim_buffer
|
|
264
|
+
claimed = claim_deficit
|
|
265
|
+
drained += drain_claim_buffer
|
|
266
|
+
@wake_signal.wait(timeout: wake_timeout) if drained.zero? && claimed.zero?
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The hold is released before the executor's own heartbeat tracking
|
|
270
|
+
# registers the same key, so the message is never untracked in between.
|
|
271
|
+
def drain_claim_buffer
|
|
272
|
+
@claim_buffer.drain_into(@pool) do |claim|
|
|
273
|
+
@pool.post do
|
|
274
|
+
VisibilityHeartbeat.release(claim.hold)
|
|
275
|
+
process_message(claim.message, claim.queue_name, source_queue: claim.source_queue)
|
|
276
|
+
end
|
|
254
277
|
end
|
|
278
|
+
end
|
|
255
279
|
|
|
256
|
-
|
|
280
|
+
def claim_deficit
|
|
281
|
+
prefetch_room = config.prefetch_limit && (config.prefetch_limit - @in_flight.value)
|
|
282
|
+
want = @claim_buffer.deficit(free_slots: @pool.available_capacity, read_ahead: @read_ahead,
|
|
283
|
+
prefetch_room: prefetch_room)
|
|
284
|
+
return 0 if want.zero?
|
|
257
285
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return
|
|
261
|
-
end
|
|
286
|
+
tagged_messages = fetch_messages(want)
|
|
287
|
+
return 0 if tagged_messages.empty?
|
|
262
288
|
|
|
263
289
|
@rate_counter.increment(:dequeued, tagged_messages.size)
|
|
290
|
+
client = Pgbus.client
|
|
264
291
|
tagged_messages.each do |queue_name, message, source_queue|
|
|
265
292
|
detect_zombie(queue_name, message)
|
|
266
293
|
@in_flight.increment
|
|
267
|
-
@
|
|
294
|
+
@claim_buffer.push(queue_name, message, source_queue, client: client)
|
|
268
295
|
end
|
|
296
|
+
tagged_messages.size
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def return_claims
|
|
300
|
+
return if @claim_buffer.empty?
|
|
301
|
+
|
|
302
|
+
@in_flight.decrement(@claim_buffer.return_all!(client: Pgbus.client))
|
|
269
303
|
end
|
|
270
304
|
|
|
271
305
|
# Returns an array of [queue_name, message] pairs so we always know
|
|
@@ -616,26 +650,33 @@ module Pgbus
|
|
|
616
650
|
end
|
|
617
651
|
|
|
618
652
|
def exceeded_max_jobs?
|
|
619
|
-
|
|
653
|
+
limit = recycle_limit(:max_jobs_per_worker)
|
|
654
|
+
return false unless limit && @jobs_processed.value >= limit
|
|
620
655
|
|
|
621
656
|
Pgbus.logger.info { "[Pgbus] Worker recycling: max_jobs reached (#{@jobs_processed.value})" }
|
|
622
657
|
true
|
|
623
658
|
end
|
|
624
659
|
|
|
625
660
|
def exceeded_max_memory?
|
|
626
|
-
|
|
661
|
+
limit = recycle_limit(:max_memory_mb)
|
|
662
|
+
return false unless limit && current_memory_mb > limit
|
|
627
663
|
|
|
628
|
-
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)" }
|
|
629
665
|
true
|
|
630
666
|
end
|
|
631
667
|
|
|
632
668
|
def exceeded_max_lifetime?
|
|
633
|
-
|
|
669
|
+
limit = recycle_limit(:max_worker_lifetime)
|
|
670
|
+
return false unless limit && (monotonic_now - @started_at_monotonic) > limit
|
|
634
671
|
|
|
635
672
|
Pgbus.logger.info { "[Pgbus] Worker recycling: lifetime exceeded" }
|
|
636
673
|
true
|
|
637
674
|
end
|
|
638
675
|
|
|
676
|
+
def recycle_limit(key)
|
|
677
|
+
config.recycle_limit_for(@recycle_limits, key)
|
|
678
|
+
end
|
|
679
|
+
|
|
639
680
|
# Instrumentation payload may report a value up to MEMORY_CHECK_TTL seconds old.
|
|
640
681
|
def current_memory_mb
|
|
641
682
|
MemoryUsage.current_mb
|
|
@@ -788,10 +829,13 @@ module Pgbus
|
|
|
788
829
|
def start_heartbeat
|
|
789
830
|
@heartbeat = Heartbeat.new(
|
|
790
831
|
kind: "worker",
|
|
832
|
+
# capsule/process lead: the dashboard's metadata cell truncates.
|
|
791
833
|
metadata: {
|
|
834
|
+
capsule: @capsule, process: @process,
|
|
792
835
|
queues: queues, threads: threads, pid: ::Process.pid,
|
|
793
|
-
execution_mode: @execution_mode, consumer_priority: @consumer_priority
|
|
794
|
-
|
|
836
|
+
execution_mode: @execution_mode, consumer_priority: @consumer_priority,
|
|
837
|
+
**lifecycle_metadata
|
|
838
|
+
}.compact,
|
|
795
839
|
on_beat: -> { on_heartbeat },
|
|
796
840
|
loop_tick_supplier: -> { @loop_tick_at.get },
|
|
797
841
|
metadata_supplier: -> { throughput_metadata }
|
|
@@ -831,10 +875,32 @@ module Pgbus
|
|
|
831
875
|
"rates" => @rate_counter.rates.transform_keys(&:to_s),
|
|
832
876
|
"jobs_processed" => @jobs_processed.value,
|
|
833
877
|
"jobs_failed" => @jobs_failed.value,
|
|
834
|
-
"in_flight" => @in_flight.value
|
|
878
|
+
"in_flight" => @in_flight.value,
|
|
879
|
+
"buffered" => @claim_buffer.size,
|
|
880
|
+
**pool_metadata
|
|
835
881
|
}
|
|
836
882
|
end
|
|
837
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
|
+
|
|
838
904
|
def shutdown
|
|
839
905
|
Pgbus.logger.info { "[Pgbus] Worker draining thread pool..." }
|
|
840
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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Reports which Ruby JIT this process runs under (issue #484). Read-only:
|
|
5
|
+
# Rails owns turning YJIT on (config.yjit), pgbus only makes the state
|
|
6
|
+
# visible in the boot log and `pgbus doctor`.
|
|
7
|
+
module RubyJit
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def label
|
|
11
|
+
return "yjit" if yjit_available? && RubyVM::YJIT.enabled?
|
|
12
|
+
return "zjit" if defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled?
|
|
13
|
+
|
|
14
|
+
"none"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def yjit_available?
|
|
18
|
+
defined?(RubyVM::YJIT) ? true : false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -30,8 +30,9 @@ module Pgbus
|
|
|
30
30
|
#
|
|
31
31
|
# The ninth member takes the struct out of the 80-byte slot it used to fit
|
|
32
32
|
# (measured: 80 → 160). That is paid at most once per in-flight message, so
|
|
33
|
-
# the whole table is bounded by the execution pool's capacity
|
|
34
|
-
#
|
|
33
|
+
# the whole table is bounded by the execution pool's capacity plus the
|
|
34
|
+
# read-ahead buffer (threads + read_ahead per process, issue #486) — a
|
|
35
|
+
# handful of entries per process, not one per enqueued job.
|
|
35
36
|
Entry = Struct.new(:client, :queue_name, :prefixed, :msg_id, :job_class, :extended_at, :extensions,
|
|
36
37
|
:concurrency, :on_beat, keyword_init: true)
|
|
37
38
|
|
|
@@ -81,6 +82,25 @@ module Pgbus
|
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
# Keep a claimed-but-not-started message invisible until #release.
|
|
86
|
+
# Used by Process::ClaimBuffer for read-ahead (issue #486): a buffered
|
|
87
|
+
# message waits for a free slot and must not be redelivered meanwhile.
|
|
88
|
+
# Same key as #track, so the run's own tracking takes over after the
|
|
89
|
+
# hold is released. Returns the entry, or nil when the heartbeat is off.
|
|
90
|
+
def hold(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration)
|
|
91
|
+
return unless config.visibility_heartbeat
|
|
92
|
+
|
|
93
|
+
entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
|
|
94
|
+
job_class: job_class, extended_at: monotonic_now, extensions: 0)
|
|
95
|
+
register(entry, config)
|
|
96
|
+
entry
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Drop a #hold. A nil entry (heartbeat off) is a no-op.
|
|
100
|
+
def release(entry)
|
|
101
|
+
unregister(entry) if entry
|
|
102
|
+
end
|
|
103
|
+
|
|
84
104
|
# Extend every tracked message whose last extension is older than the
|
|
85
105
|
# heartbeat interval. Public so tests and callers without the thread
|
|
86
106
|
# can drive it.
|
|
@@ -127,11 +147,22 @@ module Pgbus
|
|
|
127
147
|
end
|
|
128
148
|
end
|
|
129
149
|
|
|
150
|
+
# Only the entry that is registered under the key: a stale hold released
|
|
151
|
+
# after #track re-registered the same message must not drop the running
|
|
152
|
+
# job's entry (issue #486).
|
|
130
153
|
def unregister(entry)
|
|
131
|
-
synchronize
|
|
154
|
+
synchronize do
|
|
155
|
+
key = key_for(entry)
|
|
156
|
+
entries.delete(key) if entries[key].equal?(entry)
|
|
157
|
+
end
|
|
132
158
|
end
|
|
133
159
|
|
|
134
160
|
def extend!(entry, now:, config:)
|
|
161
|
+
# tick! picked this entry under the mutex but runs here without it: if
|
|
162
|
+
# the entry was released meanwhile (a read-ahead claim handed back with
|
|
163
|
+
# vt: 0, a job that finished), extending it would undo that.
|
|
164
|
+
return unless registered?(entry)
|
|
165
|
+
|
|
135
166
|
vt = config.visibility_timeout
|
|
136
167
|
entry.client.set_visibility_timeout(entry.queue_name, entry.msg_id, vt: vt, prefixed: entry.prefixed)
|
|
137
168
|
entry.extended_at = now
|
|
@@ -218,6 +249,10 @@ module Pgbus
|
|
|
218
249
|
@entries ||= {}
|
|
219
250
|
end
|
|
220
251
|
|
|
252
|
+
def registered?(entry)
|
|
253
|
+
synchronize { entries[key_for(entry)].equal?(entry) }
|
|
254
|
+
end
|
|
255
|
+
|
|
221
256
|
def key_for(entry)
|
|
222
257
|
[entry.queue_name, entry.msg_id]
|
|
223
258
|
end
|
|
@@ -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
|