pgbus 0.19.3 → 0.20.0
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 +8 -0
- data/README.md +104 -9
- 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 +130 -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/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 +142 -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 +39 -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 +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -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 +67 -26
- data/lib/pgbus/active_job/batch_id.rb +23 -1
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/batch/builder.rb +1 -0
- data/lib/pgbus/batch.rb +35 -3
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
- data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
- data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
- 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 +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- 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 +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- 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 +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -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 +131 -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 +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +95 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# A `fan_out` step (issue #541): a StepDefinition whose body adds jobs
|
|
6
|
+
# to a builder instead of doing the work itself. FanOut runs the body,
|
|
7
|
+
# opens the batch and parks the step on it.
|
|
8
|
+
class FanOutDefinition < StepDefinition
|
|
9
|
+
# What a batch with dead-lettered members does to the run.
|
|
10
|
+
FAILURE_ACTIONS = %i[pause! cancel! skip!].freeze
|
|
11
|
+
# The step options a fan_out takes (on_batch_failure: besides): every
|
|
12
|
+
# one but the signal waits.
|
|
13
|
+
OPTIONS = (StepDefinition::OPTIONS - %i[wait_for wait_timeout on_timeout]).freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :on_batch_failure
|
|
16
|
+
|
|
17
|
+
def initialize(on_batch_failure: :pause!, **)
|
|
18
|
+
@on_batch_failure = normalize_failure_action(on_batch_failure)
|
|
19
|
+
super(**)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def kind = "fan_out"
|
|
23
|
+
|
|
24
|
+
# Runs the body with a recording builder, opens the batch, and ends the
|
|
25
|
+
# attempt with the :fan_out outcome (FanOut.run throws it).
|
|
26
|
+
def invoke(workflow)
|
|
27
|
+
FanOut.run(self, workflow)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_failure_action(value)
|
|
33
|
+
verb = value.nil? ? nil : :"#{value.to_s.delete_suffix("!")}!"
|
|
34
|
+
return verb if FAILURE_ACTIONS.include?(verb)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"on_batch_failure: must be one of #{FAILURE_ACTIONS.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The flow-control verbs. Inside a step body each one ends the body at
|
|
6
|
+
# once (`throw :pgbus_workflow_flow, Outcome`) and the StepJob applies the
|
|
7
|
+
# outcome under the workflow lock. Outside a step, `pause!` and `cancel!`
|
|
8
|
+
# are operator verbs (they change the run through the Runner, with
|
|
9
|
+
# actor:/reason: on the audit trail); the step-only verbs raise NotInStep.
|
|
10
|
+
module FlowControl
|
|
11
|
+
THROW_TAG = :pgbus_workflow_flow
|
|
12
|
+
ACTIONS = %i[complete reattempt pause cancel skip finish fan_out].freeze
|
|
13
|
+
|
|
14
|
+
# What a step attempt ended with. +wait+ is the reattempt delay in
|
|
15
|
+
# seconds (nil = now); +actor+ and +reason+ are what a step's own
|
|
16
|
+
# pause!/cancel! (or an operator's skip!) passed, for the audit trail;
|
|
17
|
+
# +details+ carries a fan_out's batch_id and size.
|
|
18
|
+
Outcome = Data.define(:action, :wait, :actor, :reason, :details) do
|
|
19
|
+
def initialize(action:, wait: nil, actor: nil, reason: nil, details: nil) = super
|
|
20
|
+
|
|
21
|
+
def self.complete = new(action: :complete, wait: nil)
|
|
22
|
+
def self.pause(actor: nil, reason: nil) = new(action: :pause, actor: actor, reason: reason)
|
|
23
|
+
def self.cancel(actor: nil, reason: nil) = new(action: :cancel, actor: actor, reason: reason)
|
|
24
|
+
def self.skip(actor: nil, reason: nil) = new(action: :skip, actor: actor, reason: reason)
|
|
25
|
+
def self.finish = new(action: :finish, wait: nil)
|
|
26
|
+
def self.reattempt(wait: nil) = new(action: :reattempt, wait: StepDefinition.seconds(wait, "wait", positive: false))
|
|
27
|
+
# A fan_out body returned and its batch is open: the step parks on it.
|
|
28
|
+
def self.fan_out(batch_id:, size:) = new(action: :fan_out, details: { batch_id: batch_id, size: size }.freeze)
|
|
29
|
+
|
|
30
|
+
# An ExceptionPolicy action (:pause!, :reattempt!, :cancel!, :skip!).
|
|
31
|
+
def self.for(verb, wait: nil)
|
|
32
|
+
case verb.to_s.delete_suffix("!").to_sym
|
|
33
|
+
when :pause then pause
|
|
34
|
+
when :cancel then cancel
|
|
35
|
+
when :skip then skip
|
|
36
|
+
when :reattempt then reattempt(wait: wait)
|
|
37
|
+
when :finished, :finish then finish
|
|
38
|
+
else raise ArgumentError, "unknown flow-control verb #{verb.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reattempt? = action == :reattempt
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Inside a step: end it here, completed, with the workflow paused.
|
|
46
|
+
# Outside: pause the run (ready, performing or waiting → paused).
|
|
47
|
+
def pause!(actor: nil, reason: nil)
|
|
48
|
+
return throw_outcome(Outcome.pause(actor: actor, reason: reason)) if in_step?
|
|
49
|
+
|
|
50
|
+
apply_outside_verb(:pause, actor: actor, reason: reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Inside a step: end it here and cancel the workflow.
|
|
54
|
+
# Outside: cancel the run from any ongoing state.
|
|
55
|
+
def cancel!(actor: nil, reason: nil)
|
|
56
|
+
return throw_outcome(Outcome.cancel(actor: actor, reason: reason)) if in_step?
|
|
57
|
+
|
|
58
|
+
apply_outside_verb(:cancel, actor: actor, reason: reason)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Ends this attempt as failed and schedules the next one, +wait+
|
|
62
|
+
# seconds from now. Step bodies only.
|
|
63
|
+
def reattempt!(wait: nil)
|
|
64
|
+
throw_outcome(Outcome.reattempt(wait: wait), verb: :reattempt!)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Inside a step: end it here, skipped; the workflow moves on.
|
|
68
|
+
# Outside: skip the step a waiting run waits on (a signal it holds is
|
|
69
|
+
# consumed) and schedule the next one.
|
|
70
|
+
def skip!(actor: nil, reason: nil)
|
|
71
|
+
return throw_outcome(Outcome.skip) if in_step?
|
|
72
|
+
|
|
73
|
+
apply_outside_verb(:skip, actor: actor, reason: reason)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Ends the step as completed and finishes the workflow, skipping the
|
|
77
|
+
# steps after it. Step bodies only.
|
|
78
|
+
def finished!
|
|
79
|
+
throw_outcome(Outcome.finish, verb: :finished!)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def in_step? = !step_context.nil?
|
|
85
|
+
|
|
86
|
+
def throw_outcome(outcome, verb: nil)
|
|
87
|
+
raise NotInStep, "#{verb} can only be called inside a step body" if verb && !in_step?
|
|
88
|
+
raise NotInStep, "flow-control verbs end a step from its body or an on_exception block, not from cancel_if or skip_if" if @guarding
|
|
89
|
+
|
|
90
|
+
throw THROW_TAG, outcome
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The instance side of a workflow: the run's readers, its outputs and
|
|
6
|
+
# idempotency keys, and the outside verbs. An instance wraps one
|
|
7
|
+
# Pgbus::WorkflowEntry row; inside a step it also knows which step
|
|
8
|
+
# attempt it is running (the step context).
|
|
9
|
+
module Instance
|
|
10
|
+
def workflow_id = record.workflow_id
|
|
11
|
+
|
|
12
|
+
def workflow_class = record.workflow_class
|
|
13
|
+
|
|
14
|
+
def status = record.status
|
|
15
|
+
|
|
16
|
+
def current_step = record.current_step
|
|
17
|
+
|
|
18
|
+
# True when start!(…, idempotency_key:) found an existing run with that
|
|
19
|
+
# key and returned it instead of starting another.
|
|
20
|
+
def duplicate_start? = @duplicate_start
|
|
21
|
+
|
|
22
|
+
# The record the run was started for, loaded through its GlobalID;
|
|
23
|
+
# nil for a subject-less run or when the record is gone.
|
|
24
|
+
def subject
|
|
25
|
+
return @subject if defined?(@subject)
|
|
26
|
+
|
|
27
|
+
@subject = Payload.locate_subject(record.subject_gid)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# start!'s keyword arguments, with indifferent access.
|
|
31
|
+
def arguments
|
|
32
|
+
return @arguments if defined?(@arguments)
|
|
33
|
+
|
|
34
|
+
@arguments = Payload.load(record.arguments) || {}.with_indifferent_access
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reload
|
|
38
|
+
@record = WorkflowEntry.find_by!(workflow_id: workflow_id)
|
|
39
|
+
remove_instance_variable(:@subject) if defined?(@subject)
|
|
40
|
+
remove_instance_variable(:@arguments) if defined?(@arguments)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The audit trail, oldest first.
|
|
45
|
+
def events = WorkflowEvent.where(workflow_id: workflow_id).order(:id).to_a
|
|
46
|
+
|
|
47
|
+
# The outside verbs legal in the current status (States).
|
|
48
|
+
def available_actions = States.available_actions(status)
|
|
49
|
+
|
|
50
|
+
# Inside a step: record durable output values. They are written in the
|
|
51
|
+
# same UPDATE that completes the step, so they exist exactly when the
|
|
52
|
+
# step does; later steps read them with output_of. Merges across calls.
|
|
53
|
+
def output(**values)
|
|
54
|
+
context = step_context!(:output)
|
|
55
|
+
raise NotInStep, "output can only be called from the step body, not from cancel_if or skip_if" if @guarding
|
|
56
|
+
|
|
57
|
+
merged = (@output_values || {}).merge(values)
|
|
58
|
+
@pending_output = Payload.dump(merged, what: "output of step #{context.step_name}")
|
|
59
|
+
@output_values = merged
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# What this attempt's output(...) calls recorded, serialized (or nil).
|
|
64
|
+
def pending_output = @pending_output
|
|
65
|
+
|
|
66
|
+
# The output of +step_name+'s latest completed attempt, or nil. A
|
|
67
|
+
# failed or reattempted attempt's output is never returned.
|
|
68
|
+
def output_of(step_name)
|
|
69
|
+
Payload.load(WorkflowStep.completed_output(workflow_id, step_name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# { "step_name" => output } for every step with a completed attempt.
|
|
73
|
+
def outputs
|
|
74
|
+
WorkflowStep.completed_outputs(workflow_id).transform_values { |output| Payload.load(output) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The same on every attempt of this step (and on a resume_at! re-run):
|
|
78
|
+
# hand it to an external API so a repeated side effect is deduplicated.
|
|
79
|
+
def step_idempotency_key
|
|
80
|
+
"pgbus-wf:#{workflow_id}:#{step_context!(:step_idempotency_key).step_name}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Inside a wait_for: step: the Pgbus::WorkflowSignal the step took (its
|
|
84
|
+
# +payload+ has indifferent access), or nil when its wait timed out.
|
|
85
|
+
def received_signal
|
|
86
|
+
step_context!(:received_signal)
|
|
87
|
+
@received_signal
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Inside a wait_for: step: true when the body runs because the wait
|
|
91
|
+
# timed out (on_timeout: :run) rather than for a signal.
|
|
92
|
+
def timed_out?
|
|
93
|
+
step_context!(:timed_out?)
|
|
94
|
+
@timed_out == true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Deliver a signal to this run. Stored under the run's lock; a step
|
|
98
|
+
# waiting for it (wait_for:) is woken at once, otherwise it waits,
|
|
99
|
+
# pending, for the step that claims it. With an idempotency_key a
|
|
100
|
+
# redelivery stores nothing and returns the first signal
|
|
101
|
+
# (duplicate_delivery? true). Raises NotOngoing on a finished or
|
|
102
|
+
# cancelled run. +reason+ goes on the signal_received event.
|
|
103
|
+
#
|
|
104
|
+
# wf.signal!(:payment_confirmed, payload: { amount: 42 }, idempotency_key: event.id)
|
|
105
|
+
def signal!(name, payload: {}, idempotency_key: nil, actor: nil, reason: nil)
|
|
106
|
+
@record, signal = Signals.deliver(workflow_id, name, payload: payload, idempotency_key: idempotency_key,
|
|
107
|
+
actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
|
|
108
|
+
signal
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Differs per attempt, for the rare call that must not be deduplicated
|
|
112
|
+
# across attempts.
|
|
113
|
+
def attempt_idempotency_key
|
|
114
|
+
"#{step_idempotency_key}:#{step_context!(:attempt_idempotency_key).attempt}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# paused → ready. A failed step runs again as a new attempt, a
|
|
118
|
+
# completed one is followed by the next step.
|
|
119
|
+
def resume!(actor: nil, reason: nil) = apply_outside_verb(:resume, actor: actor, reason: reason)
|
|
120
|
+
|
|
121
|
+
# paused → ready, running +step_name+ next.
|
|
122
|
+
def resume_at!(step_name, actor: nil, reason: nil)
|
|
123
|
+
apply_outside_verb(:resume_at, actor: actor, reason: reason, step_name: step_name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The scheduled step runs now instead of after its wait.
|
|
127
|
+
def run_now!(actor: nil, reason: nil) = apply_outside_verb(:run_now, actor: actor, reason: reason)
|
|
128
|
+
|
|
129
|
+
# The scheduled step runs at +at+ instead.
|
|
130
|
+
def reschedule!(at:, actor: nil, reason: nil) # rubocop:disable Naming/MethodParameterName
|
|
131
|
+
apply_outside_verb(:reschedule, actor: actor, reason: reason, at: at)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A finished or cancelled run, started again as a new run with the same
|
|
135
|
+
# subject and arguments. Returns the new run.
|
|
136
|
+
def rerun!(actor: nil, reason: nil)
|
|
137
|
+
Runner.rerun(self, actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inspect
|
|
141
|
+
"#<#{self.class.name} workflow_id=#{workflow_id.inspect} status=#{status.inspect} current_step=#{current_step.inspect}>"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
attr_reader :step_context
|
|
147
|
+
|
|
148
|
+
def apply_outside_verb(verb, actor:, reason:, **)
|
|
149
|
+
@record = Runner.apply(self, verb, actor: actor || Runner::DEFAULT_ACTOR, reason: reason, **)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def step_context!(reader)
|
|
154
|
+
step_context || raise(NotInStep, "#{reader} can only be called inside a step body")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Used by the StepJob only: cancel_if and skip_if run inside this, where
|
|
158
|
+
# the flow-control verbs raise NotInStep (only the body and an
|
|
159
|
+
# on_exception block can end a step).
|
|
160
|
+
def guarding
|
|
161
|
+
@guarding = true
|
|
162
|
+
yield
|
|
163
|
+
ensure
|
|
164
|
+
@guarding = false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Used by the StepJob only.
|
|
168
|
+
def enter_step(context)
|
|
169
|
+
@step_context = context
|
|
170
|
+
@output_values = nil
|
|
171
|
+
@pending_output = nil
|
|
172
|
+
@received_signal = nil
|
|
173
|
+
@timed_out = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Used by the StepJob's signal gate only.
|
|
177
|
+
def enter_wait(signal:, timed_out:)
|
|
178
|
+
@received_signal = signal
|
|
179
|
+
@timed_out = timed_out
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The outside verbs, each called by Runner.apply with the run's row
|
|
6
|
+
# locked and the transition already checked against the States table.
|
|
7
|
+
module OutsideVerbs
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def pause(record, notes, actor:, reason:)
|
|
11
|
+
from = record.status
|
|
12
|
+
Runner.transition!(record, :pause, paused_at: Time.current)
|
|
13
|
+
Runner.event!(record, "paused", actor: actor, reason: reason, details: { from: from })
|
|
14
|
+
notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: record.current_step, error_class: nil))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancel(record, notes, actor:, reason:)
|
|
18
|
+
now = Time.current
|
|
19
|
+
from = record.status
|
|
20
|
+
Runner.transition!(record, :cancel, cancelled_at: now)
|
|
21
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
22
|
+
Runner.event!(record, "cancelled", actor: actor, reason: reason, details: { from: from })
|
|
23
|
+
notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: record.current_step))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What runs next depends on the row the run paused on: a failed one
|
|
27
|
+
# (an exception or a stall) runs again as a new attempt; a completed or
|
|
28
|
+
# skipped one (the step called pause!) is followed by the next step, or
|
|
29
|
+
# finishes the run when it was the last; a scheduled one (an outside
|
|
30
|
+
# pause! before it ran) is sent again; one still running goes on; one
|
|
31
|
+
# still waiting (for a signal or its batch) waits again.
|
|
32
|
+
def resume(record, notes, actor:, reason:)
|
|
33
|
+
latest = WorkflowStep.latest_for(record.workflow_id)
|
|
34
|
+
resumed = { paused_at: nil, **ErrorCapture.cleared }
|
|
35
|
+
case latest&.status
|
|
36
|
+
when "in_progress"
|
|
37
|
+
Runner.transition!(record, :unpause, **resumed)
|
|
38
|
+
resumed_event(record, actor, reason, latest)
|
|
39
|
+
when "waiting"
|
|
40
|
+
extend_wait(record, latest)
|
|
41
|
+
Runner.transition!(record, :rewait, **resumed)
|
|
42
|
+
resumed_event(record, actor, reason, latest)
|
|
43
|
+
when "scheduled"
|
|
44
|
+
Runner.transition!(record, :resume, **resumed)
|
|
45
|
+
resumed_event(record, actor, reason, latest)
|
|
46
|
+
latest.update!(enqueued_at: Time.current)
|
|
47
|
+
when "failed"
|
|
48
|
+
Runner.transition!(record, :resume, **resumed)
|
|
49
|
+
resumed_event(record, actor, reason, latest)
|
|
50
|
+
Runner.schedule!(record, latest.step_name, at: Time.current, actor: actor)
|
|
51
|
+
else
|
|
52
|
+
resume_after(record, notes, latest, resumed, actor: actor, reason: reason)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The step a waiting run waits on is skipped (a signal it holds is
|
|
57
|
+
# consumed), as if it had called skip!; the next step is scheduled, or
|
|
58
|
+
# the run finishes after the last. Its fan-out batch, if any, runs on and
|
|
59
|
+
# its callback finds nothing to resume.
|
|
60
|
+
def skip(record, notes, actor:, reason:)
|
|
61
|
+
step = WorkflowStep.latest_for(record.workflow_id)
|
|
62
|
+
unless step&.status == "waiting"
|
|
63
|
+
raise InvalidTransition, "workflow #{record.workflow_id} has no waiting step to skip (latest: #{step&.status || "none"})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.skip(actor: actor, reason: reason), notes: notes)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resume_at(record, _notes, actor:, reason:, step_name:)
|
|
70
|
+
definition = definition!(record, step_name)
|
|
71
|
+
now = Time.current
|
|
72
|
+
WorkflowStep.cancel_open!(record.workflow_id, now: now)
|
|
73
|
+
Runner.transition!(record, :resume_at, current_step: definition.name, paused_at: nil, **ErrorCapture.cleared)
|
|
74
|
+
Runner.event!(record, "resumed", actor: actor, reason: reason, details: { resume_at: definition.name })
|
|
75
|
+
Runner.schedule!(record, definition.name, at: now, actor: actor)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_now(record, _notes, actor:, reason:)
|
|
79
|
+
reschedule(record, nil, actor: actor, reason: reason, at: Time.current, event: "run_now")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reschedule(record, _notes, actor:, reason:, at:, event: "rescheduled") # rubocop:disable Naming/MethodParameterName
|
|
83
|
+
time = at.respond_to?(:to_time) && !at.is_a?(String) ? at.to_time : nil
|
|
84
|
+
raise ArgumentError, "reschedule!(at:) takes a Time, got #{at.inspect}" unless time
|
|
85
|
+
|
|
86
|
+
step = WorkflowStep.where(workflow_id: record.workflow_id, status: "scheduled").order(id: :desc).first
|
|
87
|
+
raise InvalidTransition, "workflow #{record.workflow_id} has no scheduled step to #{event.tr("_", " ")}" unless step
|
|
88
|
+
|
|
89
|
+
verb = event == "run_now" ? :run_now : :reschedule
|
|
90
|
+
from = step.scheduled_at
|
|
91
|
+
Runner.transition!(record, verb)
|
|
92
|
+
step.update!(scheduled_at: time, enqueued_at: Time.current)
|
|
93
|
+
Runner.event!(record, event, actor: actor, reason: reason, step: step,
|
|
94
|
+
details: { from: from.utc.iso8601(3), to: step.scheduled_at.utc.iso8601(3) })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resume_after(record, notes, latest, resumed, actor:, reason:)
|
|
98
|
+
klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
|
|
99
|
+
steps = klass.workflow_steps
|
|
100
|
+
if latest && steps.entries.none? { |entry| entry.name == latest.step_name }
|
|
101
|
+
raise StepNotDefined, "#{klass.name} has no step #{latest.step_name} (declare `removed_step :#{latest.step_name}`)"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
following = latest ? steps.next_after(latest.step_name) : steps.first
|
|
105
|
+
if following.nil?
|
|
106
|
+
Runner.transition!(record, :finish, finished_at: Time.current, **resumed)
|
|
107
|
+
resumed_event(record, actor, reason, latest)
|
|
108
|
+
Runner.event!(record, "finished", actor: actor)
|
|
109
|
+
notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
Runner.transition!(record, :resume, current_step: following.name, **resumed)
|
|
114
|
+
resumed_event(record, actor, reason, latest)
|
|
115
|
+
Runner.schedule!(record, following.name, at: Time.current + following.wait, actor: actor)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The time a run spent paused does not count against its waiting
|
|
119
|
+
# step's wait_timeout: the deadline moves by the length of the pause
|
|
120
|
+
# (from when the step started waiting, if that came later).
|
|
121
|
+
def extend_wait(record, step, now: Time.current)
|
|
122
|
+
return unless step.timeout_at && record.paused_at
|
|
123
|
+
|
|
124
|
+
paused_for = now - [record.paused_at, step.waiting_since].compact.max
|
|
125
|
+
step.update_columns(timeout_at: step.timeout_at + paused_for) if paused_for.positive?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resumed_event(record, actor, reason, step)
|
|
129
|
+
Runner.event!(record, "resumed", actor: actor, reason: reason, step: step)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def definition!(record, step_name)
|
|
133
|
+
klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
|
|
134
|
+
klass.workflow_steps.fetch(step_name) || raise(StepNotDefined, "#{klass.name} has no step #{step_name}")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
class Workflow
|
|
7
|
+
# Arguments, step outputs and the subject, to and from the database.
|
|
8
|
+
# Values go through ActiveJob::Arguments (JSON, GlobalID-aware, never
|
|
9
|
+
# Marshal), are capped at config.workflow_max_payload_size, and come back
|
|
10
|
+
# with indifferent access. GlobalIDs are gated by
|
|
11
|
+
# config.allowed_global_id_models, as job arguments are.
|
|
12
|
+
module Payload
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def dump(values, what:, limit: Pgbus.configuration.workflow_max_payload_size)
|
|
16
|
+
serialized = ::ActiveJob::Arguments.serialize([values.to_h]).first
|
|
17
|
+
size = JSON.generate(serialized).bytesize
|
|
18
|
+
raise PayloadTooLarge, "#{what} is #{size} bytes, over workflow_max_payload_size (#{limit})" if size > limit
|
|
19
|
+
|
|
20
|
+
serialized
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load(serialized, configuration: Pgbus.configuration)
|
|
24
|
+
return nil if serialized.nil?
|
|
25
|
+
|
|
26
|
+
Serializer.assert_job_global_ids_allowed!(serialized, configuration: configuration)
|
|
27
|
+
::ActiveJob::Arguments.deserialize([serialized]).first.with_indifferent_access
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The subject columns of a run: the polymorphic type and id the
|
|
31
|
+
# one-ongoing index keys on, and the GlobalID +subject+ loads through.
|
|
32
|
+
def subject_attributes(subject)
|
|
33
|
+
return { subject_type: nil, subject_id: nil, subject_gid: nil } if subject.nil?
|
|
34
|
+
unless subject.respond_to?(:to_global_id) && subject.respond_to?(:id)
|
|
35
|
+
raise ArgumentError, "a workflow subject must be a GlobalID record (an ActiveRecord model), got #{subject.class}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
klass = subject.class
|
|
39
|
+
type = klass.respond_to?(:base_class) ? klass.base_class.name : klass.name
|
|
40
|
+
{ subject_type: type, subject_id: subject.id.to_s, subject_gid: subject.to_global_id.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# nil when the run has no subject or the record is gone.
|
|
44
|
+
def locate_subject(gid, configuration: Pgbus.configuration)
|
|
45
|
+
return nil if gid.nil?
|
|
46
|
+
|
|
47
|
+
Serializer.locate_global_id(gid, configuration: configuration)
|
|
48
|
+
rescue ActiveRecord::RecordNotFound
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
class Workflow
|
|
8
|
+
# Every Pgbus::Workflow subclass, registered from `inherited` (what the
|
|
9
|
+
# insights page lists). The list is a frozen Array replaced whole under a
|
|
10
|
+
# lock built at load (docs/concurrency.md): a reader never sees it change
|
|
11
|
+
# under it, and two classes defined at once are both kept.
|
|
12
|
+
#
|
|
13
|
+
# Classes are kept as objects, not names: `Class.new(Pgbus::Workflow)`
|
|
14
|
+
# has no name yet when `inherited` runs. Names are read when listing, and
|
|
15
|
+
# a name defined again (code reloading) lists its latest class only.
|
|
16
|
+
module Registry
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
@classes = [].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def register(klass)
|
|
22
|
+
@lock.synchronize { @classes = merged(@classes, klass) }
|
|
23
|
+
klass
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def classes
|
|
27
|
+
named = @lock.synchronize { @classes }.select(&:name)
|
|
28
|
+
named.reverse.uniq(&:name).reverse
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Specs only.
|
|
32
|
+
def reset!
|
|
33
|
+
@lock.synchronize { @classes = [].freeze }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# SHA256 of the ordered [name, kind] pairs of a Steps list.
|
|
37
|
+
def digest(steps)
|
|
38
|
+
Digest::SHA256.hexdigest(JSON.generate(steps.digest_pairs))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# A class registered again by name (code reloading) replaces the old
|
|
44
|
+
# one, so reloads do not accumulate. Anonymous classes are kept until
|
|
45
|
+
# they get a name; classes lists them then.
|
|
46
|
+
def merged(list, klass)
|
|
47
|
+
name = klass.name
|
|
48
|
+
(list.reject { |existing| existing.equal?(klass) || (name && existing.name == name) } << klass).freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# A removed_step gravestone: a step that existed once. A scheduled row
|
|
6
|
+
# still naming it is skipped and the workflow moves on to the next step
|
|
7
|
+
# declared after it.
|
|
8
|
+
RemovedStep = Data.define(:name) do
|
|
9
|
+
def kind = "removed"
|
|
10
|
+
|
|
11
|
+
def removed? = true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|