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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +83 -13
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
- data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
- data/app/controllers/pgbus/application_controller.rb +5 -2
- data/app/controllers/pgbus/batches_controller.rb +4 -1
- data/app/controllers/pgbus/dashboard_controller.rb +20 -6
- data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
- data/app/controllers/pgbus/events_controller.rb +52 -3
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/controllers/pgbus/insights_controller.rb +19 -1
- data/app/controllers/pgbus/jobs_controller.rb +45 -26
- data/app/controllers/pgbus/locks_controller.rb +7 -2
- data/app/controllers/pgbus/outbox_controller.rb +4 -1
- data/app/controllers/pgbus/processes_controller.rb +3 -1
- data/app/controllers/pgbus/queues_controller.rb +16 -4
- data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +137 -77
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/helpers/pgbus/application_helper.rb +155 -113
- data/app/helpers/pgbus/button_helper.rb +88 -0
- data/app/helpers/pgbus/dashboard_helper.rb +157 -0
- data/app/helpers/pgbus/events_helper.rb +40 -0
- data/app/helpers/pgbus/insights_helper.rb +39 -0
- data/app/helpers/pgbus/processes_helper.rb +79 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
- data/app/views/pgbus/batches/show.html.erb +3 -4
- data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
- data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
- data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
- data/app/views/pgbus/dashboard/show.html.erb +1 -1
- data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
- data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
- data/app/views/pgbus/dead_letter/index.html.erb +6 -9
- data/app/views/pgbus/dead_letter/show.html.erb +37 -21
- data/app/views/pgbus/events/_list.html.erb +55 -0
- data/app/views/pgbus/events/_processed_table.html.erb +45 -0
- data/app/views/pgbus/events/_replay.html.erb +10 -0
- data/app/views/pgbus/events/_row.html.erb +162 -0
- data/app/views/pgbus/events/_tabs.html.erb +13 -0
- data/app/views/pgbus/events/index.html.erb +27 -48
- data/app/views/pgbus/events/show.html.erb +25 -7
- data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
- data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
- data/app/views/pgbus/insights/_status_table.html.erb +19 -0
- data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
- data/app/views/pgbus/insights/show.html.erb +83 -100
- data/app/views/pgbus/jobs/_list.html.erb +64 -0
- data/app/views/pgbus/jobs/_row.html.erb +107 -0
- data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
- data/app/views/pgbus/jobs/index.html.erb +10 -10
- data/app/views/pgbus/jobs/show.html.erb +18 -20
- data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
- data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
- data/app/views/pgbus/outbox/index.html.erb +30 -27
- data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
- data/app/views/pgbus/processes/_row.html.erb +48 -0
- data/app/views/pgbus/processes/_status.html.erb +4 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
- data/app/views/pgbus/queues/show.html.erb +69 -118
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
- data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
- data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
- data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
- data/app/views/pgbus/shared/_pager.html.erb +34 -0
- data/config/i18n-tasks.yml +11 -2
- data/config/locales/da.yml +405 -113
- data/config/locales/de.yml +405 -113
- data/config/locales/en.yml +405 -113
- data/config/locales/es.yml +404 -112
- data/config/locales/fi.yml +404 -112
- data/config/locales/fr.yml +406 -114
- data/config/locales/it.yml +405 -113
- data/config/locales/ja.yml +405 -113
- data/config/locales/nb.yml +405 -113
- data/config/locales/nl.yml +405 -113
- data/config/locales/pt.yml +406 -114
- data/config/locales/sv.yml +404 -112
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
- data/lib/pgbus/active_job/executor.rb +16 -6
- data/lib/pgbus/cli/dlq.rb +32 -5
- data/lib/pgbus/client.rb +4 -2
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration.rb +51 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/event_bus/handler.rb +18 -11
- data/lib/pgbus/event_bus/registry.rb +6 -0
- data/lib/pgbus/failed_event_recorder.rb +34 -0
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/instrumentation.rb +2 -1
- data/lib/pgbus/mcp/base_tool.rb +25 -0
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
- data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
- data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
- data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
- data/lib/pgbus/outbox/poller.rb +1 -1
- data/lib/pgbus/process/consumer.rb +53 -3
- data/lib/pgbus/process/dispatcher.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +32 -11
- data/lib/pgbus/process/worker.rb +45 -8
- data/lib/pgbus/recurring/scheduler.rb +2 -1
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
- data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
- data/lib/pgbus/web/data_source/event_list.rb +161 -0
- data/lib/pgbus/web/data_source/job_list.rb +323 -0
- data/lib/pgbus/web/data_source/list_counts.rb +80 -0
- data/lib/pgbus/web/data_source/process_list.rb +69 -0
- data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
- data/lib/pgbus/web/data_source.rb +132 -254
- data/lib/pgbus/web/dead_letter_reason.rb +81 -0
- data/lib/pgbus/web/event_state.rb +71 -0
- data/lib/pgbus/web/health_app.rb +7 -3
- data/lib/pgbus/web/health_verdict.rb +78 -0
- data/lib/pgbus/web/insights_charts.rb +84 -0
- data/lib/pgbus/web/job_state.rb +111 -0
- data/lib/pgbus/web/process_activity.rb +97 -0
- data/lib/pgbus/web/process_state.rb +187 -0
- data/lib/pgbus/web/queue_summary.rb +104 -0
- data/lib/pgbus/web/stream_app.rb +18 -6
- data/lib/pgbus/web/streamer/connection.rb +7 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +2 -1
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
- data/lib/pgbus/web/time_format.rb +128 -0
- metadata +55 -4
- data/app/views/pgbus/events/_pending_table.html.erb +0 -169
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
- 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
|
data/lib/pgbus/web/health_app.rb
CHANGED
|
@@ -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)
|
|
110
|
-
#
|
|
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
|
-
|
|
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
|