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,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