pgbus 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +48 -13
  4. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  5. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  6. data/app/controllers/pgbus/application_controller.rb +5 -2
  7. data/app/controllers/pgbus/batches_controller.rb +4 -1
  8. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  9. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  10. data/app/controllers/pgbus/events_controller.rb +52 -3
  11. data/app/controllers/pgbus/insights_controller.rb +19 -1
  12. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  13. data/app/controllers/pgbus/locks_controller.rb +7 -2
  14. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  15. data/app/controllers/pgbus/processes_controller.rb +3 -1
  16. data/app/controllers/pgbus/queues_controller.rb +16 -4
  17. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  18. data/app/frontend/pgbus/modules/charts.js +119 -77
  19. data/app/frontend/pgbus/style.css +1 -1
  20. data/app/frontend/pgbus/tailwind.css +18 -0
  21. data/app/helpers/pgbus/application_helper.rb +155 -113
  22. data/app/helpers/pgbus/button_helper.rb +88 -0
  23. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  24. data/app/helpers/pgbus/events_helper.rb +40 -0
  25. data/app/helpers/pgbus/insights_helper.rb +39 -0
  26. data/app/helpers/pgbus/processes_helper.rb +79 -0
  27. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  28. data/app/views/pgbus/batches/show.html.erb +3 -4
  29. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  30. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  31. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  32. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  33. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  34. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  35. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  36. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  37. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  38. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  39. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  40. data/app/views/pgbus/events/_list.html.erb +55 -0
  41. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  42. data/app/views/pgbus/events/_replay.html.erb +10 -0
  43. data/app/views/pgbus/events/_row.html.erb +162 -0
  44. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  45. data/app/views/pgbus/events/index.html.erb +27 -48
  46. data/app/views/pgbus/events/show.html.erb +25 -7
  47. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  48. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  49. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  50. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  51. data/app/views/pgbus/insights/show.html.erb +83 -100
  52. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  53. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  54. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  55. data/app/views/pgbus/jobs/index.html.erb +10 -10
  56. data/app/views/pgbus/jobs/show.html.erb +18 -20
  57. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  58. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  59. data/app/views/pgbus/outbox/index.html.erb +30 -27
  60. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  61. data/app/views/pgbus/processes/_row.html.erb +48 -0
  62. data/app/views/pgbus/processes/_status.html.erb +4 -0
  63. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  64. data/app/views/pgbus/queues/show.html.erb +69 -118
  65. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  66. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  67. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  68. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  69. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  70. data/config/i18n-tasks.yml +11 -2
  71. data/config/locales/da.yml +405 -113
  72. data/config/locales/de.yml +405 -113
  73. data/config/locales/en.yml +405 -113
  74. data/config/locales/es.yml +404 -112
  75. data/config/locales/fi.yml +404 -112
  76. data/config/locales/fr.yml +406 -114
  77. data/config/locales/it.yml +405 -113
  78. data/config/locales/ja.yml +405 -113
  79. data/config/locales/nb.yml +405 -113
  80. data/config/locales/nl.yml +405 -113
  81. data/config/locales/pt.yml +406 -114
  82. data/config/locales/sv.yml +404 -112
  83. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  84. data/lib/pgbus/active_job/executor.rb +16 -6
  85. data/lib/pgbus/cli/dlq.rb +32 -5
  86. data/lib/pgbus/client.rb +4 -2
  87. data/lib/pgbus/configuration.rb +47 -2
  88. data/lib/pgbus/dead_letter_header.rb +129 -0
  89. data/lib/pgbus/doctor.rb +8 -4
  90. data/lib/pgbus/event_bus/handler.rb +18 -11
  91. data/lib/pgbus/event_bus/registry.rb +6 -0
  92. data/lib/pgbus/failed_event_recorder.rb +34 -0
  93. data/lib/pgbus/instrumentation.rb +2 -1
  94. data/lib/pgbus/mcp/base_tool.rb +25 -0
  95. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  96. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  97. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  98. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  99. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  100. data/lib/pgbus/outbox/poller.rb +1 -1
  101. data/lib/pgbus/process/consumer.rb +53 -3
  102. data/lib/pgbus/process/dispatcher.rb +1 -1
  103. data/lib/pgbus/process/supervisor.rb +32 -11
  104. data/lib/pgbus/process/worker.rb +45 -8
  105. data/lib/pgbus/recurring/scheduler.rb +2 -1
  106. data/lib/pgbus/version.rb +1 -1
  107. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  108. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  109. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  110. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  111. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  112. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  113. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  114. data/lib/pgbus/web/data_source.rb +132 -254
  115. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  116. data/lib/pgbus/web/event_state.rb +71 -0
  117. data/lib/pgbus/web/health_app.rb +7 -3
  118. data/lib/pgbus/web/health_verdict.rb +78 -0
  119. data/lib/pgbus/web/insights_charts.rb +84 -0
  120. data/lib/pgbus/web/job_state.rb +111 -0
  121. data/lib/pgbus/web/process_activity.rb +97 -0
  122. data/lib/pgbus/web/process_state.rb +187 -0
  123. data/lib/pgbus/web/queue_summary.rb +104 -0
  124. data/lib/pgbus/web/time_format.rb +128 -0
  125. metadata +43 -4
  126. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  127. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  128. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The dead-letter queue pages (issue #495): list, count, detail, and the
7
+ # retry/discard actions. Moved out of data_source.rb unchanged.
8
+ module DeadLetter
9
+ # The header path the error-class filter reads (DeadLetterHeader::KEY).
10
+ ERROR_CLASS_SQL = "headers #>> '{#{Pgbus::DeadLetterHeader::KEY},error_class}'".freeze
11
+
12
+ # DLQ queue names from queues_with_metrics are already fully qualified
13
+ # (e.g., "pgbus_default_dlq"), so we use them directly without re-prefixing.
14
+ # dlq: one DLQ by its full name (unknown names list nothing);
15
+ # error_class: only messages whose dead-letter block names that class.
16
+ def dlq_messages(page: 1, per_page: 25, dlq: nil, error_class: nil)
17
+ offset = (page - 1) * per_page
18
+ names = dlq_queue_names(dlq)
19
+ return [] if names.empty?
20
+ return paginated_queue_messages(names, per_page, offset) unless error_class
21
+
22
+ paginated_queue_messages(names, per_page, offset, where: "#{ERROR_CLASS_SQL} = $3", binds: [error_class])
23
+ rescue StandardError => e
24
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ messages: #{e.message}" }
25
+ []
26
+ end
27
+
28
+ def dlq_total_count(dlq: nil, error_class: nil)
29
+ names = dlq_queue_names(dlq)
30
+ return 0 if names.empty?
31
+ return dlq_counts_by_queue.values_at(*names).sum unless error_class
32
+
33
+ connection.select_value(
34
+ "SELECT COUNT(*) FROM (#{queue_message_fragments(names, "#{ERROR_CLASS_SQL} = $1")}) AS combined",
35
+ "Pgbus DLQ Count",
36
+ [error_class]
37
+ ).to_i
38
+ rescue StandardError => e
39
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ count: #{e.message}" }
40
+ 0
41
+ end
42
+
43
+ # { "pgbus_default_dlq" => 12, ... } for the filter chips, from the
44
+ # cached metrics (no query of its own).
45
+ def dlq_counts_by_queue
46
+ queues_with_metrics
47
+ .select { |q| q[:name].end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
48
+ .to_h { |q| [q[:name], q[:queue_length].to_i] }
49
+ rescue StandardError => e
50
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ counts: #{e.message}" }
51
+ {}
52
+ end
53
+
54
+ # msg_ids are unique per DLQ table only: queue_name: picks the table;
55
+ # without it the first DLQ holding the id wins.
56
+ def dlq_message_detail(msg_id, queue_name: nil)
57
+ dlq_queue_names(queue_name).each do |name|
58
+ row = connection.select_one(
59
+ "SELECT * FROM pgmq.q_#{sanitize_name(name)} WHERE msg_id = $1",
60
+ "Pgbus DLQ Detail",
61
+ [msg_id.to_i]
62
+ )
63
+ return format_message(row, name) if row
64
+ end
65
+ nil
66
+ rescue StandardError => e
67
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ message #{msg_id}: #{e.message}" }
68
+ nil
69
+ end
70
+
71
+ def retry_dlq_message(queue_name, msg_id)
72
+ # queue_name here is the full DLQ name (already prefixed)
73
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
74
+ original_queue = queue_name.delete_suffix(dlq_suffix)
75
+
76
+ row = connection.select_one(
77
+ "SELECT * FROM pgmq.q_#{sanitize_name(queue_name)} WHERE msg_id = $1",
78
+ "Pgbus DLQ Read",
79
+ [msg_id.to_i]
80
+ )
81
+ return false unless row
82
+
83
+ @client.transaction do |txn|
84
+ # A live message must not claim to be dead: strip the block, count the trip.
85
+ txn.produce(original_queue, row["message"], headers: DeadLetterHeader.strip_for_retry(row["headers"]))
86
+ txn.delete(queue_name, msg_id.to_i)
87
+ end
88
+ true
89
+ rescue StandardError => e
90
+ Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{msg_id}: #{e.message}" }
91
+ false
92
+ end
93
+
94
+ def discard_dlq_message(queue_name, msg_id)
95
+ # queue_name here is the full DLQ name (already prefixed)
96
+ release_lock_for_message(queue_name, msg_id)
97
+ @client.delete_message(queue_name, msg_id.to_i, prefixed: false)
98
+ true
99
+ rescue StandardError => e
100
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{msg_id}: #{e.message}" }
101
+ false
102
+ end
103
+
104
+ def retry_all_dlq
105
+ messages = dlq_messages(page: 1, per_page: 1000)
106
+ count = 0
107
+ messages.each do |m|
108
+ retry_dlq_message(m[:queue_name], m[:msg_id]) && count += 1
109
+ rescue StandardError => e
110
+ Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{m[:msg_id]}: #{e.message}" }
111
+ next
112
+ end
113
+ count
114
+ end
115
+
116
+ def discard_all_dlq
117
+ messages = dlq_messages(page: 1, per_page: 1000)
118
+ return 0 if messages.empty?
119
+
120
+ release_locks_for_messages(messages)
121
+
122
+ # Group by queue for batch delete — one call per DLQ instead of N calls
123
+ messages.group_by { |m| m[:queue_name] }.sum do |queue_name, msgs|
124
+ ids = msgs.map { |m| m[:msg_id].to_i }
125
+ @client.delete_batch(queue_name, ids, prefixed: false).size
126
+ rescue StandardError => e
127
+ Pgbus.logger.debug { "[Pgbus::Web] Error batch-discarding DLQ messages from #{queue_name}: #{e.message}" }
128
+ 0
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Only names that are real DLQs ever reach sanitize_name and SQL.
135
+ def dlq_queue_names(dlq)
136
+ names = dlq_counts_by_queue.keys
137
+ dlq.nil? ? names : names & [dlq]
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Pgbus
6
+ module Web
7
+ class DataSource
8
+ # The Events page (issue #494): the unified job list scoped to the
9
+ # EventBus handler queues, the words EventState needs about consumers,
10
+ # and Replay of a processed event from its archived message.
11
+ #
12
+ # Handler names and patterns come from the subscriber registry, in Ruby:
13
+ # rows match on the physical queue (queue rows) or the logical one
14
+ # (orphaned failed rows, which carry the name the consumer recorded).
15
+ module EventList
16
+ # Lower bound on archived_at below the oldest claim on a page: the
17
+ # claim always precedes the archive, the margin absorbs clock skew.
18
+ ARCHIVE_PROBE_MARGIN = 60
19
+
20
+ # An archived envelope's routing key, where Publisher and the outbox
21
+ # put it. Without one the consumer would archive a replay unrouted.
22
+ ROUTING_KEY_SQL = "COALESCE(a.message->'headers'->>'routing_key', a.message->>'routing_key')"
23
+
24
+ def event_rows(state: nil, queue_name: nil, page: 1, per_page: 25)
25
+ queues = handler_queue_physical_names
26
+ return [] if queues.empty?
27
+
28
+ rows = job_rows(state: state, queue_name: queue_name, queues: queues, page: page, per_page: per_page)
29
+ by_queue = subscribers_by_queue_name
30
+ rows.map do |row|
31
+ sub = by_queue[row[:queue_name]]
32
+ row.merge(handler_class: sub&.dig(:handler_class), pattern: sub&.dig(:pattern))
33
+ end
34
+ end
35
+
36
+ def event_state_counts(queue_name: nil)
37
+ job_state_counts(queue_name: queue_name, queues: handler_queue_physical_names)
38
+ end
39
+
40
+ def events_ahead(rows) = jobs_ahead(rows)
41
+
42
+ def event_list_context(now: Time.now)
43
+ EventState::Context.new(jobs: job_list_context(now: now), covered_queues: covered_handler_queues,
44
+ claim_window: EventBus::Handler.claim_ownership_window)
45
+ end
46
+
47
+ # { processed_event id => :replayable | :not_archived | :no_handler }
48
+ # for one page of processed events: one bounded archive probe per
49
+ # handler queue, never one per row.
50
+ def event_replay_states(events)
51
+ subs = replay_subscribers
52
+ states = events.to_h { |e| [e["id"], subs[e["handler_class"]] ? :not_archived : :no_handler] }
53
+
54
+ events.select { |e| subs[e["handler_class"]] }.group_by { |e| subs[e["handler_class"]] }.each do |sub, rows|
55
+ keys = archived_routing_keys(sub[:physical_queue_name], rows)
56
+ rows.each { |e| states[e["id"]] = :replayable if replayable_key?(sub, keys[e["event_id"]]) }
57
+ end
58
+ states
59
+ end
60
+
61
+ # Re-deliver a processed event's archived envelope to its handler's
62
+ # queue, under a new event_id (so the idempotency claim is new) that
63
+ # links back through replayed_from. Only that handler runs again: a
64
+ # topic publish would re-run every subscriber of the routing key.
65
+ def replay_event(event)
66
+ sub = replay_subscribers[event["handler_class"]]
67
+ return false unless sub
68
+
69
+ archived = archived_event(sub[:physical_queue_name], event)
70
+ return false unless archived
71
+
72
+ message = JSON.parse(archived["message"])
73
+ return false unless replayable_key?(sub, event_routing_key(message))
74
+
75
+ replay = message.merge("event_id" => SecureRandom.uuid, "replayed_from" => event["event_id"])
76
+ @client.transaction do |txn|
77
+ txn.produce(sub[:physical_queue_name], JSON.generate(replay), headers: archived["headers"])
78
+ end
79
+ true
80
+ rescue StandardError => e
81
+ Pgbus.logger.error { "[Pgbus::Web] Error replaying event #{event["event_id"]}: #{e.class}: #{e.message}" }
82
+ false
83
+ end
84
+
85
+ private
86
+
87
+ def subscribers_by_queue_name
88
+ registered_subscribers.each_with_object({}) do |s, out|
89
+ out[s[:physical_queue_name]] ||= s
90
+ out[s[:queue_name]] ||= s
91
+ end
92
+ end
93
+
94
+ # Physical handler queues some healthy consumer reads: its topics
95
+ # overlap the subscriber's pattern (Registry#queue_names_for_topics'
96
+ # rule). nil when a healthy consumer does not report its topics.
97
+ def covered_handler_queues
98
+ consumers = processes.select { |p| p[:kind].to_s == "consumer" && p[:healthy] }
99
+ topics = consumers.map { |p| p[:metadata].is_a?(Hash) ? p[:metadata]["topics"] : nil }
100
+ return nil if topics.any?(&:nil?)
101
+
102
+ registry = EventBus::Registry.instance
103
+ topics = topics.flatten
104
+ registered_subscribers.select { |s| topics.any? { |t| registry.pattern_overlaps?(t, s[:pattern]) } }
105
+ .to_set { |s| s[:physical_queue_name] }
106
+ end
107
+
108
+ # { event_id => routing key } for the page's events still in the archive.
109
+ def archived_routing_keys(queue, events)
110
+ ids = events.map { |e| text_array_element(e["event_id"]) }
111
+ rows = connection.select_all(<<~SQL, "Pgbus Archived Events", ["{#{ids.join(",")}}", archive_floor(events)])
112
+ SELECT a.message->>'event_id' AS event_id, #{ROUTING_KEY_SQL} AS routing_key
113
+ FROM pgmq.a_#{sanitize_name(queue)} a
114
+ WHERE a.archived_at >= $2::timestamptz AND a.message->>'event_id' = ANY($1::text[])
115
+ AND #{ROUTING_KEY_SQL} IS NOT NULL
116
+ SQL
117
+ rows.to_a.to_h { |r| [r["event_id"], r["routing_key"]] }
118
+ rescue StandardError => e
119
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the archive of #{queue}: #{e.class}: #{e.message}" }
120
+ {}
121
+ end
122
+
123
+ # The registration Replay uses per handler class: the first, so the
124
+ # availability check and the action never disagree.
125
+ def replay_subscribers
126
+ registered_subscribers.each_with_object({}) { |s, out| out[s[:handler_class]] ||= s }
127
+ end
128
+
129
+ # A replay the consumer would archive unrouted is no replay: the key
130
+ # must still match the subscriber's pattern.
131
+ def replayable_key?(sub, routing_key)
132
+ !routing_key.nil? && EventBus::Registry.instance.pattern_matches?(sub[:pattern], routing_key)
133
+ end
134
+
135
+ # event_id is any string an envelope carried (Edit & Retry accepts any
136
+ # payload), so each element of the bound text[] literal is quoted.
137
+ def text_array_element(value)
138
+ %("#{value.to_s.gsub(/["\\]/) { |c| "\\#{c}" }}")
139
+ end
140
+
141
+ def archived_event(queue, event)
142
+ connection.select_one(<<~SQL, "Pgbus Archived Event", [event["event_id"].to_s, archive_floor([event])])
143
+ SELECT a.message::text AS message, a.headers::text AS headers FROM pgmq.a_#{sanitize_name(queue)} a
144
+ WHERE a.message->>'event_id' = $1 AND a.archived_at >= $2::timestamptz
145
+ ORDER BY a.archived_at DESC LIMIT 1
146
+ SQL
147
+ end
148
+
149
+ def archive_floor(events)
150
+ oldest = events.filter_map { |e| JobState.time(e["processed_at"]) }.min || Time.at(0)
151
+ (oldest - ARCHIVE_PROBE_MARGIN).utc.iso8601(6)
152
+ end
153
+
154
+ def event_routing_key(message)
155
+ headers = message["headers"]
156
+ (headers["routing_key"] if headers.is_a?(Hash)).presence || message["routing_key"].presence
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The Jobs page's unified list (issue #489): every job the dashboard can
7
+ # see, one row each, whatever table it lives in, with its state derived
8
+ # in SQL so tabs, counts and paging all cut on the same value.
9
+ #
10
+ # Sources, one UNION ALL fragment each:
11
+ # - every non-DLQ queue table, LEFT JOINed to its pgbus_failed_events row
12
+ # (failed_events.queue_name is the LOGICAL name; the physical one is
13
+ # matched too for rows older code wrote)
14
+ # - failed rows whose message is no longer in its queue ("orphans")
15
+ # - pgbus_blocked_executions (jobs parked by limits_concurrency)
16
+ #
17
+ # Queue names reach SQL only through sanitize_name; the state filter and
18
+ # paging are bound parameters. Each fragment carries its own ORDER BY +
19
+ # LIMIT so it stays index-backed and bounded; msg_ids from different
20
+ # queues are not comparable, so the outer sort is by time.
21
+ #
22
+ # queues: cuts the list to those physical queues (the Events page passes
23
+ # the EventBus handler queues); exclude: leaves physical queues out (the
24
+ # Jobs page leaves the handler queues to the Events page). Both are
25
+ # optional, and without them the SQL is unchanged (issue #494).
26
+ module JobList
27
+ # A failure recorded after the read that ran the attempt means the job
28
+ # is backing off; a later read (last_read_at past failed_at) means the
29
+ # retry attempt is running now.
30
+ STATE_CASE = <<~SQL.gsub(/\s+/, " ").strip.freeze
31
+ CASE
32
+ WHEN m.read_ct = 0 AND m.vt > now() THEN 'scheduled'
33
+ WHEN m.read_ct = 0 THEN 'ready'
34
+ WHEN f.failed_at IS NOT NULL AND f.failed_at >= m.last_read_at THEN 'retrying'
35
+ WHEN m.vt > now() THEN 'running'
36
+ ELSE 'ready'
37
+ END
38
+ SQL
39
+
40
+ # Per-fragment ceiling for the tab counts, so a million-row queue never
41
+ # forces a full scan on every auto-refresh. A fragment that reaches it
42
+ # makes its states' counts lower bounds.
43
+ COUNT_CAP = 10_000
44
+
45
+ QUEUE_STATES = %w[ready scheduled running retrying].freeze
46
+
47
+ # A predicate on m that each state branch of STATE_CASE already implies.
48
+ # Without it a filtered tab walks the whole pkey and probes the LATERAL for
49
+ # every row before LIMIT bites (300k-row queue, no matches: ~350 ms); with it
50
+ # only candidates reach the CASE. The exact `q.state = $1` still applies.
51
+ STATE_PREFILTER = {
52
+ "scheduled" => "m.read_ct = 0 AND m.vt > now()",
53
+ "ready" => "m.vt <= now()",
54
+ "running" => "m.read_ct > 0 AND m.vt > now()",
55
+ "retrying" => "m.read_ct > 0"
56
+ }.freeze
57
+
58
+ INTEGER_COLUMNS = %i[id msg_id read_ct failed_event_id slots_held slots_max].freeze
59
+
60
+ StateCounts = Data.define(:counts, :capped) do
61
+ def [](state) = counts.fetch(state.to_s, 0)
62
+ def capped?(state) = capped.include?(state.to_s)
63
+ end
64
+
65
+ def job_rows(state: nil, queue_name: nil, queues: nil, exclude: nil, page: 1, per_page: 25)
66
+ scope = job_list_scope(queue_name, queues: queues, exclude: exclude)
67
+ return [] unless scope
68
+
69
+ binds = []
70
+ state_param = nil
71
+ if state && state != "blocked" && scope[:queues].any?
72
+ binds << state
73
+ state_param = "$1"
74
+ end
75
+
76
+ fetch = (page * per_page).to_i
77
+ fragments = job_row_fragments(scope, state, state_param, fetch)
78
+ return [] if fragments.empty?
79
+
80
+ sql = <<~SQL
81
+ SELECT * FROM (#{fragments.join("\nUNION ALL\n")}) AS jobs
82
+ ORDER BY sort_at DESC NULLS LAST, id DESC
83
+ LIMIT $#{binds.size + 1} OFFSET $#{binds.size + 2}
84
+ SQL
85
+ binds.push(per_page, (page - 1) * per_page)
86
+
87
+ connection.select_all(sql, "Pgbus Job List", binds).to_a.map { |row| format_job_row(row) }
88
+ rescue StandardError => e
89
+ Pgbus.logger.error { "[Pgbus::Web] Error reading the job list: #{e.class}: #{e.message}" }
90
+ []
91
+ end
92
+
93
+ def job_state_counts(queue_name: nil, queues: nil, exclude: nil)
94
+ scope = job_list_scope(queue_name, queues: queues, exclude: exclude)
95
+ return empty_state_counts unless scope
96
+
97
+ kinds = []
98
+ fragments = []
99
+ count_fragments(scope).each do |kind, sql|
100
+ fragments << "(SELECT #{kinds.size} AS frag, state FROM (#{sql}) c LIMIT #{COUNT_CAP + 1})"
101
+ kinds << kind
102
+ end
103
+
104
+ rows = connection.select_all(<<~SQL, "Pgbus Job State Counts").to_a
105
+ SELECT frag, state, COUNT(*) AS n FROM (#{fragments.join("\nUNION ALL\n")}) AS counted
106
+ GROUP BY frag, state
107
+ SQL
108
+ tally_state_counts(rows, kinds)
109
+ rescue StandardError => e
110
+ Pgbus.logger.error { "[Pgbus::Web] Error counting job states: #{e.class}: #{e.message}" }
111
+ empty_state_counts
112
+ end
113
+
114
+ # { [physical_queue, msg_id] => jobs_ahead } for the ready queue rows
115
+ # on one page: one bounded COUNT per distinct queue, never per row.
116
+ def jobs_ahead(rows)
117
+ ready = rows.select { |r| r[:source] == "queue" && r[:state] == "ready" }
118
+ ready.group_by { |r| r[:queue_name] }.each_with_object({}) do |(queue, queue_rows), out|
119
+ out.merge!(jobs_ahead_in(queue, queue_rows.map { |r| r[:msg_id].to_i }.sort))
120
+ end
121
+ end
122
+
123
+ def job_list_context(now: Time.now)
124
+ alive = ->(kind) { processes.any? { |p| p[:kind].to_s == kind && p[:healthy] } }
125
+ JobState::Context.new(now: now, max_retries: Pgbus.configuration.max_retries,
126
+ paused: paused_queue_names.to_set, drained: drained_queue_names,
127
+ workers_alive: alive.call("worker"), handler_queues: handler_queue_physical_names.to_set,
128
+ consumers_alive: alive.call("consumer"))
129
+ end
130
+
131
+ private
132
+
133
+ # The physical non-DLQ queues in view, grouped under their logical
134
+ # name, or nil when the requested queue (or every listed one) does not
135
+ # exist. Excluded queues stay known, so their failed rows never show up
136
+ # as orphans of an unknown queue.
137
+ def job_list_scope(queue_name, queues: nil, exclude: nil)
138
+ names = queues_with_metrics.map { |q| q[:name] }.reject { |n| n.end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
139
+ names &= queues if queues
140
+ return nil if queues && names.empty?
141
+
142
+ excluded = exclude ? names & exclude : []
143
+ names -= excluded
144
+ if queue_name
145
+ return nil unless names.include?(queue_name)
146
+
147
+ names = [queue_name]
148
+ end
149
+
150
+ pairs = ->(list) { list.map { |n| [sanitize_name(n), logical_queue_name(n)] } }
151
+ scoped = pairs.call(names)
152
+ { queues: scoped, by_logical: scoped.group_by(&:last), excluded: pairs.call(excluded),
153
+ filtered: !queue_name.nil? || !queues.nil? }
154
+ end
155
+
156
+ def job_row_fragments(scope, state, state_param, fetch)
157
+ fragments = []
158
+ unless state == "blocked"
159
+ fragments.concat(scope[:queues].map { |(qtable, logical)| queue_row_fragment(qtable, logical, state, state_param, fetch) })
160
+ end
161
+ fragments.concat(orphan_fragments(scope, fetch)) if state.nil? || state == "retrying"
162
+ fragments << blocked_fragment(scope, fetch) if state.nil? || state == "blocked"
163
+ fragments
164
+ end
165
+
166
+ def queue_row_fragment(qtable, logical, state, state_param, fetch)
167
+ where = state_param ? "WHERE q.state = #{state_param}" : ""
168
+ prefilter = state_param && STATE_PREFILTER[state] ? "WHERE #{STATE_PREFILTER[state]}" : ""
169
+ <<~SQL.strip
170
+ (SELECT * FROM (
171
+ SELECT 'queue'::text AS source, m.msg_id AS id, '#{qtable}'::text AS queue_name,
172
+ '#{logical}'::text AS logical_queue, m.msg_id, m.message->>'job_class' AS job_class,
173
+ m.read_ct, m.enqueued_at, m.last_read_at, m.vt, #{STATE_CASE} AS state,
174
+ f.error_class, f.error_message, f.id AS failed_event_id,
175
+ NULL::text AS concurrency_key, NULL::integer AS slots_held, NULL::integer AS slots_max,
176
+ m.message::text AS payload, m.headers::text AS headers, m.enqueued_at AS sort_at
177
+ FROM pgmq.q_#{qtable} m
178
+ #{failed_join(qtable, logical)}
179
+ #{prefilter}
180
+ ) q #{where} ORDER BY q.msg_id DESC LIMIT #{fetch})
181
+ SQL
182
+ end
183
+
184
+ def failed_join(qtable, logical)
185
+ <<~SQL.strip
186
+ LEFT JOIN LATERAL (
187
+ SELECT f.id, f.error_class::text AS error_class, f.error_message,
188
+ f.failed_at::timestamptz AS failed_at
189
+ FROM pgbus_failed_events f
190
+ WHERE f.queue_name IN ('#{logical}', '#{qtable}') AND f.msg_id = m.msg_id
191
+ ORDER BY f.failed_at DESC LIMIT 1
192
+ ) f ON true
193
+ SQL
194
+ end
195
+
196
+ # Failed rows whose message left its queue: one fragment per logical
197
+ # queue, plus (unfiltered) one for rows whose queue no longer exists.
198
+ def orphan_fragments(scope, fetch)
199
+ fragments = scope[:by_logical].map do |logical, tables|
200
+ missing = tables.map { |(qtable, _)| "NOT EXISTS (SELECT 1 FROM pgmq.q_#{qtable} m WHERE m.msg_id = f.msg_id)" }
201
+ names = quoted_list([logical, *tables.map(&:first)])
202
+ orphan_fragment("'#{logical}'::text", "f.queue_name IN (#{names}) AND #{missing.join(" AND ")}", fetch)
203
+ end
204
+ return fragments if scope[:filtered]
205
+
206
+ known = scope[:by_logical].flat_map { |logical, tables| [logical, *tables.map(&:first)] }
207
+ known += scope[:excluded].flat_map { |(qtable, logical)| [logical, qtable] }
208
+ where = known.empty? ? "true" : "NOT (f.queue_name = ANY(ARRAY[#{quoted_list(known)}]))"
209
+ fragments << orphan_fragment("f.queue_name::text", where, fetch)
210
+ end
211
+
212
+ def orphan_fragment(logical_sql, where, fetch)
213
+ <<~SQL.strip
214
+ (SELECT 'failed'::text AS source, f.id, f.queue_name::text AS queue_name, #{logical_sql} AS logical_queue,
215
+ f.msg_id, f.payload->>'job_class' AS job_class, (f.retry_count + 1) AS read_ct,
216
+ NULL::timestamptz AS enqueued_at, NULL::timestamptz AS last_read_at, NULL::timestamptz AS vt,
217
+ 'retrying'::text AS state, f.error_class::text AS error_class, f.error_message,
218
+ f.id AS failed_event_id, NULL::text AS concurrency_key, NULL::integer AS slots_held,
219
+ NULL::integer AS slots_max, f.payload::text AS payload, f.headers::text AS headers,
220
+ f.failed_at::timestamptz AS sort_at
221
+ FROM pgbus_failed_events f WHERE #{where} ORDER BY f.id DESC LIMIT #{fetch})
222
+ SQL
223
+ end
224
+
225
+ def blocked_fragment(scope, fetch)
226
+ <<~SQL.strip
227
+ (SELECT 'blocked'::text AS source, b.id, b.queue_name::text AS queue_name,
228
+ b.queue_name::text AS logical_queue, NULL::bigint AS msg_id, b.payload->>'job_class' AS job_class,
229
+ NULL::integer AS read_ct, b.created_at::timestamptz AS enqueued_at,
230
+ NULL::timestamptz AS last_read_at, NULL::timestamptz AS vt, 'blocked'::text AS state,
231
+ NULL::text AS error_class, NULL::text AS error_message, NULL::bigint AS failed_event_id,
232
+ b.concurrency_key::text AS concurrency_key, s.value AS slots_held, s.max_value AS slots_max,
233
+ b.payload::text AS payload, NULL::text AS headers, b.created_at::timestamptz AS sort_at
234
+ FROM pgbus_blocked_executions b
235
+ LEFT JOIN pgbus_semaphores s ON s.key = b.concurrency_key
236
+ #{blocked_where(scope)} ORDER BY b.created_at DESC LIMIT #{fetch})
237
+ SQL
238
+ end
239
+
240
+ def blocked_where(scope)
241
+ if scope[:filtered]
242
+ return "WHERE false" if scope[:queues].empty?
243
+
244
+ "WHERE b.queue_name IN (#{quoted_list(scope[:queues].flat_map { |(qtable, logical)| [logical, qtable] })})"
245
+ elsif scope[:excluded].any?
246
+ "WHERE b.queue_name NOT IN (#{quoted_list(scope[:excluded].flat_map { |(qtable, logical)| [logical, qtable] })})"
247
+ else
248
+ ""
249
+ end
250
+ end
251
+
252
+ # [kind, sql selecting a `state` column] per fragment, uncapped.
253
+ def count_fragments(scope)
254
+ queue = scope[:queues].map do |(qtable, logical)|
255
+ [:queue, "SELECT #{STATE_CASE} AS state FROM pgmq.q_#{qtable} m #{failed_join(qtable, logical)}"]
256
+ end
257
+ orphans = orphan_fragments(scope, COUNT_CAP + 1).map { |sql| [:orphan, "SELECT state FROM #{sql} o"] }
258
+ queue + orphans + [[:blocked, "SELECT state FROM #{blocked_fragment(scope, COUNT_CAP + 1)} b2"]]
259
+ end
260
+
261
+ def tally_state_counts(rows, kinds)
262
+ counts = Hash.new(0)
263
+ per_fragment = Hash.new(0)
264
+ rows.each do |row|
265
+ n = row["n"].to_i
266
+ counts[row["state"]] += n
267
+ per_fragment[row["frag"].to_i] += n
268
+ end
269
+
270
+ capped = Set.new
271
+ per_fragment.each do |frag, total|
272
+ next if total <= COUNT_CAP
273
+
274
+ capped.merge(capped_states_for(kinds[frag]))
275
+ end
276
+ counts["all"] = counts.values.sum
277
+ StateCounts.new(counts: counts.to_h, capped: capped)
278
+ end
279
+
280
+ def capped_states_for(kind)
281
+ states = { queue: QUEUE_STATES, orphan: %w[retrying], blocked: %w[blocked] }.fetch(kind)
282
+ [*states, "all"]
283
+ end
284
+
285
+ def empty_state_counts
286
+ StateCounts.new(counts: {}, capped: Set.new)
287
+ end
288
+
289
+ def jobs_ahead_in(queue, msg_ids)
290
+ @jobs_ahead_memo ||= {}
291
+ @jobs_ahead_memo[[queue, msg_ids]] ||= begin
292
+ rows = connection.select_all(<<~SQL, "Pgbus Jobs Ahead", ["{#{msg_ids.join(",")}}"])
293
+ SELECT x.msg_id, (
294
+ SELECT COUNT(*) FROM (
295
+ SELECT 1 FROM pgmq.q_#{sanitize_name(queue)} m
296
+ WHERE m.vt <= now() AND m.msg_id < x.msg_id LIMIT #{JobState::AHEAD_CAP}
297
+ ) s
298
+ ) AS ahead
299
+ FROM unnest($1::bigint[]) AS x(msg_id)
300
+ SQL
301
+ rows.to_a.to_h { |r| [[queue, r["msg_id"].to_i], r["ahead"].to_i] }
302
+ end
303
+ rescue StandardError => e
304
+ Pgbus.logger.error { "[Pgbus::Web] Error counting jobs ahead in #{queue}: #{e.class}: #{e.message}" }
305
+ {}
306
+ end
307
+
308
+ # Values are sanitized queue names (word characters only), so quoting
309
+ # them as literals is safe.
310
+ def quoted_list(names)
311
+ names.uniq.map { |n| "'#{sanitize_name(n)}'" }.join(", ")
312
+ end
313
+
314
+ def format_job_row(row)
315
+ row.to_h.each_with_object({}) do |(key, value), out|
316
+ sym = key.to_sym
317
+ out[sym] = INTEGER_COLUMNS.include?(sym) && !value.nil? ? value.to_i : value
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end