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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # The Events page's words for one pending event (issue #494): JobState's
6
+ # state and reason, re-keyed into event vocabulary with the handler named
7
+ # in every reason, plus the one thing only events have: a pattern no
8
+ # running consumer subscribes to.
9
+ #
10
+ # Rows come from DataSource#event_rows (JobList SQL scoped to the handler
11
+ # queues) and carry :handler_class and :pattern. Pure, like JobState.
12
+ module EventState
13
+ STATES = %w[ready scheduled running retrying].freeze
14
+
15
+ # Waiting reasons a missing consumer explains better.
16
+ UNCOVERED = %w[waiting waiting_priority waiting_unknown lease_expired no_workers not_drained].freeze
17
+
18
+ # Handler queues have no priority sub-tables and are always drained.
19
+ RENAMED = {
20
+ "running" => "handling", "no_workers" => "no_consumers",
21
+ "waiting_priority" => "waiting", "not_drained" => "no_consumer_for_queue"
22
+ }.freeze
23
+
24
+ PROCESSED_TONES = { "completed" => :green, "handling" => :indigo, "abandoned" => :yellow }.freeze
25
+
26
+ # jobs: the JobState::Context. covered_queues: Set of physical handler
27
+ # queues some healthy consumer's topics overlap, or nil when unknown.
28
+ # claim_window: EventBus::Handler.claim_ownership_window, in seconds.
29
+ Context = Data.define(:jobs, :covered_queues, :claim_window)
30
+ Result = Data.define(:state, :reason_key, :reason_args, :next_run_at, :badge_tone, :handler_class)
31
+ ProcessedResult = Data.define(:state, :reason_key, :reason_args, :badge_tone)
32
+
33
+ module_function
34
+
35
+ def present(row, context, ahead: nil)
36
+ job = JobState.present(row, context.jobs, ahead: ahead)
37
+ key = event_reason_key(job.reason_key, row, context)
38
+ handler = row[:handler_class] || row[:queue_name]
39
+ args = key == "no_consumer_for_queue" ? { pattern: row[:pattern] } : job.reason_args
40
+ Result.new(state: job.state, reason_key: key, reason_args: args.merge(handler: handler),
41
+ next_run_at: job.next_run_at, badge_tone: job.badge_tone, handler_class: row[:handler_class])
42
+ end
43
+
44
+ def event_reason_key(key, row, context)
45
+ covered = context.covered_queues
46
+ return "no_consumer_for_queue" if UNCOVERED.include?(key) && covered && !covered.include?(row[:queue_name])
47
+
48
+ RENAMED.fetch(key, key)
49
+ end
50
+
51
+ # The audit state of one pgbus_processed_events row. processed_at is the
52
+ # claim's last heartbeat (ClaimBeat refreshes it while the handler runs),
53
+ # so a pending claim older than the ownership window went silent.
54
+ def processed(row, now:, claim_window:)
55
+ processed_at = JobState.time(row["processed_at"])
56
+ return processed_result("completed", "completed_legacy", processed_at) unless row.key?("completed_at")
57
+
58
+ completed_at = JobState.time(row["completed_at"])
59
+ return processed_result("completed", "completed", completed_at) if completed_at
60
+ return processed_result("handling", "handling", processed_at) if processed_at && now - processed_at < claim_window
61
+
62
+ processed_result("abandoned", "abandoned", processed_at)
63
+ end
64
+
65
+ def processed_result(state, key, ago)
66
+ ProcessedResult.new(state: state, reason_key: key, reason_args: { ago: ago },
67
+ badge_tone: PROCESSED_TONES.fetch(state))
68
+ end
69
+ end
70
+ end
71
+ end
@@ -106,10 +106,14 @@ module Pgbus
106
106
  end
107
107
 
108
108
  # Reuse an injected DataSource (tests, an app that wants one shared
109
- # instance); otherwise build a fresh one each check so per-instance
110
- # memoization can never serve stale queue/process metrics.
109
+ # instance), dropping its per-instance memos first; otherwise build a
110
+ # fresh one each check. Either way no check sees the last one's queue,
111
+ # process, stream or table metrics.
111
112
  def data_source_for_check
112
- @data_source || DataSource.new
113
+ return DataSource.new unless @data_source
114
+
115
+ @data_source.reset_cache! if @data_source.respond_to?(:reset_cache!)
116
+ @data_source
113
117
  end
114
118
 
115
119
  def not_found
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Turns queue_health_stats into what the home page's Queue Health cards
6
+ # say (issue #492): each number's level, and the thresholds behind it, so
7
+ # the words can state them instead of colouring a percentage unexplained.
8
+ #
9
+ # Pure like QueueSummary: no I/O. Each Entry carries a translation key
10
+ # under pgbus.dashboard.queue_health.words and raw args (seconds, ratios,
11
+ # counts) the helper formats.
12
+ module HealthVerdict
13
+ Entry = Data.define(:level, :key, :args)
14
+ Result = Data.define(:dead_tuples, :bloat, :vacuum, :transaction)
15
+
16
+ DEAD_TUPLES_WARN = 10_000
17
+ BLOAT_WARN = 0.10
18
+ BLOAT_CRITICAL = 0.20
19
+ VACUUM_WARN_SEC = 3600
20
+ VACUUM_CRITICAL_SEC = 86_400
21
+ TXN_WARN_SEC = 30
22
+ TXN_CRITICAL_SEC = 60
23
+ # Where Pgbus::MCP::HealthAnalyzer turns the verdict DEGRADED (its MVCC
24
+ # check); the card's own thresholds are earlier warnings.
25
+ VERDICT_TXN_SEC = 300
26
+
27
+ module_function
28
+
29
+ def present(health)
30
+ Result.new(dead_tuples: dead_tuples(health), bloat: bloat(health), vacuum: vacuum(health),
31
+ transaction: transaction(health))
32
+ end
33
+
34
+ def dead_tuples(health)
35
+ count = health[:total_dead_tuples].to_i
36
+ entry("dead_tuples", count > DEAD_TUPLES_WARN ? :warn : :ok, count: count, limit: DEAD_TUPLES_WARN)
37
+ end
38
+
39
+ def bloat(health)
40
+ worst = Array(health[:tables]).max_by { |t| t[:bloat_ratio].to_f }
41
+ return entry("bloat", :ok, :none) unless worst
42
+
43
+ ratio = health[:worst_bloat_ratio].to_f
44
+ entry("bloat", level(ratio, BLOAT_WARN, BLOAT_CRITICAL), ratio: ratio, table: worst[:table],
45
+ warn: BLOAT_WARN, critical: BLOAT_CRITICAL)
46
+ end
47
+
48
+ def vacuum(health)
49
+ age = health[:oldest_vacuum_ago_sec]
50
+ return entry("vacuum", :unknown) if age.nil?
51
+
52
+ entry("vacuum", level(age, VACUUM_WARN_SEC, VACUUM_CRITICAL_SEC), age: age, warn: VACUUM_WARN_SEC,
53
+ critical: VACUUM_CRITICAL_SEC)
54
+ end
55
+
56
+ def transaction(health)
57
+ age = health[:oldest_transaction_age_sec]
58
+ args = { warn: TXN_WARN_SEC, critical: TXN_CRITICAL_SEC, verdict: VERDICT_TXN_SEC }
59
+ return entry("transaction", :ok, :none, **args) if age.nil?
60
+
61
+ entry("transaction", level(age, TXN_WARN_SEC, TXN_CRITICAL_SEC), age: age, **args)
62
+ end
63
+
64
+ def level(value, warn, critical)
65
+ return :critical if value > critical
66
+ return :warn if value > warn
67
+
68
+ :ok
69
+ end
70
+
71
+ # The key is the metric and the level, or a named case (none) that has
72
+ # its own sentence.
73
+ def entry(metric, level, variant = level, **args)
74
+ Entry.new(level: level, key: "#{metric}.#{variant}", args: args)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # What each Insights chart says in words, and the rows of its data table
6
+ # (issue #492): the accessible alternative to the SVG, rendered on the
7
+ # server. Pure: it takes the DataSource's chart series and does no I/O.
8
+ #
9
+ # Series are minute buckets; a 30-day window has up to 43,200 of them, so
10
+ # the tables roll consecutive minutes up into at most MAX_TABLE_ROWS rows
11
+ # (the chart itself still plots every minute).
12
+ module InsightsCharts
13
+ MAX_TABLE_ROWS = 60
14
+ STATUS_ORDER = %w[success failed dead_lettered].freeze
15
+
16
+ Throughput = Data.define(:total, :peak, :peak_at, :step_minutes, :rows) do
17
+ def empty? = rows.empty?
18
+ end
19
+
20
+ Status = Data.define(:total, :rows) do
21
+ def empty? = total.zero?
22
+
23
+ # 0 for a status with no row: grouped counts omit statuses with none.
24
+ def [](status)
25
+ row = rows.find { |r| r[:status] == status.to_s }
26
+ row ? row[:count] : 0
27
+ end
28
+ end
29
+
30
+ Latency = Data.define(:step_minutes, :rows) do
31
+ def empty? = rows.empty?
32
+ end
33
+
34
+ module_function
35
+
36
+ def throughput(points, minutes:)
37
+ series = points.filter_map { |p| (time = TimeFormat.coerce(p[:time])) && [time, p[:count].to_i] }
38
+ busiest = series.max_by(&:last)
39
+ step = step_minutes(minutes)
40
+ rows = bucket(series, step).map { |from, group| { from: from, count: group.sum(&:last) } }
41
+ Throughput.new(total: series.sum(&:last), peak: busiest&.last, peak_at: busiest&.first, step_minutes: step,
42
+ rows: rows)
43
+ end
44
+
45
+ def status(counts)
46
+ rows = counts.to_h { |status, count| [status.to_s, count.to_i] }
47
+ .sort_by { |status, _| [STATUS_ORDER.index(status) || STATUS_ORDER.size, status] }
48
+ total = rows.sum(&:last)
49
+ Status.new(total: total, rows: rows.map do |status, count|
50
+ { status: status, count: count, share: total.zero? ? 0.0 : count.to_f / total }
51
+ end)
52
+ end
53
+
54
+ # Each row: the mean of its minutes' averages and the highest minute p95
55
+ # (percentiles do not average).
56
+ def latency(points, minutes:)
57
+ series = points.filter_map { |p| (time = TimeFormat.coerce(p[:time])) && [time, p[:avg_ms].to_f, p[:p95_ms].to_f] }
58
+ step = step_minutes(minutes)
59
+ rows = bucket(series, step).map do |from, group|
60
+ { from: from, avg_ms: (group.sum { |g| g[1] } / group.size).round, p95_ms: group.map(&:last).max.round }
61
+ end
62
+ Latency.new(step_minutes: step, rows: rows)
63
+ end
64
+
65
+ def step_minutes(minutes)
66
+ [(minutes.to_f / MAX_TABLE_ROWS).ceil, 1].max
67
+ end
68
+
69
+ # [[bucket start, [entries]], ...] in time order; entries start with their
70
+ # Time. Buckets are anchored at the first entry, and the last one absorbs
71
+ # an entry exactly a window's length later, so a table never exceeds
72
+ # MAX_TABLE_ROWS rows.
73
+ def bucket(series, step)
74
+ return [] if series.empty?
75
+
76
+ seconds = step * 60
77
+ first = series.map(&:first).min.to_i
78
+ series.group_by { |entry| [(entry.first.to_i - first) / seconds, MAX_TABLE_ROWS - 1].min }
79
+ .sort_by(&:first)
80
+ .map { |index, group| [Time.at(first + (index * seconds)).utc, group] }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Pgbus
6
+ module Web
7
+ # Turns one Jobs-list row into the words an operator reads: why the job is
8
+ # sitting there and when it is expected to run (issue #489).
9
+ #
10
+ # The row's `state` is derived in SQL (DataSource::JobList::STATE_CASE) and
11
+ # taken as given; this class never re-derives it. It only adds the reason
12
+ # and the ETA, from facts the row and the request context already carry, so
13
+ # it does no I/O and is fully unit-testable.
14
+ #
15
+ # reason_args carry raw values: Integers, Strings, and Times under :time
16
+ # (a future moment) and :ago (a past one). The view helper formats them.
17
+ module JobState
18
+ STATES = %w[ready scheduled running retrying blocked].freeze
19
+ AHEAD_CAP = 10_000
20
+ PRIORITY_TABLE = /_p\d+\z/
21
+
22
+ BADGE_TONES = {
23
+ "ready" => :blue, "scheduled" => :gray, "running" => :indigo,
24
+ "retrying" => :yellow, "blocked" => :purple
25
+ }.freeze
26
+
27
+ # paused: Set of logical queue names. drained: Set of physical queue
28
+ # names, or nil when a wildcard capsule drains every queue.
29
+ # handler_queues: physical EventBus handler queues, which consumers
30
+ # drain; every other queue needs a live worker.
31
+ Context = Data.define(:now, :max_retries, :paused, :drained, :workers_alive, :handler_queues,
32
+ :consumers_alive)
33
+ Result = Data.define(:state, :reason_key, :reason_args, :next_run_at, :badge_tone)
34
+
35
+ module_function
36
+
37
+ def present(row, context, ahead: nil)
38
+ state = row[:state].to_s
39
+ key, args, next_run_at = reason_for(state, row, context, ahead)
40
+ Result.new(state: state, reason_key: key, reason_args: args, next_run_at: next_run_at,
41
+ badge_tone: BADGE_TONES.fetch(state, :gray))
42
+ end
43
+
44
+ def reason_for(state, row, context, ahead)
45
+ case state
46
+ when "scheduled"
47
+ vt = time(row[:vt])
48
+ ["scheduled", { time: vt }, vt]
49
+ when "running" then ["running", { ago: time(row[:last_read_at]), time: time(row[:vt]) }, nil]
50
+ when "retrying" then retrying_reason(row, context)
51
+ when "blocked" then blocked_reason(row)
52
+ else ready_reason(row, context, ahead)
53
+ end
54
+ end
55
+
56
+ def ready_reason(row, context, ahead)
57
+ overlay = ready_overlay(row, context)
58
+ return [overlay, {}, nil] if overlay
59
+ return ["lease_expired", { attempt: row[:read_ct].to_i + 1 }, nil] if row[:read_ct].to_i.positive?
60
+ return ["waiting_unknown", {}, nil] if ahead.nil?
61
+
62
+ key = row[:queue_name].to_s.match?(PRIORITY_TABLE) ? "waiting_priority" : "waiting"
63
+ [key, { count: ahead_count(ahead) }, nil]
64
+ end
65
+
66
+ # First match wins: a paused queue explains everything below it, a queue
67
+ # no capsule drains explains why live workers do not help, and only then
68
+ # does the absence of any healthy worker matter.
69
+ def ready_overlay(row, context)
70
+ return "paused" if context.paused.include?(row[:logical_queue])
71
+ return "not_drained" if context.drained && !context.drained.include?(row[:queue_name])
72
+
73
+ alive = context.handler_queues.include?(row[:queue_name]) ? context.consumers_alive : context.workers_alive
74
+ "no_workers" unless alive
75
+ end
76
+
77
+ def retrying_reason(row, context)
78
+ error = row[:error_class]
79
+ return ["orphaned", { error: error }, nil] if row[:source].to_s == "failed"
80
+
81
+ attempt = row[:read_ct].to_i
82
+ args = { attempt: attempt, max: context.max_retries, error: error }
83
+ vt = time(row[:vt])
84
+ return ["retrying_dlq", args, vt] if attempt >= context.max_retries
85
+ return ["retrying_due", args, vt] if vt.nil? || vt <= context.now
86
+
87
+ ["retrying", args.merge(time: vt), vt]
88
+ end
89
+
90
+ def blocked_reason(row)
91
+ key = row[:concurrency_key]
92
+ ago = time(row[:enqueued_at])
93
+ return ["blocked_no_slots", { key: key, ago: ago }, nil] if row[:slots_max].nil?
94
+
95
+ ["blocked", { key: key, held: row[:slots_held].to_i, limit: row[:slots_max].to_i, ago: ago }, nil]
96
+ end
97
+
98
+ def ahead_count(ahead)
99
+ ahead.to_i >= AHEAD_CAP ? "10k+" : ahead.to_i
100
+ end
101
+
102
+ def time(value)
103
+ case value
104
+ when nil, "" then nil
105
+ when String then Time.parse(value)
106
+ else value
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # What a process is doing and how close it is to a recycle (issue #493),
6
+ # as lists of [key, args] parts the view joins with " · ". A part shows up
7
+ # only when its fact was reported, so a process from before a key existed
8
+ # just says less. Pure, like ProcessState.
9
+ #
10
+ # args carry raw values: Integers and Floats, seconds under :interval,
11
+ # :uptime and :limit (lifetime), Strings for queue and topic lists.
12
+ module ProcessActivity
13
+ KIND_ORDER = %w[worker consumer dispatcher scheduler outbox_poller].freeze
14
+ RECYCLE_LIMITS = [%w[jobs max_jobs jobs_processed done], %w[memory max_memory_mb memory_mb used]].freeze
15
+
16
+ module_function
17
+
18
+ def activity(kind:, metadata:, children:, thresholds:)
19
+ case kind
20
+ when "worker" then worker(metadata)
21
+ when "consumer" then consumer(metadata)
22
+ when "dispatcher" then [["maintenance", { interval: thresholds.dispatch_interval }]]
23
+ when "scheduler"
24
+ [["schedule", { count: metadata["tasks"].to_i, interval: thresholds.recurring_schedule_interval }]]
25
+ when "supervisor" then supervisor(children)
26
+ when "outbox_poller" then [["outbox", {}]]
27
+ else []
28
+ end
29
+ end
30
+
31
+ def worker(metadata)
32
+ queues = list(metadata["queues"])
33
+ # A "*" anywhere puts the worker in wildcard mode (Worker#@wildcard).
34
+ drains = if queues.include?("*") then ["drains_all", {}]
35
+ elsif queues.any? then ["drains", { queues: queues.join(", ") }]
36
+ end
37
+ [drains, *pool(metadata), buffered(metadata), *rates(metadata["rates"])].compact
38
+ end
39
+
40
+ def rates(rates)
41
+ return [] unless rates.is_a?(Hash)
42
+
43
+ { "rate" => rates["processed"], "failed_rate" => rates["failed"] }
44
+ .filter_map { |key, rate| [key, { rate: rate.to_f }] if rate.to_f.positive? }
45
+ end
46
+
47
+ def consumer(metadata)
48
+ topics = list(metadata["topics"])
49
+ handled = metadata["jobs_processed"]
50
+ [(["consumes", { topics: topics.join(", ") }] if topics.any?), *pool(metadata), buffered(metadata),
51
+ (["handled", { number: handled.to_i }] if handled)].compact
52
+ end
53
+
54
+ # "3/5 threads busy" from the pool's capacity/busy, or "5 threads" from
55
+ # the registration's thread count when the pool was not reported.
56
+ def pool(metadata)
57
+ unit = metadata["execution_mode"].to_s == "async" ? "slots" : "threads"
58
+ capacity = metadata["capacity"]
59
+ return [["busy_#{unit}", { busy: metadata["busy"].to_i, capacity: capacity.to_i }]] if capacity
60
+
61
+ metadata["threads"] ? [[unit, { count: metadata["threads"].to_i }]] : []
62
+ end
63
+
64
+ def buffered(metadata)
65
+ count = metadata["buffered"].to_i
66
+ ["buffered", { number: count }] if count.positive?
67
+ end
68
+
69
+ def supervisor(children)
70
+ return [["supervises_none", {}]] if children.empty?
71
+
72
+ sorted = children.sort_by { |kind, _| [KIND_ORDER.index(kind) || KIND_ORDER.size, kind] }
73
+ [["supervises", { children: sorted }]]
74
+ end
75
+
76
+ # Each configured limit next to how far the process has come; nil when
77
+ # the process reported no limit.
78
+ def recycle(metadata, uptime_seconds)
79
+ parts = RECYCLE_LIMITS.filter_map do |key, limit_key, value_key, arg|
80
+ limit = metadata[limit_key]
81
+ [key, { arg.to_sym => metadata[value_key]&.to_i, limit: limit.to_i }] if limit
82
+ end
83
+ lifetime = metadata["max_lifetime"]
84
+ parts << ["lifetime", { uptime: uptime_seconds, limit: lifetime.to_i }] if lifetime
85
+ parts.empty? ? nil : parts
86
+ end
87
+
88
+ def list(value)
89
+ case value
90
+ when Array then value.map(&:to_s)
91
+ when String then value.split(",").map(&:strip).reject(&:empty?)
92
+ else []
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Pgbus
7
+ module Web
8
+ # The Processes page's words for one pgbus_processes row (issue #493):
9
+ # why its status is what it is and what happens next.
10
+ #
11
+ # Owns the status derivation too (DataSource#processes calls derive), so
12
+ # the badge and the sentence "loop last ticked 45s ago (limit 90s)" come
13
+ # from the same now and the same limits. present takes the row's status as
14
+ # given, like JobState. Pure: no I/O, no config reads.
15
+ #
16
+ # reason_args/next_args carry raw values: Times under :ago, seconds under
17
+ # :limit, :age and :within. The view helper formats them.
18
+ module ProcessState
19
+ # Kinds that stamp a loop beacon (loop_tick_at) on every iteration.
20
+ LOOP_KINDS = %w[worker consumer dispatcher scheduler].freeze
21
+ # Kinds the supervisor's watchdog SIGKILLs when their beacon stalls.
22
+ WATCHED_KINDS = %w[worker consumer].freeze
23
+
24
+ BADGE_TONES = { "healthy" => :green, "stalled" => :yellow, "stale" => :red }.freeze
25
+
26
+ # Everything derive needs, captured once per request so every row is
27
+ # judged against the same now.
28
+ Thresholds = Data.define(:now, :alive_threshold, :stall_threshold, :dispatch_interval,
29
+ :recurring_schedule_interval, :polling_interval, :notify_fallback_poll,
30
+ :notify_wakeup, :reap_interval)
31
+
32
+ # supervisors: { [hostname, pid] => status } for every supervisor row.
33
+ # children: { [hostname, supervisor_pid] => { kind => count } }.
34
+ # live_dispatcher: some dispatcher row is healthy (the reaper runs in it).
35
+ Context = Data.define(:thresholds, :supervisors, :children, :live_dispatcher)
36
+
37
+ Result = Data.define(:status, :badge_tone, :reason_key, :reason_args, :next_key, :next_args,
38
+ :activity, :recycle, :started_at, :uptime_seconds, :supervisor_pid)
39
+
40
+ module_function
41
+
42
+ def derive(kind:, metadata:, heartbeat_at:, thresholds:)
43
+ return :stale if stale?(heartbeat_at, thresholds)
44
+ return :healthy unless metadata.is_a?(Hash)
45
+
46
+ limit = stall_limit_for(kind, thresholds)
47
+ tick = metadata["loop_tick_at"]
48
+ return :healthy unless limit && tick
49
+
50
+ thresholds.now.to_f - tick.to_f > limit ? :stalled : :healthy
51
+ end
52
+
53
+ def stale?(heartbeat_at, thresholds)
54
+ heartbeat = JobState.time(heartbeat_at)
55
+ heartbeat ? thresholds.now - heartbeat > thresholds.alive_threshold : false
56
+ end
57
+
58
+ # Kind-aware stall limit. Workers run a tight loop, so the base
59
+ # stall_threshold applies directly. The dispatcher and scheduler sleep
60
+ # between iterations, so their beacon is naturally up to one sleep
61
+ # interval old even when healthy. Consumers idle on an empty-read wait
62
+ # that reaches notify_fallback_poll when a live listener drives wake-up,
63
+ # or polling_interval otherwise. Kinds with no loop beacon: nil.
64
+ def stall_limit_for(kind, thresholds)
65
+ base = thresholds.stall_threshold
66
+ return nil unless base&.positive?
67
+
68
+ case kind.to_s
69
+ when "worker" then base
70
+ when "dispatcher" then base + thresholds.dispatch_interval
71
+ when "scheduler" then base + thresholds.recurring_schedule_interval
72
+ when "consumer" then base + consumer_slack(thresholds)
73
+ end
74
+ end
75
+
76
+ def consumer_slack(thresholds)
77
+ return thresholds.polling_interval unless thresholds.notify_wakeup
78
+
79
+ [thresholds.polling_interval, thresholds.notify_fallback_poll].max
80
+ end
81
+
82
+ # The cross-row facts, from the already-derived rows; shared by
83
+ # DataSource and the test stub.
84
+ def context_for(rows, thresholds)
85
+ supervisors = rows.select { |r| r[:kind].to_s == "supervisor" }
86
+ .to_h { |r| [[r[:hostname], r[:pid].to_i], status_of(r)] }
87
+ children = Hash.new { |h, k| h[k] = Hash.new(0) }
88
+ rows.each do |r|
89
+ parent = parse_metadata(r[:metadata])["supervisor_pid"]
90
+ children[[r[:hostname], parent.to_i]][r[:kind].to_s] += 1 if parent && r[:kind].to_s != "supervisor"
91
+ end
92
+ live = rows.any? { |r| r[:kind].to_s == "dispatcher" && status_of(r) == :healthy }
93
+ Context.new(thresholds: thresholds, supervisors: supervisors,
94
+ children: children.transform_values(&:to_h), live_dispatcher: live)
95
+ end
96
+
97
+ # Host by host: each supervisor followed by its children, then the
98
+ # standalone rows, each group in the order given.
99
+ def order(rows, context)
100
+ rows.group_by { |r| r[:hostname].to_s }.sort_by(&:first).flat_map do |_host, host_rows|
101
+ supervisors, others = host_rows.partition { |r| r[:kind].to_s == "supervisor" }
102
+ children = others.group_by { |r| supervisor_pid(r, parse_metadata(r[:metadata]), context) }
103
+ supervisors.flat_map { |s| [s, *children.delete(s[:pid].to_i)] } + children.values.flatten
104
+ end
105
+ end
106
+
107
+ def present(row, context)
108
+ thresholds = context.thresholds
109
+ metadata = parse_metadata(row[:metadata])
110
+ status = status_of(row).to_s
111
+ reason_key, reason_args = reason_for(status, row, metadata, thresholds)
112
+ next_key, next_args = next_for(status, row, metadata, context)
113
+ started_at = JobState.time(metadata["started_at"] && Time.at(metadata["started_at"].to_f))
114
+ uptime = started_at && (thresholds.now - started_at).round
115
+ Result.new(
116
+ status: status, badge_tone: BADGE_TONES.fetch(status, :red),
117
+ reason_key: reason_key, reason_args: reason_args, next_key: next_key, next_args: next_args,
118
+ activity: ProcessActivity.activity(kind: row[:kind].to_s, metadata: metadata,
119
+ children: context.children.fetch([row[:hostname], row[:pid].to_i], {}),
120
+ thresholds: thresholds),
121
+ recycle: ProcessActivity.recycle(metadata, uptime),
122
+ started_at: started_at, uptime_seconds: uptime, supervisor_pid: supervisor_pid(row, metadata, context)
123
+ )
124
+ end
125
+
126
+ def reason_for(status, row, metadata, thresholds)
127
+ heartbeat = JobState.time(row[:last_heartbeat_at])
128
+ alive = thresholds.alive_threshold
129
+ limit = stall_limit_for(row[:kind], thresholds)
130
+ tick = metadata["loop_tick_at"] && Time.at(metadata["loop_tick_at"].to_f)
131
+ case status
132
+ when "stale" then ["stale", { age: heartbeat && (thresholds.now - heartbeat).round, limit: alive }]
133
+ when "stalled" then ["stalled", { ago: tick, limit: limit }]
134
+ else healthy_reason(row[:kind].to_s, heartbeat, tick, limit, alive)
135
+ end
136
+ end
137
+
138
+ def healthy_reason(kind, heartbeat, tick, limit, alive)
139
+ return ["healthy_loop", { ago: tick, limit: limit }] if limit && tick
140
+
141
+ key = LOOP_KINDS.include?(kind) && limit.nil? ? "healthy_detection_off" : "healthy_heartbeat"
142
+ [key, { ago: heartbeat, limit: alive }]
143
+ end
144
+
145
+ def next_for(status, row, metadata, context)
146
+ case status
147
+ when "stale"
148
+ context.live_dispatcher ? ["stale_reaped", { within: context.thresholds.reap_interval }] : ["stale_unreaped", {}]
149
+ when "stalled" then [stalled_next(row, metadata, context), {}]
150
+ else [nil, {}]
151
+ end
152
+ end
153
+
154
+ def stalled_next(row, metadata, context)
155
+ kind = row[:kind].to_s
156
+ return { "dispatcher" => "stalled_maintenance", "scheduler" => "stalled_schedule" }[kind] unless WATCHED_KINDS.include?(kind)
157
+
158
+ parent = supervisor_pid(row, metadata, context)
159
+ parent && context.supervisors[[row[:hostname], parent]] == :healthy ? "stalled_supervised" : "stalled_standalone"
160
+ end
161
+
162
+ # The supervisor's pid, only when that supervisor's row is on the same
163
+ # host: a standalone process's parent is a shell, foreman or systemd.
164
+ def supervisor_pid(row, metadata, context)
165
+ parent = metadata["supervisor_pid"]&.to_i
166
+ parent if parent && context.supervisors.key?([row[:hostname], parent])
167
+ end
168
+
169
+ def status_of(row)
170
+ status = row[:status]
171
+ return status.to_sym if status
172
+
173
+ row[:healthy] ? :healthy : :stale
174
+ end
175
+
176
+ def parse_metadata(metadata)
177
+ case metadata
178
+ when Hash then metadata
179
+ when String then JSON.parse(metadata).then { |h| h.is_a?(Hash) ? h : {} }
180
+ else {}
181
+ end
182
+ rescue JSON::ParserError
183
+ {}
184
+ end
185
+ end
186
+ end
187
+ end