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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +69 -14
  4. data/Rakefile +7 -1
  5. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  6. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  7. data/app/controllers/pgbus/application_controller.rb +5 -2
  8. data/app/controllers/pgbus/batches_controller.rb +4 -1
  9. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  10. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  11. data/app/controllers/pgbus/events_controller.rb +52 -3
  12. data/app/controllers/pgbus/insights_controller.rb +19 -1
  13. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  14. data/app/controllers/pgbus/locks_controller.rb +7 -2
  15. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  16. data/app/controllers/pgbus/processes_controller.rb +3 -1
  17. data/app/controllers/pgbus/queues_controller.rb +16 -4
  18. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  19. data/app/frontend/pgbus/modules/charts.js +119 -77
  20. data/app/frontend/pgbus/style.css +1 -1
  21. data/app/frontend/pgbus/tailwind.css +18 -0
  22. data/app/helpers/pgbus/application_helper.rb +155 -113
  23. data/app/helpers/pgbus/button_helper.rb +88 -0
  24. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  25. data/app/helpers/pgbus/events_helper.rb +40 -0
  26. data/app/helpers/pgbus/insights_helper.rb +39 -0
  27. data/app/helpers/pgbus/processes_helper.rb +79 -0
  28. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  29. data/app/views/pgbus/batches/show.html.erb +3 -4
  30. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  31. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  32. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  33. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  34. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  35. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  36. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  37. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  38. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  39. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  40. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  41. data/app/views/pgbus/events/_list.html.erb +55 -0
  42. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  43. data/app/views/pgbus/events/_replay.html.erb +10 -0
  44. data/app/views/pgbus/events/_row.html.erb +162 -0
  45. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  46. data/app/views/pgbus/events/index.html.erb +27 -48
  47. data/app/views/pgbus/events/show.html.erb +25 -7
  48. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  49. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  50. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  51. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  52. data/app/views/pgbus/insights/show.html.erb +83 -100
  53. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  54. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  55. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  56. data/app/views/pgbus/jobs/index.html.erb +10 -10
  57. data/app/views/pgbus/jobs/show.html.erb +18 -20
  58. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  59. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  60. data/app/views/pgbus/outbox/index.html.erb +30 -27
  61. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  62. data/app/views/pgbus/processes/_row.html.erb +48 -0
  63. data/app/views/pgbus/processes/_status.html.erb +4 -0
  64. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  65. data/app/views/pgbus/queues/show.html.erb +69 -118
  66. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  67. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  68. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  69. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  70. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  71. data/config/i18n-tasks.yml +11 -2
  72. data/config/locales/da.yml +405 -113
  73. data/config/locales/de.yml +405 -113
  74. data/config/locales/en.yml +405 -113
  75. data/config/locales/es.yml +404 -112
  76. data/config/locales/fi.yml +404 -112
  77. data/config/locales/fr.yml +406 -114
  78. data/config/locales/it.yml +405 -113
  79. data/config/locales/ja.yml +405 -113
  80. data/config/locales/nb.yml +405 -113
  81. data/config/locales/nl.yml +405 -113
  82. data/config/locales/pt.yml +406 -114
  83. data/config/locales/sv.yml +404 -112
  84. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  85. data/lib/pgbus/active_job/executor.rb +36 -17
  86. data/lib/pgbus/cli/dlq.rb +32 -5
  87. data/lib/pgbus/client.rb +7 -2
  88. data/lib/pgbus/configuration.rb +93 -2
  89. data/lib/pgbus/dead_letter_header.rb +129 -0
  90. data/lib/pgbus/doctor.rb +37 -5
  91. data/lib/pgbus/event_bus/handler.rb +18 -11
  92. data/lib/pgbus/event_bus/registry.rb +6 -0
  93. data/lib/pgbus/failed_event_recorder.rb +51 -0
  94. data/lib/pgbus/instrumentation.rb +2 -1
  95. data/lib/pgbus/mcp/base_tool.rb +25 -0
  96. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  97. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  98. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  99. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  100. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  101. data/lib/pgbus/outbox/poller.rb +1 -1
  102. data/lib/pgbus/process/claim_buffer.rb +115 -0
  103. data/lib/pgbus/process/consumer.rb +94 -17
  104. data/lib/pgbus/process/dispatcher.rb +1 -1
  105. data/lib/pgbus/process/supervisor.rb +35 -12
  106. data/lib/pgbus/process/worker.rb +91 -25
  107. data/lib/pgbus/recurring/scheduler.rb +2 -1
  108. data/lib/pgbus/ruby_jit.rb +21 -0
  109. data/lib/pgbus/version.rb +1 -1
  110. data/lib/pgbus/visibility_heartbeat.rb +38 -3
  111. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  112. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  113. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  114. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  115. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  116. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  117. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  118. data/lib/pgbus/web/data_source.rb +132 -254
  119. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  120. data/lib/pgbus/web/event_state.rb +71 -0
  121. data/lib/pgbus/web/health_app.rb +7 -3
  122. data/lib/pgbus/web/health_verdict.rb +78 -0
  123. data/lib/pgbus/web/insights_charts.rb +84 -0
  124. data/lib/pgbus/web/job_state.rb +111 -0
  125. data/lib/pgbus/web/process_activity.rb +97 -0
  126. data/lib/pgbus/web/process_state.rb +187 -0
  127. data/lib/pgbus/web/queue_summary.rb +104 -0
  128. data/lib/pgbus/web/time_format.rb +128 -0
  129. metadata +45 -4
  130. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  131. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  132. 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} mode=#{mode}"
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
- def fork_worker(worker_config, slot: nil)
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
@@ -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
- poll_interval = wake_timeout
245
-
246
- idle = @pool.available_capacity
247
- return @wake_signal.wait(timeout: poll_interval) if idle <= 0
248
-
249
- if config.prefetch_limit
250
- available = config.prefetch_limit - @in_flight.value
251
- return @wake_signal.wait(timeout: poll_interval) if available <= 0
252
-
253
- idle = [idle, available].min
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
- tagged_messages = fetch_messages(idle)
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
- if tagged_messages.empty?
259
- @wake_signal.wait(timeout: poll_interval)
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
- @pool.post { process_message(message, queue_name, source_queue: source_queue) }
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
- return false unless config.max_jobs_per_worker && @jobs_processed.value >= config.max_jobs_per_worker
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
- return false unless config.max_memory_mb && current_memory_mb > config.max_memory_mb
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 > #{config.max_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
- return false unless config.max_worker_lifetime && (monotonic_now - @started_at_monotonic) > config.max_worker_lifetime
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.17.0"
4
+ VERSION = "0.17.2"
5
5
  end
@@ -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 — a handful of
34
- # entries per process, not one per enqueued job.
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 { entries.delete(key_for(entry)) }
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