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,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# One StepJob delivery: claim the step row, check the guards, run the
|
|
6
|
+
# body in a fresh workflow instance, apply the outcome.
|
|
7
|
+
#
|
|
8
|
+
# 1. Claim, under the run's row lock: scheduled → in_progress (only once
|
|
9
|
+
# due) and the run ready → performing. A row that is not claimable
|
|
10
|
+
# (already running elsewhere, finished, early, or its run is paused or
|
|
11
|
+
# gone) makes this delivery a no-op, except a redelivery of the very
|
|
12
|
+
# message that claimed an in_progress row while the visibility
|
|
13
|
+
# heartbeat is on: its worker died, and the stall action applies now.
|
|
14
|
+
# 2. Guards, still under the lock: the class or the step no longer
|
|
15
|
+
# defined (pause with StepNotDefined, or skip a removed_step),
|
|
16
|
+
# cancel_if, skip_if.
|
|
17
|
+
# 3. The body, outside any transaction, inside `catch` for the
|
|
18
|
+
# flow-control verbs and `rescue StandardError` for the policies.
|
|
19
|
+
# 4. The outcome, under the lock again (StepOutcome).
|
|
20
|
+
#
|
|
21
|
+
# Whatever the step's outcome, the job itself completes normally: the
|
|
22
|
+
# step row and the pgbus.workflow.* events carry the truth.
|
|
23
|
+
class StepExecution
|
|
24
|
+
Claim = Data.define(:record, :step, :klass, :definition, :workflow, :guard_error)
|
|
25
|
+
|
|
26
|
+
def initialize(step_id, delivery: Pgbus::ActiveJob::Delivery.current, config: Pgbus.configuration)
|
|
27
|
+
@step_id = step_id
|
|
28
|
+
@delivery = delivery
|
|
29
|
+
@config = config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
step = WorkflowStep.find_by(id: @step_id)
|
|
34
|
+
return debug("step #{@step_id} no longer exists (its run was deleted)") unless step
|
|
35
|
+
|
|
36
|
+
StepContext.with(workflow_id: step.workflow_id, workflow_class: step.workflow_class, step_name: step.step_name,
|
|
37
|
+
attempt: step.attempt) do
|
|
38
|
+
claim = claim(step)
|
|
39
|
+
run(claim) if claim
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :config
|
|
47
|
+
|
|
48
|
+
# The worker tag and the app revision (which may call the app's
|
|
49
|
+
# app_revision callable or read a file) are resolved before the run's
|
|
50
|
+
# row lock is taken.
|
|
51
|
+
def claim(step)
|
|
52
|
+
@stamp = { worker: Runner.worker_tag, app_revision: Deploy::Revision.current(config) }
|
|
53
|
+
Runner.locked(step.workflow_id) do |record, notes|
|
|
54
|
+
step = WorkflowStep.find_by(id: step.id) if record
|
|
55
|
+
if record.nil? || step.nil?
|
|
56
|
+
debug("step #{@step_id}'s run no longer exists")
|
|
57
|
+
elsif step.status == "in_progress"
|
|
58
|
+
check_redelivery(record, step, notes)
|
|
59
|
+
elsif claimable?(record, step)
|
|
60
|
+
claim_step(record, step, notes)
|
|
61
|
+
else
|
|
62
|
+
debug("step #{step.id} is not claimable (step #{step.status}, run #{record.status}, due #{step.scheduled_at})")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def claimable?(record, step)
|
|
68
|
+
step.status == "scheduled" && States.legal?(:claim, record.status) && step.scheduled_at <= Time.current + 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A live worker keeps its message invisible (VisibilityHeartbeat), so
|
|
72
|
+
# the same message coming back for a row it claimed means that worker
|
|
73
|
+
# is gone. Without the heartbeat a long live step can outlive its
|
|
74
|
+
# visibility timeout, so the sweep's stall_at decides instead.
|
|
75
|
+
def check_redelivery(record, step, notes)
|
|
76
|
+
msg_id = @delivery && @delivery[:msg_id]
|
|
77
|
+
redelivered = !msg_id.nil? && step.msg_id == msg_id.to_i && @delivery[:read_ct].to_i > 1 && config.visibility_heartbeat
|
|
78
|
+
return debug("step #{step.id} is already in progress elsewhere") unless redelivered
|
|
79
|
+
|
|
80
|
+
StepOutcome.stall!(record, step, detection: "redelivery", notes: notes, config: config)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def claim_step(record, step, notes)
|
|
85
|
+
now = Time.current
|
|
86
|
+
klass = record.workflow_klass
|
|
87
|
+
definition = klass&.workflow_steps&.fetch(step.step_name)
|
|
88
|
+
attrs = { stall_at: now + (definition&.timeout || config.workflow_stall_threshold), msg_id: @delivery&.dig(:msg_id),
|
|
89
|
+
**@stamp }
|
|
90
|
+
return nil if WorkflowStep.claim!(step.id, now: now, **attrs).zero?
|
|
91
|
+
|
|
92
|
+
step.assign_attributes(status: "in_progress", started_at: now, **attrs)
|
|
93
|
+
Runner.transition!(record, :claim, current_step: step.step_name)
|
|
94
|
+
Runner.event!(record, "step_started", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
95
|
+
details: attrs.slice(:worker, :app_revision, :msg_id).compact)
|
|
96
|
+
guard(record, step, klass, definition, notes)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the Claim to run, or nil when a guard already ended the
|
|
100
|
+
# attempt. A guard that raises is handed to the exception policies,
|
|
101
|
+
# like a body that raises.
|
|
102
|
+
def guard(record, step, klass, definition, notes)
|
|
103
|
+
return undefined!(record, step, notes, "#{record.workflow_class} is not a Pgbus::Workflow class any more") unless klass
|
|
104
|
+
|
|
105
|
+
unless definition
|
|
106
|
+
return skip!(record, step, notes, klass) if klass.workflow_steps.removed?(step.step_name)
|
|
107
|
+
|
|
108
|
+
return undefined!(record, step, notes, "#{klass.name} has no step #{step.step_name} " \
|
|
109
|
+
"(declare `removed_step :#{step.step_name}` to skip it)")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
workflow = klass.new(record)
|
|
113
|
+
workflow.send(:enter_step, StepContext::Attempt.new(step_id: step.id, step_name: step.step_name, attempt: step.attempt))
|
|
114
|
+
claim = Claim.new(record: record, step: step, klass: klass, definition: definition, workflow: workflow, guard_error: nil)
|
|
115
|
+
begin
|
|
116
|
+
verdict = workflow.send(:guarding) do
|
|
117
|
+
next FlowControl::Outcome.cancel if cancel?(klass, workflow)
|
|
118
|
+
|
|
119
|
+
FlowControl::Outcome.skip if definition.skip?(workflow)
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
return claim.with(guard_error: e)
|
|
123
|
+
end
|
|
124
|
+
return outcome!(record, step, notes, klass, verdict) if verdict
|
|
125
|
+
|
|
126
|
+
gate(record, step, definition, workflow, claim)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A wait_for: step runs only with a signal (or once its wait timed
|
|
130
|
+
# out); without one it parks and this delivery ends. A matcher that
|
|
131
|
+
# raises is handed to the exception policies, like a guard.
|
|
132
|
+
def gate(record, step, definition, workflow, claim)
|
|
133
|
+
return claim unless definition.waits_for_signal?
|
|
134
|
+
|
|
135
|
+
Signals.gate(record, step, definition, workflow) ? claim : nil
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
claim.with(guard_error: e)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def cancel?(klass, workflow)
|
|
141
|
+
klass.cancel_conditions.any? do |condition|
|
|
142
|
+
condition.is_a?(Symbol) ? workflow.send(condition) : workflow.instance_exec(&condition)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def undefined!(record, step, notes, message)
|
|
147
|
+
StepOutcome.apply(record, step, FlowControl::Outcome.pause, notes: notes, error: StepNotDefined.new(message), klass: nil)
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def skip!(record, step, notes, klass) = outcome!(record, step, notes, klass, FlowControl::Outcome.skip)
|
|
152
|
+
|
|
153
|
+
def outcome!(record, step, notes, klass, outcome)
|
|
154
|
+
StepOutcome.apply(record, step, outcome, notes: notes, klass: klass, duration: 0.0)
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def run(claim)
|
|
159
|
+
started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
160
|
+
outcome, error = claim.guard_error ? [nil, claim.guard_error] : execute(claim)
|
|
161
|
+
duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started
|
|
162
|
+
finish(claim, outcome, error, duration)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def execute(claim)
|
|
166
|
+
outcome = catch(FlowControl::THROW_TAG) do
|
|
167
|
+
claim.definition.invoke(claim.workflow)
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
[outcome || FlowControl::Outcome.complete, nil]
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
[nil, e]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The exception policy is decided before the run's lock is taken
|
|
176
|
+
# again: an imperative on_exception block is app code, and must not
|
|
177
|
+
# hold the row lock (and an open transaction) while it runs.
|
|
178
|
+
def finish(claim, outcome, error, duration)
|
|
179
|
+
return finish_late(claim, error) if error && !attempt_active?(claim)
|
|
180
|
+
|
|
181
|
+
decision = error && decide(claim, error)
|
|
182
|
+
outcome = decision.outcome if decision
|
|
183
|
+
Runner.locked(claim.record.workflow_id) do |record, notes|
|
|
184
|
+
next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
|
|
185
|
+
next park_fan_out(record, claim, outcome, notes) if outcome&.action == :fan_out
|
|
186
|
+
|
|
187
|
+
notes.report(decision.handler_error, handler_context(claim)) if decision&.handler_error
|
|
188
|
+
StepOutcome.apply(record, claim.step, outcome, notes: notes, error: error, output: claim.workflow.pending_output,
|
|
189
|
+
report: decision ? decision.report : true, duration: duration,
|
|
190
|
+
klass: claim.klass)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def park_fan_out(record, claim, outcome, notes)
|
|
195
|
+
FanOut.park(record, claim.step, outcome, notes: notes, output: claim.workflow.pending_output)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# While its body runs, a row leaves in_progress only for a terminal
|
|
199
|
+
# status (a parked row went waiting before any body ran), so an attempt
|
|
200
|
+
# that finds it gone has been declared stalled (or its run cancelled): late.
|
|
201
|
+
def attempt_active?(claim) = WorkflowStep.where(id: claim.step.id, status: "in_progress").exists?
|
|
202
|
+
|
|
203
|
+
# A late attempt that raised: its outcome is ignored either way, so an
|
|
204
|
+
# imperative on_exception block must not run (and cause side effects)
|
|
205
|
+
# for it. A declarative policy still names the outcome it would have
|
|
206
|
+
# applied and decides the report; an imperative one records none.
|
|
207
|
+
def finish_late(claim, error)
|
|
208
|
+
policy = policy_for(claim, error)
|
|
209
|
+
decision = policy.handler ? nil : decide(claim, error, policy: policy)
|
|
210
|
+
column = decision && StepOutcome::STEP_OUTCOME.fetch(decision.outcome.action)
|
|
211
|
+
report = decision ? decision.report : policy.report != :never
|
|
212
|
+
Runner.locked(claim.record.workflow_id) do |record, notes|
|
|
213
|
+
next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
|
|
214
|
+
|
|
215
|
+
StepOutcome.ignore_late_completion(record, claim.step, "failed", column, error)
|
|
216
|
+
notes.report(error, StepOutcome.error_context(record, claim.step, "late_completion_ignored")) if report
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def policy_for(claim, error)
|
|
221
|
+
ExceptionPolicy.resolve(error, step_policies: claim.definition.exception_policies,
|
|
222
|
+
class_policies: claim.klass.exception_policies)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def decide(claim, error, policy: policy_for(claim, error))
|
|
226
|
+
step = claim.step
|
|
227
|
+
policy.decide(error, workflow: claim.workflow,
|
|
228
|
+
consecutive_reattempts: -> { WorkflowStep.consecutive_reattempts(step.workflow_id, step.step_name) })
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def handler_context(claim)
|
|
232
|
+
{ action: "workflow_on_exception", workflow_class: claim.klass.name, workflow_id: claim.step.workflow_id,
|
|
233
|
+
step_name: claim.step.step_name, attempt: claim.step.attempt }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def debug(message)
|
|
237
|
+
Pgbus.logger.debug { "[Pgbus::Workflow] #{message} #{StepContext.log_tag}".rstrip }
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Workflow
|
|
7
|
+
# The job behind every step attempt: perform(step_id) runs one
|
|
8
|
+
# StepExecution. Enqueued only by Pgbus::WorkflowStep's after-commit
|
|
9
|
+
# callback, on config.workflow_queue unless the class
|
|
10
|
+
# (set_step_job_options) or the step (job_options:) says otherwise.
|
|
11
|
+
#
|
|
12
|
+
# No retry_on and no discard_on: a step's failure is the exception
|
|
13
|
+
# policies' business, and the job returns normally whatever the step's
|
|
14
|
+
# outcome. Redelivery is reserved for a worker that died mid-step, which
|
|
15
|
+
# the claim recognises. The visibility heartbeat must stay on for this
|
|
16
|
+
# class (never `pgbus_visibility_heartbeat false`): fast crash detection
|
|
17
|
+
# depends on a live worker keeping its message invisible.
|
|
18
|
+
class StepJob < ::ActiveJob::Base
|
|
19
|
+
# Always on, whatever ActiveJob::Base or the app set: the executor reads
|
|
20
|
+
# this, and a redelivery means a dead worker only while it is true.
|
|
21
|
+
def self.pgbus_visibility_heartbeat_enabled = true # rubocop:disable Naming/PredicateMethod -- JobMixin's reader
|
|
22
|
+
|
|
23
|
+
def perform(step_id)
|
|
24
|
+
StepExecution.new(step_id).call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Applies how a step attempt ended to its row and to the run, under the
|
|
6
|
+
# run's row lock: the StepJob after a body, a guard (cancel_if, skip_if,
|
|
7
|
+
# a removed or unknown step), crash detection and the sweep.
|
|
8
|
+
#
|
|
9
|
+
# The step row is written first, guarded on status = 'in_progress'. A
|
|
10
|
+
# row that is no longer in_progress was declared stalled (or the run was
|
|
11
|
+
# cancelled) while this attempt was still running: the outcome and its
|
|
12
|
+
# output are discarded and only a late_completion_ignored event is
|
|
13
|
+
# recorded. Otherwise the run moves by the States table: advance (the
|
|
14
|
+
# next step is scheduled), finish, reattempt, step_pause, step_cancel, or
|
|
15
|
+
# hold when an outside pause! got there first.
|
|
16
|
+
module StepOutcome
|
|
17
|
+
STEP_STATUS = { complete: "completed", finish: "completed", pause: "completed",
|
|
18
|
+
skip: "skipped", cancel: "cancelled", reattempt: "failed" }.freeze
|
|
19
|
+
STEP_OUTCOME = { complete: "completed", finish: "finished_early", pause: "paused",
|
|
20
|
+
skip: "skipped", cancel: "cancelled", reattempt: "reattempted" }.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Returns false when the attempt had lost its in_progress guard.
|
|
25
|
+
def apply(record, step, outcome, notes:, error: nil, output: nil, report: true, duration: nil, klass: record.workflow_klass) # rubocop:disable Naming/PredicateMethod
|
|
26
|
+
now = Time.current
|
|
27
|
+
status = error ? "failed" : STEP_STATUS.fetch(outcome.action)
|
|
28
|
+
column = STEP_OUTCOME.fetch(outcome.action)
|
|
29
|
+
captured = error && ErrorCapture.capture(error)
|
|
30
|
+
attrs = { status: status, outcome: column, finished_at: now, output: output, **captured.to_h }
|
|
31
|
+
if WorkflowStep.finish!(step.id, **attrs).zero?
|
|
32
|
+
ignore_late_completion(record, step, status, column, error)
|
|
33
|
+
notes.report(error, error_context(record, step, "late_completion_ignored")) if error && report
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Signals.settle(step, status, column, now: now)
|
|
38
|
+
step_events(record, step, outcome, column, error)
|
|
39
|
+
move_run(record, step, outcome, notes: notes, captured: captured, klass: klass, now: now)
|
|
40
|
+
notes.instrument("pgbus.workflow.step", step_payload(record, step, column, status, error, duration))
|
|
41
|
+
notes.report(error, error_context(record, step, column)) if error && report
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A step whose worker died (detection: "redelivery") or that outlived
|
|
46
|
+
# its stall_at (detection: "sweep"): config.workflow_stall_action
|
|
47
|
+
# applies, with Pgbus::Workflow::StepStalled recorded on the row.
|
|
48
|
+
def stall!(record, step, detection:, notes:, config: Pgbus.configuration)
|
|
49
|
+
why = if detection == "redelivery"
|
|
50
|
+
"its message came back while it was in_progress: the worker #{step.worker} died"
|
|
51
|
+
else
|
|
52
|
+
"it was still in_progress at its stall_at #{step.stall_at&.utc&.iso8601} (worker #{step.worker})"
|
|
53
|
+
end
|
|
54
|
+
error = StepStalled.new("#{step.step_name} attempt #{step.attempt} stalled: #{why}")
|
|
55
|
+
Runner.event!(record, "stall_detected", actor: Runner::SYSTEM_ACTOR, step: step,
|
|
56
|
+
details: { detection: detection, worker: step.worker,
|
|
57
|
+
stall_at: step.stall_at&.utc&.iso8601(3) }.compact)
|
|
58
|
+
notes.instrument("pgbus.workflow.stall_detected",
|
|
59
|
+
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, detection: detection))
|
|
60
|
+
apply(record, step, stall_outcome(config.workflow_stall_action), notes: notes, error: error)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stall_outcome(action)
|
|
64
|
+
case action
|
|
65
|
+
when :reattempt then FlowControl::Outcome.reattempt
|
|
66
|
+
when :cancel then FlowControl::Outcome.cancel
|
|
67
|
+
else FlowControl::Outcome.pause
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_run(record, step, outcome, notes:, captured:, klass:, now:)
|
|
72
|
+
paused = record.status == "paused"
|
|
73
|
+
case outcome.action
|
|
74
|
+
when :complete, :skip
|
|
75
|
+
following = klass&.workflow_steps&.next_after(step.step_name)
|
|
76
|
+
following ? advance(record, following, paused: paused, now: now) : finish(record, notes)
|
|
77
|
+
when :finish then finish(record, notes)
|
|
78
|
+
when :reattempt
|
|
79
|
+
Runner.schedule!(record, step.step_name, at: now + (outcome.wait || 0), actor: Runner::SYSTEM_ACTOR)
|
|
80
|
+
Runner.transition!(record, paused ? :hold : :reattempt, current_step: step.step_name)
|
|
81
|
+
when :pause then pause(record, step, outcome, notes, captured: captured, paused: paused, now: now)
|
|
82
|
+
when :cancel then cancel(record, step, outcome, notes, now: now)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def advance(record, following, paused:, now:)
|
|
87
|
+
Runner.schedule!(record, following.name, at: now + following.wait, actor: Runner::SYSTEM_ACTOR)
|
|
88
|
+
Runner.transition!(record, paused ? :hold : :advance, current_step: following.name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def finish(record, notes)
|
|
92
|
+
Runner.transition!(record, :finish, finished_at: Time.current)
|
|
93
|
+
Runner.event!(record, "finished", actor: Runner::SYSTEM_ACTOR)
|
|
94
|
+
notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The run row keeps the error that paused it (cleared by resume!).
|
|
98
|
+
def pause(record, step, outcome, notes, captured:, paused:, now:)
|
|
99
|
+
run_error = captured ? captured.except(:error_causes) : {}
|
|
100
|
+
return Runner.transition!(record, :hold, **run_error) if paused
|
|
101
|
+
|
|
102
|
+
error_class = captured&.fetch(:error_class)
|
|
103
|
+
Runner.transition!(record, :step_pause, paused_at: now, **run_error)
|
|
104
|
+
Runner.event!(record, "paused", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step,
|
|
105
|
+
details: { error_class: error_class }.compact)
|
|
106
|
+
notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: step.step_name, error_class: error_class))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cancel(record, step, outcome, notes, now:)
|
|
110
|
+
Runner.transition!(record, :step_cancel, cancelled_at: now)
|
|
111
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
112
|
+
Runner.event!(record, "cancelled", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step)
|
|
113
|
+
notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: step.step_name))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def step_events(record, step, outcome, column, error)
|
|
117
|
+
actor = Runner::SYSTEM_ACTOR
|
|
118
|
+
if error
|
|
119
|
+
Runner.event!(record, "step_failed", actor: actor, step: step,
|
|
120
|
+
details: { error_class: error.class.name, error_message: error.message.to_s })
|
|
121
|
+
end
|
|
122
|
+
case outcome.action
|
|
123
|
+
when :complete, :finish, :pause
|
|
124
|
+
Runner.event!(record, "step_completed", actor: actor, step: step, details: { outcome: column }) unless error
|
|
125
|
+
when :skip then Runner.event!(record, "step_skipped", actor: outcome.actor || actor, reason: outcome.reason, step: step)
|
|
126
|
+
when :reattempt then Runner.event!(record, "step_reattempted", actor: actor, step: step, details: { wait: outcome.wait }.compact)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ignore_late_completion(record, step, status, column, error)
|
|
131
|
+
details = { status: status, outcome: column, error_class: error&.class&.name }.compact
|
|
132
|
+
Runner.event!(record, "late_completion_ignored", actor: Runner::SYSTEM_ACTOR, step: step, details: details)
|
|
133
|
+
Pgbus.logger.warn do
|
|
134
|
+
"[Pgbus] Workflow #{record.workflow_class} #{record.workflow_id}: step #{step.step_name} attempt #{step.attempt} " \
|
|
135
|
+
"finished after it stopped being in_progress (stalled or cancelled); its outcome (#{column}) and output were discarded"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def step_payload(record, step, column, status, error, duration)
|
|
140
|
+
pickup = step.started_at && step.scheduled_at ? [step.started_at - step.scheduled_at, 0].max.to_f : nil
|
|
141
|
+
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, outcome: column, status: status,
|
|
142
|
+
error_class: error&.class&.name, duration: duration, pickup_latency: pickup)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def error_context(record, step, column)
|
|
146
|
+
{ workflow_class: record.workflow_class, workflow_id: record.workflow_id, step_name: step.step_name,
|
|
147
|
+
attempt: step.attempt, outcome: column }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The ordered step list of one workflow class: StepDefinitions and
|
|
6
|
+
# RemovedStep gravestones, in declaration order. Immutable: every
|
|
7
|
+
# change (`step`, `removed_step`) builds a new frozen Steps that the
|
|
8
|
+
# class stores whole, so a subclass extends its parent's list without
|
|
9
|
+
# touching it and a reader never sees a list change under it
|
|
10
|
+
# (docs/concurrency.md).
|
|
11
|
+
class Steps
|
|
12
|
+
attr_reader :entries
|
|
13
|
+
|
|
14
|
+
def initialize(entries = [])
|
|
15
|
+
@entries = entries.dup.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
EMPTY = new
|
|
20
|
+
|
|
21
|
+
def add(definition, before: nil, after: nil)
|
|
22
|
+
raise ArgumentError, "use before_step: or after_step:, not both" if before && after
|
|
23
|
+
raise ArgumentError, "#{definition.name} is a removed step (removed_step); pick another name" if removed?(definition.name)
|
|
24
|
+
raise ArgumentError, "duplicate step #{definition.name}" if fetch(definition.name)
|
|
25
|
+
|
|
26
|
+
list = entries.dup
|
|
27
|
+
anchor = before || after
|
|
28
|
+
if anchor
|
|
29
|
+
index = position(anchor)
|
|
30
|
+
unless index
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"#{before ? "before_step" : "after_step"}: #{anchor.to_s.to_sym.inspect} — no step named #{anchor}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
list.insert(before ? index : index + 1, definition)
|
|
36
|
+
else
|
|
37
|
+
list << definition
|
|
38
|
+
end
|
|
39
|
+
self.class.new(list)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Replaces a live step with a gravestone at its position, or appends a
|
|
43
|
+
# gravestone for a step that is already gone from the class.
|
|
44
|
+
def remove(name)
|
|
45
|
+
name = -name.to_s
|
|
46
|
+
raise ArgumentError, "#{name} is already a removed step" if removed?(name)
|
|
47
|
+
|
|
48
|
+
list = entries.dup
|
|
49
|
+
index = position(name)
|
|
50
|
+
gravestone = RemovedStep.new(name: name)
|
|
51
|
+
index ? list[index] = gravestone : list << gravestone
|
|
52
|
+
self.class.new(list)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def definitions = entries.reject(&:removed?)
|
|
56
|
+
|
|
57
|
+
def names = definitions.map(&:name)
|
|
58
|
+
|
|
59
|
+
def removed_names = entries.select(&:removed?).map(&:name)
|
|
60
|
+
|
|
61
|
+
def fetch(name)
|
|
62
|
+
name = name.to_s
|
|
63
|
+
definitions.find { |entry| entry.name == name }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def removed?(name)
|
|
67
|
+
name = name.to_s
|
|
68
|
+
entries.any? { |entry| entry.removed? && entry.name == name }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def first = definitions.first
|
|
72
|
+
|
|
73
|
+
# The next live step declared after +name+ (a step or a gravestone),
|
|
74
|
+
# or nil when +name+ is the last one or unknown.
|
|
75
|
+
def next_after(name)
|
|
76
|
+
index = position(name)
|
|
77
|
+
return nil unless index
|
|
78
|
+
|
|
79
|
+
entries.drop(index + 1).find { |entry| !entry.removed? }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def empty? = definitions.empty?
|
|
83
|
+
|
|
84
|
+
# Name for an anonymous `step { ... }`: step_<n>, the first n from
|
|
85
|
+
# entries.size + 1 not taken by a step or a gravestone.
|
|
86
|
+
def anonymous_name
|
|
87
|
+
number = entries.size + 1
|
|
88
|
+
number += 1 while entries.any? { |entry| entry.name == "step_#{number}" }
|
|
89
|
+
"step_#{number}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What definition_digest hashes: the ordered [name, kind] pairs.
|
|
93
|
+
def digest_pairs = entries.map { |entry| [entry.name, entry.kind] }
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def position(name)
|
|
98
|
+
name = name.to_s
|
|
99
|
+
entries.index { |entry| entry.name == name }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The dispatcher's workflow housekeeping.
|
|
6
|
+
#
|
|
7
|
+
# repair (every config.workflow_sweep_interval):
|
|
8
|
+
# - an in_progress step past its stall_at (started_at + the step's
|
|
9
|
+
# timeout:, or workflow_stall_threshold) gets workflow_stall_action,
|
|
10
|
+
# with StepStalled recorded. A dead worker is usually caught sooner, on
|
|
11
|
+
# redelivery (StepExecution); this catches a live but hung step, and
|
|
12
|
+
# every crash when the visibility heartbeat is off.
|
|
13
|
+
# - a scheduled step of a ready run, due more than workflow_stall_threshold
|
|
14
|
+
# ago and not sent within it, is sent again by bumping enqueued_at (the
|
|
15
|
+
# model callback re-fires). No queue inspection: a duplicate message is
|
|
16
|
+
# a no-op at the claim.
|
|
17
|
+
# - a waiting step of a waiting run past its timeout_at (wait_timeout:)
|
|
18
|
+
# gets its on_timeout (WaitTimeout), so a timeout fires within one
|
|
19
|
+
# workflow_sweep_interval of its deadline.
|
|
20
|
+
# - a fan_out step still waiting on a batch that finished more than
|
|
21
|
+
# workflow_stall_threshold ago lost its BatchFinishedJob (dead-lettered,
|
|
22
|
+
# or never sent): the sweep resolves it as the callback would have.
|
|
23
|
+
# - pgbus.workflow.gauge: runs by class and status, and by class and stuck
|
|
24
|
+
# reason (waiting_overdue: waiting longer than
|
|
25
|
+
# workflow_waiting_visibility_threshold on a step without a timeout).
|
|
26
|
+
#
|
|
27
|
+
# cleanup (hourly): finished and cancelled runs older than
|
|
28
|
+
# workflow_retention are deleted in batches; their steps and events go
|
|
29
|
+
# with them (ON DELETE CASCADE).
|
|
30
|
+
module Sweep
|
|
31
|
+
BATCH_SIZE = 500
|
|
32
|
+
|
|
33
|
+
# The schema probe's memo (a true answer is kept for the life of the
|
|
34
|
+
# process), read and written under the lock; the probe itself runs
|
|
35
|
+
# outside it.
|
|
36
|
+
@schema_lock = Mutex.new
|
|
37
|
+
@tables_ready = false
|
|
38
|
+
@waiting_ready = false
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def repair(config: Pgbus.configuration, now: Time.current)
|
|
42
|
+
return nil unless tables_ready?
|
|
43
|
+
|
|
44
|
+
threshold = config.workflow_stall_threshold
|
|
45
|
+
summary = { stalled: repair_stalled(now: now, config: config), resent: resend_overdue(now: now, threshold: threshold) }
|
|
46
|
+
waiting = waiting_ready?
|
|
47
|
+
summary[:timed_out] = waiting ? WaitTimeout.expire_due(now: now) : 0
|
|
48
|
+
summary[:fan_outs] = waiting ? FanOut.resolve_lost(now: now, grace: threshold) : 0
|
|
49
|
+
emit_gauge(now: now, threshold: threshold, waiting_threshold: (config.workflow_waiting_visibility_threshold if waiting))
|
|
50
|
+
summary
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cleanup(retention: Pgbus.configuration.workflow_retention, now: Time.current, batch_size: BATCH_SIZE)
|
|
54
|
+
return 0 unless retention&.positive? && tables_ready?
|
|
55
|
+
|
|
56
|
+
cutoff = now - retention
|
|
57
|
+
deleted = 0
|
|
58
|
+
loop do
|
|
59
|
+
ids = WorkflowEntry.stale(before: cutoff).order(:id).limit(batch_size).pluck(:id)
|
|
60
|
+
break if ids.empty?
|
|
61
|
+
|
|
62
|
+
deleted += WorkflowEntry.where(id: ids).delete_all
|
|
63
|
+
end
|
|
64
|
+
deleted
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True once the three workflow tables exist (pgbus:add_workflows).
|
|
68
|
+
def tables_ready?
|
|
69
|
+
return true if @schema_lock.synchronize { @tables_ready }
|
|
70
|
+
|
|
71
|
+
ready = begin
|
|
72
|
+
[WorkflowEntry, WorkflowStep, WorkflowEvent].all?(&:table_exists?)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Pgbus.logger.debug { "[Pgbus] Workflow table probe failed: #{e.class}: #{e.message}" }
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
@schema_lock.synchronize { @tables_ready = true } if ready
|
|
78
|
+
ready
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True once the waiting-step schema exists (pgbus:add_workflow_signals,
|
|
82
|
+
# or an add_workflows that carries it): the signals table and the
|
|
83
|
+
# step columns. Asked of the database through the connection, not the
|
|
84
|
+
# schema cache (which keeps a negative answer). A process that loaded
|
|
85
|
+
# the step model before the migration still needs a restart to see
|
|
86
|
+
# the new columns, as for any column added under a running app.
|
|
87
|
+
def waiting_ready?
|
|
88
|
+
return true if @schema_lock.synchronize { @waiting_ready }
|
|
89
|
+
|
|
90
|
+
ready = begin
|
|
91
|
+
connection = WorkflowStep.connection
|
|
92
|
+
connection.table_exists?(WorkflowSignal.table_name) && connection.column_exists?(WorkflowStep.table_name, :timeout_at)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Pgbus.logger.debug { "[Pgbus] Workflow waiting-step schema probe failed: #{e.class}: #{e.message}" }
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
@schema_lock.synchronize { @waiting_ready = true } if ready
|
|
98
|
+
ready
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Specs only.
|
|
102
|
+
def reset_tables_ready!
|
|
103
|
+
@schema_lock.synchronize do
|
|
104
|
+
@tables_ready = false
|
|
105
|
+
@waiting_ready = false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def repair_stalled(now:, config:)
|
|
112
|
+
WorkflowStep.stalled(now: now).order(:id).limit(BATCH_SIZE).pluck(:id, :workflow_id).count do |id, workflow_id|
|
|
113
|
+
stall(id, workflow_id, now: now, config: config)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Re-checked under the run's lock: the step may have finished since
|
|
118
|
+
# the query.
|
|
119
|
+
def stall(id, workflow_id, now:, config:)
|
|
120
|
+
Runner.locked(workflow_id) do |record, notes|
|
|
121
|
+
step = record && WorkflowStep.find_by(id: id)
|
|
122
|
+
next false unless step&.status == "in_progress" && step.stall_at && step.stall_at < now
|
|
123
|
+
|
|
124
|
+
StepOutcome.stall!(record, step, detection: "sweep", notes: notes, config: config)
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resend_overdue(now:, threshold:)
|
|
130
|
+
WorkflowStep.overdue(threshold: threshold, now: now).order(:id).limit(BATCH_SIZE).to_a.each do |step|
|
|
131
|
+
step.update!(enqueued_at: now)
|
|
132
|
+
end.size
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def emit_gauge(now:, threshold:, waiting_threshold: nil)
|
|
136
|
+
statuses = WorkflowEntry.status_counts
|
|
137
|
+
stuck = WorkflowEntry.stuck_reason_counts(threshold: threshold, now: now, waiting_threshold: waiting_threshold)
|
|
138
|
+
classes = (statuses.keys.map(&:first) + stuck.keys.map(&:first) + Workflow.registered_classes.map(&:name)).uniq.sort
|
|
139
|
+
Instrumentation.instrument(
|
|
140
|
+
"pgbus.workflow.gauge",
|
|
141
|
+
statuses: zero_filled(classes, States::WORKFLOW, statuses, :status),
|
|
142
|
+
stuck: zero_filled(classes, States::STUCK_REASONS, stuck, :reason)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Every class × every value, so a count that drops to zero is
|
|
147
|
+
# reported as zero instead of keeping its last value.
|
|
148
|
+
def zero_filled(classes, values, counts, key)
|
|
149
|
+
classes.product(values).map do |klass, value|
|
|
150
|
+
{ workflow_class: klass, key => value, count: counts.fetch([klass, value], 0) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|