pgbus 0.19.4 → 0.20.1
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 +9 -0
- data/README.md +97 -7
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +148 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +149 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +445 -0
- data/config/locales/de.yml +445 -0
- data/config/locales/en.yml +445 -0
- data/config/locales/es.yml +445 -0
- data/config/locales/fi.yml +445 -0
- data/config/locales/fr.yml +445 -0
- data/config/locales/it.yml +445 -0
- data/config/locales/ja.yml +445 -0
- data/config/locales/nb.yml +445 -0
- data/config/locales/nl.yml +445 -0
- data/config/locales/pt.yml +445 -0
- data/config/locales/sv.yml +445 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +81 -3
- data/lib/pgbus/active_job/delivery.rb +63 -0
- data/lib/pgbus/active_job/executor.rb +20 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +64 -4
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +33 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +39 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
- data/lib/pgbus/web/data_source.rb +126 -15
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +89 -0
- data/lib/pgbus/web/workflow_timeline.rb +130 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +133 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +94 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +23 -0
- metadata +92 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
# Shapes WorkflowInsightsQueries' rows into the hashes the insights pages
|
|
6
|
+
# and MCP tools read (issue #549). Pure: no I/O.
|
|
7
|
+
#
|
|
8
|
+
# Steps come in the class's declaration order (gravestones included, as
|
|
9
|
+
# kind "removed"), then any step name the rows hold that the class does
|
|
10
|
+
# not declare (declared: false). For a class that no longer loads, the
|
|
11
|
+
# names in the rows, ordered by their mean position in the runs started
|
|
12
|
+
# in the window. Live counts are keyed by the run's status (ready,
|
|
13
|
+
# performing, waiting, paused), so each links to the filtered list.
|
|
14
|
+
module WorkflowInsightsReport
|
|
15
|
+
LIVE = Workflow::States::ONGOING.map(&:to_sym).freeze
|
|
16
|
+
STOPPED = Workflow::States::WORKFLOW.map(&:to_sym).freeze
|
|
17
|
+
OUTCOMES = %i[completed failed reattempted skipped cancelled].freeze
|
|
18
|
+
# Percentiles over fewer samples than this get a "few samples" note.
|
|
19
|
+
FEW_SAMPLES = 20
|
|
20
|
+
# The real-time step that walks a window's buckets: 15 min, the finest
|
|
21
|
+
# granularity of a UTC offset change.
|
|
22
|
+
WALK = 900
|
|
23
|
+
DAY = 86_400
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def summary(name, klass, ongoing_rows, ended_row, attention)
|
|
28
|
+
live = ongoing_rows.sum { |row| row[:runs] }
|
|
29
|
+
e2e = ended_row ? ended_row[:e2e_ms].slice(:p50, :p95, :samples) : { p50: nil, p95: nil, samples: 0 }
|
|
30
|
+
{ workflow_class: name, loaded: !klass.nil?, ongoing: live,
|
|
31
|
+
paused: ongoing_rows.select { |row| row[:status] == "paused" }.sum { |row| row[:runs] },
|
|
32
|
+
attention: attention, finished: ended_row&.fetch(:finished) || 0, cancelled: ended_row&.fetch(:cancelled) || 0,
|
|
33
|
+
e2e_ms: e2e, removed_in_flight: removed_in_flight(klass, ongoing_rows) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def detail(name, klass, window:, since:, now:, ongoing:, ended:, attention:, stats:, histogram:, throughput:, reached:,
|
|
37
|
+
stopped:, revisions:, zone: nil)
|
|
38
|
+
names = step_list(klass, stats, ongoing, reached, stopped)
|
|
39
|
+
summary(name, klass, ongoing, ended, attention).merge(
|
|
40
|
+
window: window.to_h.merge(since: since), bucket_seconds: window.bucket_seconds,
|
|
41
|
+
started: stopped.values.sum, e2e_ms: ended ? ended[:e2e_ms] : empty_percentiles(%i[p50 p95 p99]),
|
|
42
|
+
steps: names.map { |entry| step(entry, stats[entry[:name]], ongoing) },
|
|
43
|
+
duration_histogram: duration_histogram(histogram),
|
|
44
|
+
throughput: throughput_buckets(throughput, since: since, now: now, bucket: window.bucket_seconds, zone: zone),
|
|
45
|
+
funnel: names.map { |entry| funnel_step(entry[:name], reached, stopped) },
|
|
46
|
+
by_revision: by_revision(revisions, names.map { |entry| entry[:name] })
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [{ name:, kind:, removed:, declared: }] in pipeline order.
|
|
51
|
+
def step_list(klass, stats, ongoing, reached, stopped)
|
|
52
|
+
seen = (stats.keys + ongoing.map { |row| row[:step] } + reached.keys + stopped.keys.map(&:first)).compact.uniq
|
|
53
|
+
declared = klass ? klass.workflow_steps.entries.map { |entry| declared_step(entry) } : []
|
|
54
|
+
extra = (seen - declared.map { |entry| entry[:name] }).sort_by do |name|
|
|
55
|
+
[reached.dig(name, :position) || Float::INFINITY, stats.dig(name, :first_seen_at)&.to_f || Float::INFINITY, name]
|
|
56
|
+
end
|
|
57
|
+
declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def declared_step(entry)
|
|
61
|
+
kind = if entry.removed? then "removed"
|
|
62
|
+
elsif entry.kind == "fan_out" then "fan_out"
|
|
63
|
+
elsif entry.waits_for_signal? then "signal"
|
|
64
|
+
else "step"
|
|
65
|
+
end
|
|
66
|
+
{ name: entry.name, kind: kind, removed: entry.removed?, declared: true }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def step(entry, stats, ongoing)
|
|
70
|
+
counts = LIVE.to_h { |status| [status, 0] }
|
|
71
|
+
ongoing.each { |row| counts[row[:status].to_sym] += row[:runs] if row[:step] == entry[:name] }
|
|
72
|
+
stats ||= { attempts: 0, run_ms: empty_percentiles(%i[p50 p95 p99]), pickup_ms: empty_percentiles(%i[p50 p95]) }
|
|
73
|
+
entry.merge(counts: counts, sample_size: stats[:attempts], outcomes: OUTCOMES.to_h { |key| [key, stats[key].to_i] },
|
|
74
|
+
rates: rates(stats), run_ms: stats[:run_ms], pickup_ms: stats[:pickup_ms])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Shares of the attempts: failed (an error was recorded), reattempted,
|
|
78
|
+
# skipped. nil without attempts.
|
|
79
|
+
def rates(stats)
|
|
80
|
+
attempts = stats[:attempts].to_i
|
|
81
|
+
%i[failed reattempted skipped].to_h do |key|
|
|
82
|
+
[key, attempts.zero? ? nil : (stats[key].to_f / attempts).round(4)]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def empty_percentiles(keys) = keys.to_h { |key| [key, nil] }.merge(samples: 0)
|
|
87
|
+
|
|
88
|
+
# Gravestones some ongoing run still sits on: { "reminder" => 3 }.
|
|
89
|
+
def removed_in_flight(klass, ongoing_rows)
|
|
90
|
+
return {} unless klass
|
|
91
|
+
|
|
92
|
+
names = klass.workflow_steps.removed_names
|
|
93
|
+
ongoing_rows.select { |row| names.include?(row[:step]) }
|
|
94
|
+
.each_with_object(Hash.new(0)) { |row, counts| counts[row[:step]] += row[:runs] }.to_h
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def duration_histogram(histogram)
|
|
98
|
+
bounds = WorkflowInsightsQueries::DURATION_BOUNDS_MS
|
|
99
|
+
(0..bounds.size).map do |bin|
|
|
100
|
+
{ from_ms: bin.zero? ? 0 : bounds[bin - 1], to_ms: bounds[bin], count: histogram.fetch(bin, 0) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Every bucket of the window, oldest first, empty ones as zeros. +from+
|
|
105
|
+
# and +to+ are the real times its wall-clock start and the next
|
|
106
|
+
# bucket's fall on in +zone+ (a day bucket across a DST change is 23 or
|
|
107
|
+
# 25 hours long; a fall-back hour spans two real hours).
|
|
108
|
+
def throughput_buckets(counts, since:, now:, bucket:, zone: nil)
|
|
109
|
+
zone ||= utc_zone
|
|
110
|
+
bucket_starts(since: since, now: now, bucket: bucket, zone: zone).map do |start|
|
|
111
|
+
started, finished, cancelled = counts.fetch(start, [0, 0, 0])
|
|
112
|
+
{ from: from_wall(start, zone), to: from_wall(start + bucket, zone), started: started, finished: finished,
|
|
113
|
+
cancelled: cancelled }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The wall-clock epoch seconds (in +zone+) each bucket from +since+ to
|
|
118
|
+
# +now+ starts at: what WorkflowInsightsQueries.throughput keys by.
|
|
119
|
+
# Walked in real time (steps of at most WALK seconds, finer than any DST
|
|
120
|
+
# change), so a spring-forward night has no bucket for the hour it
|
|
121
|
+
# skips and a fall-back night's repeated hour is one bucket.
|
|
122
|
+
def bucket_starts(since:, now:, bucket:, zone: nil)
|
|
123
|
+
zone ||= utc_zone
|
|
124
|
+
moments = (since.to_i..now.to_i).step([bucket, WALK].min).map { |seconds| Time.at(seconds) } << now
|
|
125
|
+
moments.map { |moment| wall_epoch(moment, zone) / bucket * bucket }.uniq.sort
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# +time+'s wall clock in +zone+, as epoch seconds.
|
|
129
|
+
def wall_epoch(time, zone)
|
|
130
|
+
local = time.in_time_zone(zone)
|
|
131
|
+
local.to_i + local.utc_offset
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The real (UTC) time a bucket's wall-clock start falls on in +zone+:
|
|
135
|
+
# the first of the two in a repeated hour, or, when a DST change skips
|
|
136
|
+
# the start itself (a midnight that jumps to 01:00), the first moment
|
|
137
|
+
# of the bucket that exists.
|
|
138
|
+
def from_wall(wall, zone)
|
|
139
|
+
(0...DAY).step(WALK) do |later|
|
|
140
|
+
return zone.tzinfo.local_to_utc(Time.at(wall + later).utc, true).utc
|
|
141
|
+
rescue TZInfo::PeriodNotFound
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
Time.at(wall).utc
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def utc_zone = ActiveSupport::TimeZone["UTC"]
|
|
148
|
+
|
|
149
|
+
def funnel_step(name, reached, stopped)
|
|
150
|
+
{ name: name, reached: reached.dig(name, :reached) || 0,
|
|
151
|
+
stopped: STOPPED.to_h { |status| [status, stopped.fetch([name, status.to_s], 0)] } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# One entry per revision (newest first): its totals and its steps in
|
|
155
|
+
# pipeline order.
|
|
156
|
+
def by_revision(rows, order)
|
|
157
|
+
rows.group_by { |row| row[:revision] }.map do |revision, group|
|
|
158
|
+
total = group.find { |row| row[:step].nil? }
|
|
159
|
+
steps = group.select { |row| row[:step] }.sort_by { |row| order.index(row[:step]) || order.size }
|
|
160
|
+
{ revision: revision, first_seen_at: total[:first_seen_at], last_seen_at: total[:last_seen_at],
|
|
161
|
+
**revision_numbers(total[:stats]),
|
|
162
|
+
steps: steps.map { |row| { name: row[:step], **revision_numbers(row[:stats]) } } }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def revision_numbers(stats)
|
|
167
|
+
{ attempts: stats[:attempts], rates: rates(stats), run_ms: stats[:run_ms].slice(:p50, :p95, :samples) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
# Workflow arguments, step outputs and signal payloads as the Workflows
|
|
6
|
+
# dashboard and its MCP tools show them (issue #542). They are stored
|
|
7
|
+
# through ActiveJob::Arguments; this reads that stored form without
|
|
8
|
+
# deserializing it, so showing a run never loads its records (a GlobalID
|
|
9
|
+
# shows as its URI) and never runs a custom serializer.
|
|
10
|
+
module WorkflowPayload
|
|
11
|
+
BOOKKEEPING = %w[_aj_symbol_keys _aj_ruby2_keywords _aj_hash_with_indifferent_access].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def display(value)
|
|
16
|
+
case value
|
|
17
|
+
when nil then {}
|
|
18
|
+
when Hash then display_hash(value)
|
|
19
|
+
when Array then value.map { |element| element.nil? ? nil : display(element) }
|
|
20
|
+
else value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def display_hash(hash)
|
|
25
|
+
return hash["_aj_globalid"] if hash.size == 1 && hash.key?("_aj_globalid")
|
|
26
|
+
if hash.key?("_aj_serialized")
|
|
27
|
+
# A serializer that stores one "value" shows it; one that stores
|
|
28
|
+
# several fields (a Range: begin, end, exclude_end) shows those.
|
|
29
|
+
return hash.key?("value") ? display(hash["value"]) : display_hash(hash.except("_aj_serialized"))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
hash.except(*BOOKKEEPING).transform_values { |nested| nested.nil? ? nil : display(nested) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_support/core_ext/object/blank"
|
|
6
|
+
require "active_support/core_ext/time"
|
|
7
|
+
|
|
8
|
+
module Pgbus
|
|
9
|
+
module Web
|
|
10
|
+
# The Workflows list's query string (issue #542), checked: a status from
|
|
11
|
+
# DataSource::WorkflowList::STATUSES, the text filters stripped (blank
|
|
12
|
+
# ones dropped, each at most MAX_LENGTH characters) and the created-at
|
|
13
|
+
# and ended-at (issue #549: finished_at or cancelled_at) ranges parsed in
|
|
14
|
+
# Time.zone (a bare date as the start or the end of that day). Anything
|
|
15
|
+
# else raises Invalid, which the controller answers with 400. #to_param
|
|
16
|
+
# is the same query back, for links that keep it.
|
|
17
|
+
class WorkflowQuery
|
|
18
|
+
class Invalid < StandardError; end
|
|
19
|
+
|
|
20
|
+
TEXT_FILTERS = %i[workflow_class step error_class subject_type subject_id workflow_id].freeze
|
|
21
|
+
DATE_FILTERS = %i[created_from created_to ended_from ended_to].freeze
|
|
22
|
+
MAX_LENGTH = 255
|
|
23
|
+
DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
|
|
24
|
+
# A date (what <input type="date"> sends) or an ISO 8601 date and time.
|
|
25
|
+
DATE_OR_TIME = /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
26
|
+
|
|
27
|
+
attr_reader :status, :filters, :raw
|
|
28
|
+
|
|
29
|
+
def initialize(params)
|
|
30
|
+
@status = (params[:status].presence || "ongoing").to_s
|
|
31
|
+
raise Invalid, "unknown status #{@status.inspect}" unless DataSource::WorkflowList::STATUSES.include?(@status)
|
|
32
|
+
|
|
33
|
+
@raw = {}
|
|
34
|
+
@filters = {}
|
|
35
|
+
TEXT_FILTERS.each { |key| text(key, params[key]) }
|
|
36
|
+
DATE_FILTERS.each { |key| date(key, params[key]) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def filtered? = @raw.any?
|
|
40
|
+
|
|
41
|
+
# The query as URL params (status and the filters as given), merged with +extra+.
|
|
42
|
+
def to_param(**extra) = { status: status, **@raw }.merge(extra).compact
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def text(key, value)
|
|
47
|
+
value = scalar(key, value)
|
|
48
|
+
return if value.empty?
|
|
49
|
+
raise Invalid, "#{key} is longer than #{MAX_LENGTH} characters" if value.length > MAX_LENGTH
|
|
50
|
+
|
|
51
|
+
@raw[key] = value
|
|
52
|
+
@filters[key] = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def date(key, value)
|
|
56
|
+
value = scalar(key, value)
|
|
57
|
+
return if value.empty?
|
|
58
|
+
|
|
59
|
+
time = parse_time(value) || raise(Invalid, "#{key} #{value.inspect} is not a date")
|
|
60
|
+
time = time.end_of_day if key.end_with?("_to") && value.match?(DATE_ONLY)
|
|
61
|
+
@raw[key] = value
|
|
62
|
+
@filters[key] = time
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scalar(key, value)
|
|
66
|
+
return "" if value.nil?
|
|
67
|
+
raise Invalid, "#{key} must be a single value" unless value.is_a?(String)
|
|
68
|
+
|
|
69
|
+
value.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Date.valid_date? because Time.zone.parse rolls 2026-02-30 over to March.
|
|
73
|
+
def parse_time(value)
|
|
74
|
+
return nil unless value.match?(DATE_OR_TIME) && Date.valid_date?(*value[0, 10].split("-").map(&:to_i))
|
|
75
|
+
|
|
76
|
+
Time.zone ? Time.zone.parse(value) : Time.parse(value)
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
# The Workflows dashboard's words for one run (issue #542): the badge
|
|
6
|
+
# (its status, or "scheduled" for a ready run whose step waits for a
|
|
7
|
+
# time still ahead) and what it is doing and what happens next, as a
|
|
8
|
+
# pgbus.workflows.next.<key>_html key and its arguments. Arguments named
|
|
9
|
+
# :step, :signal and :batch are names, :ago and :late past moments and
|
|
10
|
+
# :time a future one; the helper renders each. Pure.
|
|
11
|
+
module WorkflowState
|
|
12
|
+
BADGE_TONES = { "ready" => :gray, "scheduled" => :gray, "performing" => :blue, "waiting" => :yellow,
|
|
13
|
+
"paused" => :yellow, "finished" => :green, "cancelled" => :red }.freeze
|
|
14
|
+
|
|
15
|
+
Result = Data.define(:badge, :badge_tone, :next_key, :next_args)
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# row – a DataSource#workflows row. threshold – how long a due step may
|
|
20
|
+
# wait for a worker before it counts as overdue (workflow_stall_threshold).
|
|
21
|
+
def present(row, threshold:, now: Time.current)
|
|
22
|
+
badge, key, args = derive(row, threshold, now)
|
|
23
|
+
Result.new(badge: badge, badge_tone: BADGE_TONES.fetch(badge, :gray), next_key: key, next_args: args)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def derive(row, threshold, now)
|
|
27
|
+
step = row[:current_step]
|
|
28
|
+
case row[:status]
|
|
29
|
+
when "performing" then performing(row, step)
|
|
30
|
+
when "waiting" then waiting(row, step)
|
|
31
|
+
when "paused" then paused(row, step)
|
|
32
|
+
when "ready" then ready(row, step, threshold, now)
|
|
33
|
+
when "finished" then ["finished", "finished", { ago: row[:finished_at] || row[:updated_at] }]
|
|
34
|
+
when "cancelled" then ["cancelled", "cancelled", { ago: row[:cancelled_at] || row[:updated_at] }]
|
|
35
|
+
else [row[:status].to_s, "unknown", { status: row[:status].to_s }]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def performing(row, step)
|
|
40
|
+
attempt = row[:current_attempt].to_i
|
|
41
|
+
return ["performing", "performing", { step: step }] if attempt <= 1
|
|
42
|
+
|
|
43
|
+
["performing", "performing_attempt", { step: step, attempt: attempt }]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def waiting(row, step)
|
|
47
|
+
if row[:batch_id]
|
|
48
|
+
progress = row[:batch_progress] || {}
|
|
49
|
+
return ["waiting", "waiting_batch", { batch: batch_name(row), done: progress[:done].to_i, total: progress[:total].to_i }]
|
|
50
|
+
end
|
|
51
|
+
return ["waiting", "waiting_step", { step: step }] unless row[:waiting_for]
|
|
52
|
+
|
|
53
|
+
args = { signal: row[:waiting_for], ago: row[:waiting_since] }
|
|
54
|
+
return ["waiting", "waiting_signal", args] unless row[:timeout_at]
|
|
55
|
+
|
|
56
|
+
signal_deadline(row, args.merge(time: row[:timeout_at]))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An on_timeout: :notify deadline sends a late notice and the wait goes
|
|
60
|
+
# on (issue #565); late_since is the first deadline the wait missed.
|
|
61
|
+
def signal_deadline(row, args)
|
|
62
|
+
# A deploy that changed on_timeout since the last notice: the next
|
|
63
|
+
# deadline does something else (nil: the class is not loaded here).
|
|
64
|
+
late = row[:late_since] && [nil, "notify"].include?(row[:on_timeout]&.to_s)
|
|
65
|
+
return ["waiting", "waiting_signal_late", args.merge(late: row[:late_since])] if late
|
|
66
|
+
|
|
67
|
+
["waiting", row[:on_timeout].to_s == "notify" ? "waiting_signal_notify" : "waiting_signal_timeout", args]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def paused(row, step)
|
|
71
|
+
return ["paused", "paused_error", { step: step, error: row[:error_class] }] if row[:error_class]
|
|
72
|
+
|
|
73
|
+
["paused", "paused_manual", { step: step }]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ready(row, step, threshold, now)
|
|
77
|
+
due = JobState.time(row[:scheduled_at])
|
|
78
|
+
return ["ready", "ready", { step: step }] unless due
|
|
79
|
+
return ["scheduled", "scheduled", { step: step, time: due }] if due > now
|
|
80
|
+
return ["ready", "overdue", { step: step, ago: due }] if threshold && due < now - threshold
|
|
81
|
+
|
|
82
|
+
["ready", "due", { step: step, ago: due }]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The fan-out batch as its description names it: "Class#step".
|
|
86
|
+
def batch_name(row) = "#{row[:workflow_class]}##{row[:current_step]}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
# Where a workflow run's time went (issue #542), from its step rows and
|
|
6
|
+
# audit trail. Each attempt splits into
|
|
7
|
+
#
|
|
8
|
+
# waited created_at → scheduled_at the step's wait: (or a reattempt's)
|
|
9
|
+
# queued scheduled_at → started_at due, not yet claimed by a worker
|
|
10
|
+
# ran started_at → finished_at the body (up to now while in progress)
|
|
11
|
+
# waiting waiting_since → … parked on a signal or its fan-out batch
|
|
12
|
+
#
|
|
13
|
+
# and a paused run's pauses (paused → resumed, from the events) are their
|
|
14
|
+
# own segments, so a long gap explains itself; each wait_timed_out event
|
|
15
|
+
# is a marker. The show page's step table prints durations; the
|
|
16
|
+
# range-bar chart (charts.js renderTimeline) draws chart. Pure: rows in,
|
|
17
|
+
# hashes out.
|
|
18
|
+
module WorkflowTimeline
|
|
19
|
+
OPEN = %w[scheduled waiting in_progress].freeze
|
|
20
|
+
PAUSE_ENDS = %w[resumed cancelled finished].freeze
|
|
21
|
+
PAUSE_EVENTS = (%w[paused] + PAUSE_ENDS).freeze
|
|
22
|
+
WAIT_TIMED_OUT = "wait_timed_out"
|
|
23
|
+
RAN_SERIES = { "completed" => "ran_ok", "skipped" => "ran_ok", "failed" => "ran_failed",
|
|
24
|
+
"in_progress" => "ran_open", "waiting" => "ran_open" }.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# [{ kind:, from:, to: }] in time order. +step+ is a step hash (the
|
|
29
|
+
# data source's) with Time values.
|
|
30
|
+
def segments(step, now:)
|
|
31
|
+
created, scheduled, started, since = step.values_at(:created_at, :scheduled_at, :started_at, :waiting_since)
|
|
32
|
+
ending = step[:finished_at] || (OPEN.include?(step[:status].to_s) ? now : nil)
|
|
33
|
+
pieces = if since && scheduled && since < scheduled
|
|
34
|
+
# A signal step woken by its signal: the row was scheduled
|
|
35
|
+
# again at the wake and claimed a second time.
|
|
36
|
+
[[:waited, created, since], [:waiting, since, scheduled], [:queued, scheduled, started],
|
|
37
|
+
[:ran, started, ending]]
|
|
38
|
+
elsif since
|
|
39
|
+
[[:waited, created, scheduled], [:queued, scheduled, started], [:ran, started, since],
|
|
40
|
+
[:waiting, since, ending]]
|
|
41
|
+
else
|
|
42
|
+
[[:waited, created, scheduled], [:queued, scheduled, started || ending], [:ran, started, ending]]
|
|
43
|
+
end
|
|
44
|
+
clip(pieces, step[:finished_at])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# { waited:, queued:, ran:, waiting: } in seconds; nil for a kind the
|
|
48
|
+
# attempt never reached.
|
|
49
|
+
def durations(step, now:)
|
|
50
|
+
totals = segments(step, now: now).group_by { |segment| segment[:kind] }
|
|
51
|
+
%i[waited queued ran waiting].to_h do |kind|
|
|
52
|
+
pieces = totals[kind]
|
|
53
|
+
[kind, pieces&.sum { |segment| (segment[:to] - segment[:from]).to_f }]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# [{ from:, to: }]: each "paused" event up to the event that ended it,
|
|
58
|
+
# oldest first; a pause still open runs to now. +events+ in any order.
|
|
59
|
+
def paused_periods(events, status:, now:)
|
|
60
|
+
periods = []
|
|
61
|
+
opened = nil
|
|
62
|
+
events.sort_by { |event| event[:created_at] }.each do |event|
|
|
63
|
+
if event[:event] == "paused"
|
|
64
|
+
opened ||= event[:created_at]
|
|
65
|
+
elsif opened && PAUSE_ENDS.include?(event[:event])
|
|
66
|
+
periods << { from: opened, to: event[:created_at] }
|
|
67
|
+
opened = nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
periods << { from: opened, to: now } if opened && status.to_s == "paused"
|
|
71
|
+
periods
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The chart payload: one row per attempt (oldest first), the paused
|
|
75
|
+
# periods and the wait_timed_out marks (wait_timeouts). Times are epoch milliseconds; each segment carries its
|
|
76
|
+
# series (its colour: ran is coloured by how the attempt ended) and its
|
|
77
|
+
# duration in words for the tooltip.
|
|
78
|
+
def chart(steps, events, status:, now:)
|
|
79
|
+
rows = steps.sort_by { |step| [step[:created_at], step[:attempt].to_i] }.map do |step|
|
|
80
|
+
{ label: "#{step[:step_name]} ##{step[:attempt]}", status: step[:status], outcome: step[:outcome],
|
|
81
|
+
error_class: step[:error_class],
|
|
82
|
+
segments: segments(step, now: now).map { |segment| chart_segment(segment, series_for(segment[:kind], step)) } }
|
|
83
|
+
end
|
|
84
|
+
paused = paused_periods(events, status: status, now: now).map do |period|
|
|
85
|
+
chart_segment(period.merge(kind: :paused), "paused")
|
|
86
|
+
end
|
|
87
|
+
{ rows: rows, paused: paused, wait_timeouts: wait_timeouts(events) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# [{ at:, step:, action: }] per wait_timed_out event, oldest first, at
|
|
91
|
+
# the deadline it reports (when it went out, without one): the chart
|
|
92
|
+
# draws each as a line across the rows, the page lists them. An
|
|
93
|
+
# on_timeout: :notify wait has one per late notice (issue #565).
|
|
94
|
+
def wait_timeouts(events)
|
|
95
|
+
timed_out = events.select { |event| event[:event] == WAIT_TIMED_OUT }.sort_by { |event| event[:created_at] }
|
|
96
|
+
timed_out.map do |event|
|
|
97
|
+
details = event[:details].to_h
|
|
98
|
+
{ at: millis(deadline(details["timeout_at"]) || event[:created_at]),
|
|
99
|
+
step: "#{event[:step_name]} ##{event[:attempt]}", action: details["action"] }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def series_for(kind, step)
|
|
104
|
+
return kind.to_s unless kind == :ran
|
|
105
|
+
|
|
106
|
+
RAN_SERIES.fetch(step[:status].to_s, "ran_cancelled")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def chart_segment(segment, series)
|
|
110
|
+
{ kind: segment[:kind].to_s, series: series, from: millis(segment[:from]), to: millis(segment[:to]),
|
|
111
|
+
duration: TimeFormat.duration(segment[:to] - segment[:from]) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def millis(time) = (time.to_f * 1000).round
|
|
115
|
+
|
|
116
|
+
def deadline(iso) = iso && Time.iso8601(iso)
|
|
117
|
+
|
|
118
|
+
# Drops a piece without both ends or of no length, and ends every piece
|
|
119
|
+
# at the attempt's finished_at (a step cancelled before its scheduled_at).
|
|
120
|
+
def clip(pieces, finished)
|
|
121
|
+
pieces.filter_map do |kind, from, to|
|
|
122
|
+
next unless from && to
|
|
123
|
+
|
|
124
|
+
to = [to, finished].min if finished
|
|
125
|
+
{ kind: kind, from: from, to: to } if to > from
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "active_support/core_ext/time"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
module Web
|
|
8
|
+
# The window the workflow insights read (issue #549): 1 h, 24 h (the
|
|
9
|
+
# default), 7 d or 30 d, never longer than config.workflow_retention
|
|
10
|
+
# (finished and cancelled runs older than that are gone, so a longer
|
|
11
|
+
# window would undercount). Each length has its chart bucket, at most 30
|
|
12
|
+
# per window: 5 min, 1 h, 6 h, 1 d; a capped window takes the bucket of
|
|
13
|
+
# the shortest choice that covers it. Anything else raises Invalid (400
|
|
14
|
+
# on the page, an error on the MCP tool).
|
|
15
|
+
class WorkflowWindow
|
|
16
|
+
class Invalid < StandardError; end
|
|
17
|
+
|
|
18
|
+
# name => [seconds, bucket seconds]
|
|
19
|
+
CHOICES = Ractor.make_shareable({ "1h" => [3_600, 300], "24h" => [86_400, 3_600], "7d" => [604_800, 21_600],
|
|
20
|
+
"30d" => [2_592_000, 86_400] })
|
|
21
|
+
DEFAULT = "24h"
|
|
22
|
+
|
|
23
|
+
attr_reader :name, :seconds, :bucket_seconds, :now
|
|
24
|
+
|
|
25
|
+
# +now+ is fixed here, so every read and every link of one page share
|
|
26
|
+
# one window end.
|
|
27
|
+
def self.parse(value, retention: Pgbus.configuration.workflow_retention, now: Time.current)
|
|
28
|
+
name = value.nil? || value == "" ? DEFAULT : value
|
|
29
|
+
raise Invalid, "unknown window #{value.inspect} (one of #{CHOICES.keys.join(", ")})" unless name.is_a?(String) && CHOICES.key?(name)
|
|
30
|
+
|
|
31
|
+
new(name, retention: retention, now: now)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(name, retention:, now: Time.current)
|
|
35
|
+
@name = name
|
|
36
|
+
@now = now
|
|
37
|
+
requested = CHOICES.fetch(name).first
|
|
38
|
+
@seconds = retention ? [requested, retention.to_i].min : requested
|
|
39
|
+
@capped = @seconds < requested
|
|
40
|
+
@bucket_seconds = CHOICES.values.find { |length, _| length >= @seconds }.last
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capped? = @capped
|
|
45
|
+
|
|
46
|
+
def since(reference = now) = reference - seconds
|
|
47
|
+
|
|
48
|
+
def to_h = { name: name, seconds: seconds, bucket_seconds: bucket_seconds, capped: capped? }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Workflow
|
|
7
|
+
# The on_complete callback of a fan_out step's batch (issue #541):
|
|
8
|
+
# perform(step_id) resumes the run once every member finished (FanOut).
|
|
9
|
+
# Sent on the step's StepJob queue (config.workflow_queue unless the
|
|
10
|
+
# class or step says otherwise). A callback is never a member of the
|
|
11
|
+
# batch it reports on, and the StepJob it leads to is sent outside any
|
|
12
|
+
# Pgbus.batch block, so neither joins a batch.
|
|
13
|
+
class BatchFinishedJob < ::ActiveJob::Base
|
|
14
|
+
queue_as { Pgbus.configuration.workflow_queue }
|
|
15
|
+
|
|
16
|
+
def perform(step_id)
|
|
17
|
+
FanOut.finished(step_id)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|