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