pgbus 0.17.1 → 0.17.3

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +83 -13
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  9. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  10. data/app/controllers/pgbus/application_controller.rb +5 -2
  11. data/app/controllers/pgbus/batches_controller.rb +4 -1
  12. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  13. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  14. data/app/controllers/pgbus/events_controller.rb +52 -3
  15. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  16. data/app/controllers/pgbus/insights_controller.rb +19 -1
  17. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  18. data/app/controllers/pgbus/locks_controller.rb +7 -2
  19. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  20. data/app/controllers/pgbus/processes_controller.rb +3 -1
  21. data/app/controllers/pgbus/queues_controller.rb +16 -4
  22. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  23. data/app/frontend/pgbus/application.min.js +2 -0
  24. data/app/frontend/pgbus/application.min.js.map +1 -0
  25. data/app/frontend/pgbus/modules/charts.js +137 -77
  26. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  27. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  28. data/app/frontend/pgbus/style.css +1 -1
  29. data/app/frontend/pgbus/tailwind.css +18 -0
  30. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  31. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  32. data/app/helpers/pgbus/application_helper.rb +155 -113
  33. data/app/helpers/pgbus/button_helper.rb +88 -0
  34. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  35. data/app/helpers/pgbus/events_helper.rb +40 -0
  36. data/app/helpers/pgbus/insights_helper.rb +39 -0
  37. data/app/helpers/pgbus/processes_helper.rb +79 -0
  38. data/app/views/layouts/pgbus/application.html.erb +0 -1
  39. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  40. data/app/views/pgbus/batches/show.html.erb +3 -4
  41. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  42. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  43. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  44. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  45. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  46. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  47. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  48. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  49. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  50. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  51. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  52. data/app/views/pgbus/events/_list.html.erb +55 -0
  53. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  54. data/app/views/pgbus/events/_replay.html.erb +10 -0
  55. data/app/views/pgbus/events/_row.html.erb +162 -0
  56. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  57. data/app/views/pgbus/events/index.html.erb +27 -48
  58. data/app/views/pgbus/events/show.html.erb +25 -7
  59. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  60. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  61. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  62. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  63. data/app/views/pgbus/insights/show.html.erb +83 -100
  64. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  65. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  66. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  67. data/app/views/pgbus/jobs/index.html.erb +10 -10
  68. data/app/views/pgbus/jobs/show.html.erb +18 -20
  69. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  70. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  71. data/app/views/pgbus/outbox/index.html.erb +30 -27
  72. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  73. data/app/views/pgbus/processes/_row.html.erb +48 -0
  74. data/app/views/pgbus/processes/_status.html.erb +4 -0
  75. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  76. data/app/views/pgbus/queues/show.html.erb +69 -118
  77. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  78. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  79. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  80. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  81. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  82. data/config/i18n-tasks.yml +11 -2
  83. data/config/locales/da.yml +405 -113
  84. data/config/locales/de.yml +405 -113
  85. data/config/locales/en.yml +405 -113
  86. data/config/locales/es.yml +404 -112
  87. data/config/locales/fi.yml +404 -112
  88. data/config/locales/fr.yml +406 -114
  89. data/config/locales/it.yml +405 -113
  90. data/config/locales/ja.yml +405 -113
  91. data/config/locales/nb.yml +405 -113
  92. data/config/locales/nl.yml +405 -113
  93. data/config/locales/pt.yml +406 -114
  94. data/config/locales/sv.yml +404 -112
  95. data/config/routes.rb +1 -1
  96. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  97. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  98. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  99. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  100. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  101. data/lib/pgbus/active_job/executor.rb +16 -6
  102. data/lib/pgbus/cli/dlq.rb +32 -5
  103. data/lib/pgbus/client.rb +4 -2
  104. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  105. data/lib/pgbus/configuration.rb +51 -2
  106. data/lib/pgbus/dead_letter_header.rb +129 -0
  107. data/lib/pgbus/doctor.rb +8 -4
  108. data/lib/pgbus/engine.rb +13 -7
  109. data/lib/pgbus/event_bus/handler.rb +18 -11
  110. data/lib/pgbus/event_bus/registry.rb +6 -0
  111. data/lib/pgbus/failed_event_recorder.rb +34 -0
  112. data/lib/pgbus/generators/migration_detector.rb +9 -1
  113. data/lib/pgbus/instrumentation.rb +2 -1
  114. data/lib/pgbus/mcp/base_tool.rb +25 -0
  115. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  116. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  117. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  118. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  119. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  120. data/lib/pgbus/outbox/poller.rb +1 -1
  121. data/lib/pgbus/process/consumer.rb +53 -3
  122. data/lib/pgbus/process/dispatcher.rb +1 -1
  123. data/lib/pgbus/process/supervisor.rb +32 -11
  124. data/lib/pgbus/process/worker.rb +45 -8
  125. data/lib/pgbus/recurring/scheduler.rb +2 -1
  126. data/lib/pgbus/streams/presence.rb +317 -81
  127. data/lib/pgbus/version.rb +1 -1
  128. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  129. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  130. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  131. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  132. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  133. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  134. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  135. data/lib/pgbus/web/data_source.rb +132 -254
  136. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  137. data/lib/pgbus/web/event_state.rb +71 -0
  138. data/lib/pgbus/web/health_app.rb +7 -3
  139. data/lib/pgbus/web/health_verdict.rb +78 -0
  140. data/lib/pgbus/web/insights_charts.rb +84 -0
  141. data/lib/pgbus/web/job_state.rb +111 -0
  142. data/lib/pgbus/web/process_activity.rb +97 -0
  143. data/lib/pgbus/web/process_state.rb +187 -0
  144. data/lib/pgbus/web/queue_summary.rb +104 -0
  145. data/lib/pgbus/web/stream_app.rb +18 -6
  146. data/lib/pgbus/web/streamer/connection.rb +7 -1
  147. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  148. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  149. data/lib/pgbus/web/streamer/instance.rb +2 -1
  150. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  151. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  152. data/lib/pgbus/web/time_format.rb +128 -0
  153. metadata +55 -4
  154. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  155. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  156. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -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
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # Row totals for the dashboard's paged lists (issue #496). list_count
7
+ # stops at COUNT_CAP + 1 rows, so a table with millions of rows costs no
8
+ # more than one with ten thousand; past the cap the pager shows "10,000+".
9
+ # The same bound JobList uses for its state tabs. batches_count and
10
+ # recurring_tasks_count are exact (summary_stats' badge reads the latter);
11
+ # they predate the pagers and live here to keep data_source.rb small.
12
+ module ListCounts
13
+ COUNT_CAP = 10_000
14
+
15
+ Count = Data.define(:total, :capped) do
16
+ def capped? = capped
17
+ end
18
+
19
+ EMPTY_COUNT = Count.new(total: 0, capped: false)
20
+
21
+ # Semaphores and parked jobs, FULL OUTER JOINed: a key can have parked
22
+ # rows and no semaphore (its holder died and the sweep removed the row)
23
+ # or a semaphore and nothing parked. Shared with concurrency_keys.
24
+ CONCURRENCY_KEYS_FROM = <<~SQL
25
+ FROM pgbus_semaphores s
26
+ FULL OUTER JOIN (
27
+ SELECT concurrency_key, COUNT(*) AS parked_count, MIN(created_at) AS oldest_parked_at
28
+ FROM pgbus_blocked_executions
29
+ GROUP BY concurrency_key
30
+ ) b ON s.key = b.concurrency_key
31
+ SQL
32
+
33
+ LIST_MODELS = {
34
+ batches: -> { BatchEntry },
35
+ job_locks: -> { UniquenessKey },
36
+ outbox: -> { OutboxEntry },
37
+ processed_events: -> { ProcessedEvent },
38
+ recurring_tasks: -> { RecurringTask }
39
+ }.freeze
40
+
41
+ # list – :batches, :job_locks, :concurrency_keys, :outbox, :processed_events or :recurring_tasks.
42
+ def list_count(list)
43
+ raise ArgumentError, "unknown list: #{list.inspect}" unless list == :concurrency_keys || LIST_MODELS.key?(list)
44
+
45
+ bounded_count(list)
46
+ end
47
+
48
+ def batches_count
49
+ BatchEntry.count
50
+ rescue StandardError => e
51
+ Pgbus.logger.debug { "[Pgbus::Web] Error counting batches: #{e.message}" }
52
+ 0
53
+ end
54
+
55
+ def recurring_tasks_count
56
+ RecurringTask.count
57
+ rescue StandardError => e
58
+ Pgbus.logger.debug { "[Pgbus::Web] Error counting recurring tasks: #{e.message}" }
59
+ 0
60
+ end
61
+
62
+ private
63
+
64
+ def bounded_count(list)
65
+ rows = list == :concurrency_keys ? concurrency_keys_probe : LIST_MODELS.fetch(list).call.limit(COUNT_CAP + 1).count
66
+ Count.new(total: [rows.to_i, COUNT_CAP].min, capped: rows.to_i > COUNT_CAP)
67
+ rescue StandardError => e
68
+ Pgbus.logger.debug { "[Pgbus::Web] Error counting #{list}: #{e.message}" }
69
+ EMPTY_COUNT
70
+ end
71
+
72
+ def concurrency_keys_probe
73
+ connection.select_value(<<~SQL, "Pgbus Concurrency Keys Count")
74
+ SELECT COUNT(*) FROM (SELECT 1 #{CONCURRENCY_KEYS_FROM} LIMIT #{COUNT_CAP + 1}) c
75
+ SQL
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The Processes page's rows and the context ProcessState needs to put
7
+ # them in words (issue #493). Every status in one result set is derived
8
+ # against one ProcessState::Thresholds, so one now.
9
+ module ProcessList
10
+ # thresholds: pass the ones the caller puts the rows in words with, so
11
+ # badge and sentence share one now.
12
+ def processes(thresholds: process_thresholds)
13
+ process_rows.map { |r| format_process(r, thresholds) }
14
+ rescue StandardError => e
15
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching processes: #{e.message}" }
16
+ []
17
+ end
18
+
19
+ # The dashboard host's config: the stall limits the page states are
20
+ # this host's stall_threshold, as the badge always was.
21
+ def process_thresholds(now: Time.now)
22
+ config = Pgbus.configuration
23
+ ProcessState::Thresholds.new(
24
+ now: now, alive_threshold: Process::Heartbeat::ALIVE_THRESHOLD, stall_threshold: config.stall_threshold,
25
+ dispatch_interval: config.dispatch_interval, recurring_schedule_interval: config.recurring_schedule_interval,
26
+ polling_interval: config.polling_interval, notify_fallback_poll: Process::Consumer::NOTIFY_FALLBACK_POLL_SECONDS,
27
+ notify_wakeup: config.worker_notify_wakeup?, reap_interval: Process::Dispatcher::REAP_INTERVAL
28
+ )
29
+ end
30
+
31
+ def process_context(rows:, thresholds: process_thresholds)
32
+ ProcessState.context_for(rows, thresholds)
33
+ end
34
+
35
+ private
36
+
37
+ # The raw rows, memoized per instance like queues_with_metrics (cleared
38
+ # by reset_cache!): the home page reads them for the stats, the signals,
39
+ # the health verdict, the queue lines and the panel (issue #492). Only
40
+ # the rows: each caller derives statuses against its own thresholds.
41
+ def process_rows
42
+ @process_rows ||= connection.select_all("SELECT * FROM pgbus_processes ORDER BY kind, created_at").to_a
43
+ end
44
+
45
+ def format_process(row, thresholds)
46
+ heartbeat = JobState.time(row["last_heartbeat_at"])
47
+ metadata = row["metadata"].is_a?(String) ? JSON.parse(row["metadata"]) : row["metadata"]
48
+ status = ProcessState.derive(kind: row["kind"], metadata: metadata, heartbeat_at: heartbeat,
49
+ thresholds: thresholds)
50
+ started_at = metadata.is_a?(Hash) && metadata["started_at"]
51
+
52
+ {
53
+ id: row["id"].to_i,
54
+ kind: row["kind"],
55
+ hostname: row["hostname"],
56
+ pid: row["pid"].to_i,
57
+ metadata: metadata,
58
+ last_heartbeat_at: heartbeat,
59
+ healthy: status == :healthy,
60
+ status: status,
61
+ created_at: row["created_at"],
62
+ supervisor_pid: metadata.is_a?(Hash) ? metadata["supervisor_pid"] : nil,
63
+ started_at: started_at ? Time.at(started_at.to_f) : nil
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ class DataSource
6
+ # The facts behind a queue page's summary in words (issue #491), turned
7
+ # into sentences by Pgbus::Web::QueueSummary. Both methods take the
8
+ # physical queue name the page is for.
9
+ module QueueSummary
10
+ RUNNING = { paused: false, reason: nil, paused_at: nil, resumes_at: nil, trip_count: 0 }.freeze
11
+
12
+ # Pause state is kept per logical queue, so every priority level and
13
+ # the bare table share it.
14
+ def queue_pause_state(name)
15
+ pause_state_of(QueueState.find_by(queue_name: logical_queue_name(name)))
16
+ rescue StandardError => e
17
+ Pgbus.logger.error { "[Pgbus::Web] Error reading pause state for #{name}: #{e.class}: #{e.message}" }
18
+ RUNNING
19
+ end
20
+
21
+ # { physical name => pause state } for many queues in one query (the
22
+ # home page's queues table, issue #492). Raises: the caller degrades.
23
+ def queue_pause_states(names)
24
+ states = QueueState.where(queue_name: names.map { |n| logical_queue_name(n) }.uniq).index_by(&:queue_name)
25
+ names.to_h { |name| [name, pause_state_of(states[logical_queue_name(name)])] }
26
+ end
27
+
28
+ # healthy: and streams: let a caller describing many queues load the
29
+ # process rows and the stream registry once (issue #492).
30
+ def queue_drainers(name, healthy: processes.select { |p| p[:healthy] }, streams: stream_queue_names)
31
+ table = name.to_s.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX)
32
+ level = priority_level_of(table)
33
+ logical = level ? logical_queue_name(table) : table.delete_prefix("#{Pgbus.configuration.queue_prefix}_")
34
+ capsules, wildcard = draining_capsules(name)
35
+ {
36
+ logical: logical, capsules: capsules, wildcard: wildcard,
37
+ handler: handler_queue_physical_names.include?(name), stream: streams.include?(name),
38
+ live_workers: healthy.count { |p| p[:kind].to_s == "worker" && worker_drains?(p, logical) },
39
+ live_consumers: healthy.count { |p| p[:kind].to_s == "consumer" && consumer_drains?(p, name) },
40
+ siblings: level ? priority_siblings(name, logical) : [], priority_level: level
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ def pause_state_of(state)
47
+ return RUNNING unless state&.paused
48
+
49
+ { paused: true, reason: state.paused_reason, paused_at: state.paused_at,
50
+ resumes_at: state.circuit_breaker_resume_at, trip_count: state.circuit_breaker_trip_count.to_i }
51
+ end
52
+
53
+ # The level of a priority sub-table, or nil. `_pN` is legal in an
54
+ # ordinary queue name, so only a table the queue strategy creates for
55
+ # the stripped logical name counts.
56
+ def priority_level_of(table)
57
+ level = table[Pgbus::Web::QueueSummary::PRIORITY_LEVEL, 1]
58
+ return unless level && @client.physical_queue_names(logical_queue_name(table)).include?(table)
59
+
60
+ level.to_i
61
+ rescue StandardError => e
62
+ Pgbus.logger.debug { "[Pgbus::Web] Error resolving priority level of #{table}: #{e.class}: #{e.message}" }
63
+ nil
64
+ end
65
+
66
+ # [names of the capsules whose queues expand to this table, wildcard?].
67
+ # An anonymous capsule goes by its first queue. Fails open to a
68
+ # wildcard, like drained_queue_names: never a false "nothing drains it".
69
+ def draining_capsules(name)
70
+ capsules = Array(Pgbus.configuration.workers)
71
+ wildcard = capsules.any? { |c| capsule_queue_list(c).include?("*") }
72
+ names = capsules.filter_map do |c|
73
+ queues = capsule_queue_list(c) - ["*"]
74
+ next unless queues.any? { |q| @client.physical_queue_names(q).include?(name) }
75
+
76
+ (c[:name] || c["name"] || queues.first).to_s
77
+ end
78
+ [names, wildcard]
79
+ rescue StandardError => e
80
+ Pgbus.logger.error { "[Pgbus::Web] Error resolving capsules for #{name}: #{e.class}: #{e.message}" }
81
+ [[], true]
82
+ end
83
+
84
+ def capsule_queue_list(capsule)
85
+ Array(capsule[:queues] || capsule["queues"]).map(&:to_s)
86
+ end
87
+
88
+ # A heartbeat lists queue names as configured (Array or comma-separated
89
+ # String); tables are named after their normalized form ("bulk-imports"
90
+ # → pgbus_bulk_imports), so compare that.
91
+ def worker_drains?(process, logical)
92
+ queues = Array((process[:metadata] || {})["queues"]).flat_map { |q| q.to_s.split(",") }.map(&:strip)
93
+ queues.include?("*") || queues.any? { |q| normalized_queue(q) == logical }
94
+ end
95
+
96
+ # A consumer drains a handler queue when one of its topic filters
97
+ # overlaps a subscription routed to it (the rule the consumer itself
98
+ # uses to pick queues). A heartbeat without topics counts.
99
+ def consumer_drains?(process, name)
100
+ topics = Array((process[:metadata] || {})["topics"])
101
+ return true if topics.empty?
102
+
103
+ patterns = registered_subscribers.select { |s| s[:physical_queue_name] == name }.map { |s| s[:pattern] }
104
+ registry = EventBus::Registry.instance
105
+ topics.any? { |t| patterns.any? { |pattern| registry.pattern_overlaps?(t.to_s, pattern) } }
106
+ end
107
+
108
+ def normalized_queue(queue)
109
+ Pgbus.configuration.queue_name(queue).delete_prefix("#{Pgbus.configuration.queue_prefix}_")
110
+ rescue ArgumentError
111
+ queue
112
+ end
113
+
114
+ def priority_siblings(name, logical)
115
+ queues_with_metrics.map { |q| q[:name] }.select do |n|
116
+ n != name && n.match?(Pgbus::Web::QueueSummary::PRIORITY_LEVEL) && logical_queue_name(n) == logical
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end