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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Which signal a `wait_for:` step takes:
|
|
6
|
+
#
|
|
7
|
+
# step :capture, wait_for: :payment_confirmed do ... end
|
|
8
|
+
# step :ship, wait_for: Pgbus::Workflow::SignalMatcher.new(:shipped) { |payload| payload[:region] == "eu" }
|
|
9
|
+
# step :ship, wait_for: EuShipment.new # any object answering matches?(signal)
|
|
10
|
+
#
|
|
11
|
+
# A matcher runs under the workflow row lock (when the step reaches its
|
|
12
|
+
# gate, and when signal! looks for a waiting step), so it must be fast
|
|
13
|
+
# and free of side effects. It gets a Pgbus::WorkflowSignal whose
|
|
14
|
+
# +payload+ has indifferent access.
|
|
15
|
+
class SignalMatcher
|
|
16
|
+
attr_reader :signal_name, :condition
|
|
17
|
+
|
|
18
|
+
# A name becomes a matcher; a matcher, or anything answering matches?,
|
|
19
|
+
# is kept as it is.
|
|
20
|
+
def self.coerce(value)
|
|
21
|
+
return value if value.respond_to?(:matches?)
|
|
22
|
+
return new(value) if (value.is_a?(Symbol) || value.is_a?(String)) && !value.to_s.strip.empty?
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "wait_for: takes a signal name, a Pgbus::Workflow::SignalMatcher or an object " \
|
|
25
|
+
"answering matches?(signal), got #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# What the step row's wait_for column shows: the signal name, else the
|
|
29
|
+
# matcher's class.
|
|
30
|
+
def self.label(matcher)
|
|
31
|
+
return matcher.signal_name.to_s if matcher.respond_to?(:signal_name) && matcher.signal_name
|
|
32
|
+
|
|
33
|
+
matcher.class.name.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(name, &condition)
|
|
37
|
+
raise ArgumentError, "a SignalMatcher needs a signal name, got #{name.inspect}" if name.to_s.strip.empty?
|
|
38
|
+
|
|
39
|
+
@signal_name = -name.to_s.strip
|
|
40
|
+
@condition = condition
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def matches?(signal)
|
|
45
|
+
return false unless signal.name.to_s.strip == signal_name
|
|
46
|
+
|
|
47
|
+
condition.nil? || (condition.call(signal.payload) ? true : false)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s = signal_name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# Signals (issue #541): what a wait_for: step waits for.
|
|
6
|
+
#
|
|
7
|
+
# deliver (signal!) stores the signal under the run's row lock and, when
|
|
8
|
+
# the run's current step is waiting for a signal it matches, claims it
|
|
9
|
+
# for that step and wakes it. Otherwise it stays pending (buffered).
|
|
10
|
+
#
|
|
11
|
+
# gate runs in the StepJob after the claim and the guards, under the same
|
|
12
|
+
# lock: a signal this step already holds (woken by signal!, or claimed by
|
|
13
|
+
# an earlier attempt that reattempted or paused on a failure), else the
|
|
14
|
+
# oldest pending one its matcher takes, else the step parks as waiting.
|
|
15
|
+
#
|
|
16
|
+
# A claimed signal is consumed when its step ends, except when that
|
|
17
|
+
# attempt reattempts or fails into a pause: the next attempt runs with it.
|
|
18
|
+
module Signals
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Returns [record, signal]. A redelivery with a stored idempotency key
|
|
22
|
+
# returns the stored signal (duplicate_delivery? true) and does nothing
|
|
23
|
+
# else. A blank key is no key: "" would otherwise dedup every signal
|
|
24
|
+
# sent with it against the first.
|
|
25
|
+
def deliver(workflow_id, name, payload:, idempotency_key:, actor:, reason: nil)
|
|
26
|
+
name = signal_name!(name)
|
|
27
|
+
serialized = Payload.dump(payload || {}, what: "payload of signal #{name}")
|
|
28
|
+
key = idempotency_key.to_s.presence
|
|
29
|
+
Runner.locked(workflow_id) do |record, notes|
|
|
30
|
+
raise ActiveRecord::RecordNotFound, "workflow #{workflow_id} is gone" unless record
|
|
31
|
+
|
|
32
|
+
States.check!(:signal, record.status)
|
|
33
|
+
signal = WorkflowSignal.insert_signal!(workflow_id: workflow_id, name: name, payload: serialized, idempotency_key: key)
|
|
34
|
+
signal ||= duplicate(record, key)
|
|
35
|
+
received!(record, signal, actor: actor, reason: reason, notes: notes)
|
|
36
|
+
[record, signal]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The gate of a wait_for: step. True when the body runs (with a signal,
|
|
41
|
+
# or timed out), false when the step parked. Sets received_signal and
|
|
42
|
+
# timed_out? on +workflow+.
|
|
43
|
+
def gate(record, step, definition, workflow, now: Time.current)
|
|
44
|
+
if timed_out?(step, now)
|
|
45
|
+
workflow.send(:enter_wait, signal: nil, timed_out: true)
|
|
46
|
+
return true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
signal = WorkflowSignal.held_by_step(record.workflow_id, step.step_name) || match(record, definition.wait_for)
|
|
50
|
+
return park(record, step, definition, now) unless signal
|
|
51
|
+
|
|
52
|
+
claim!(record, step, signal, actor: Runner::SYSTEM_ACTOR) if signal.status == "pending"
|
|
53
|
+
step.update_columns(signal_id: signal.id) unless step.signal_id == signal.id
|
|
54
|
+
workflow.send(:enter_wait, signal: signal, timed_out: false)
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The step's attempt ended (StepOutcome.apply): consume the signal it
|
|
59
|
+
# holds, unless the next attempt runs with it.
|
|
60
|
+
def settle(step, status, column, now:)
|
|
61
|
+
return unless step.respond_to?(:signal_id) && step.signal_id
|
|
62
|
+
return if column == "reattempted" || (status == "failed" && column == "paused")
|
|
63
|
+
|
|
64
|
+
WorkflowSignal.consume!(step.signal_id, now: now)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def signal_name!(name)
|
|
68
|
+
text = name.to_s.strip
|
|
69
|
+
raise ArgumentError, "signal! needs a signal name, got #{name.inspect}" if text.empty?
|
|
70
|
+
|
|
71
|
+
-text
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def duplicate(record, key)
|
|
75
|
+
WorkflowSignal.find_by!(workflow_id: record.workflow_id, idempotency_key: key).tap do |signal|
|
|
76
|
+
signal.duplicate_delivery = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def received!(record, signal, actor:, notes:, reason: nil)
|
|
81
|
+
duplicate = signal.duplicate_delivery?
|
|
82
|
+
Runner.event!(record, "signal_received", actor: actor, reason: reason,
|
|
83
|
+
details: { name: signal.name, idempotency_key: signal.idempotency_key,
|
|
84
|
+
signal_id: signal.id, duplicate: duplicate }.compact)
|
|
85
|
+
woke = !duplicate && wake_waiting(record, signal, actor: actor)
|
|
86
|
+
notes.instrument("pgbus.workflow.signal",
|
|
87
|
+
Runner.payload_for(record, name: signal.name, duplicate: duplicate, woke: woke))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def wake_waiting(record, signal, actor:) # rubocop:disable Naming/PredicateMethod -- true when it woke the step
|
|
91
|
+
step = WorkflowStep.latest_for(record.workflow_id)
|
|
92
|
+
return false unless step&.status == "waiting" && step.batch_id.nil?
|
|
93
|
+
|
|
94
|
+
definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
|
|
95
|
+
return false unless definition&.waits_for_signal? && definition.wait_for.matches?(signal)
|
|
96
|
+
|
|
97
|
+
claim!(record, step, signal, actor: actor)
|
|
98
|
+
Waiting.wake!(record, step, now: Time.current, signal_id: signal.id)
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def claim!(record, step, signal, actor:)
|
|
103
|
+
now = Time.current
|
|
104
|
+
WorkflowSignal.claim!(signal.id, step_id: step.id, now: now)
|
|
105
|
+
signal.assign_attributes(status: "claimed", claimed_by_step_id: step.id, claimed_at: now)
|
|
106
|
+
Runner.event!(record, "signal_claimed", actor: actor, step: step, details: { name: signal.name, signal_id: signal.id })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The oldest pending signal +matcher+ takes. A SignalMatcher narrows the
|
|
110
|
+
# query to its name; any other matcher sees every pending signal.
|
|
111
|
+
def match(record, matcher)
|
|
112
|
+
name = matcher.respond_to?(:signal_name) ? matcher.signal_name : nil
|
|
113
|
+
WorkflowSignal.pending_for(record.workflow_id, name: name).find_each(batch_size: 100) do |signal|
|
|
114
|
+
return signal if matcher.matches?(signal)
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def park(record, step, definition, now) # rubocop:disable Naming/PredicateMethod -- the gate's false: the body does not run
|
|
120
|
+
timeout_at = definition.wait_timeout && (now + definition.wait_timeout)
|
|
121
|
+
Waiting.park!(record, step, now: now, wait_for: definition.wait_for_name, timeout_at: timeout_at)
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# This attempt waited, holds no signal, and its wait_timeout passed: the
|
|
126
|
+
# sweep woke it with on_timeout: :run.
|
|
127
|
+
def timed_out?(step, now)
|
|
128
|
+
!step.waiting_since.nil? && step.signal_id.nil? && !step.timeout_at.nil? && step.timeout_at <= now + 1
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The one transition table (issue #540). Every verb, the workflow states
|
|
6
|
+
# it is legal from, the state it moves the workflow to, and what it does
|
|
7
|
+
# to the step rows. The runner's guarded UPDATEs take their WHERE status
|
|
8
|
+
# IN (...) from here, available_actions (dashboard buttons, MCP tools)
|
|
9
|
+
# reads it, and the docs page lists it. Nothing else decides whether a
|
|
10
|
+
# transition is legal.
|
|
11
|
+
#
|
|
12
|
+
# Outside verbs are the ones a person or a program calls on a run
|
|
13
|
+
# (console, dashboard, MCP); runner verbs are the StepJob's and the
|
|
14
|
+
# sweep's own moves, never offered as actions.
|
|
15
|
+
module States
|
|
16
|
+
WORKFLOW = %w[ready performing waiting paused finished cancelled].freeze
|
|
17
|
+
ONGOING = %w[ready performing waiting paused].freeze
|
|
18
|
+
TERMINAL = %w[finished cancelled].freeze
|
|
19
|
+
STEP = %w[scheduled waiting in_progress completed failed cancelled skipped].freeze
|
|
20
|
+
OPEN_STEP = %w[scheduled waiting in_progress].freeze
|
|
21
|
+
|
|
22
|
+
# Why an ongoing run needs a person (the sweep's gauge, and the
|
|
23
|
+
# dashboard's needs-attention view later).
|
|
24
|
+
STUCK_REASONS = %w[paused_error stalled paused_manual overdue waiting_overdue].freeze
|
|
25
|
+
|
|
26
|
+
Transition = Data.define(:verb, :from, :to, :outside, :error, :effect)
|
|
27
|
+
|
|
28
|
+
TRANSITIONS = Ractor.make_shareable(
|
|
29
|
+
[
|
|
30
|
+
# Outside verbs
|
|
31
|
+
Transition.new(verb: :pause, from: %w[ready performing waiting], to: "paused", outside: true,
|
|
32
|
+
error: :InvalidTransition,
|
|
33
|
+
effect: "a running step finishes and records its outcome; nothing new is claimed"),
|
|
34
|
+
Transition.new(verb: :resume, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
|
|
35
|
+
effect: "a failed step runs again as a new attempt; a completed or skipped one is " \
|
|
36
|
+
"followed by the next step; a scheduled one is sent again; a waiting one waits again"),
|
|
37
|
+
Transition.new(verb: :resume_at, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
|
|
38
|
+
effect: "the named step runs as a new attempt"),
|
|
39
|
+
Transition.new(verb: :cancel, from: ONGOING, to: "cancelled", outside: true, error: :InvalidTransition,
|
|
40
|
+
effect: "scheduled and in_progress steps are cancelled; a running step's outcome is ignored"),
|
|
41
|
+
Transition.new(verb: :run_now, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
|
|
42
|
+
effect: "the scheduled step's wait is cut short and it is sent now"),
|
|
43
|
+
Transition.new(verb: :reschedule, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
|
|
44
|
+
effect: "the scheduled step moves to the given time"),
|
|
45
|
+
Transition.new(verb: :rerun, from: TERMINAL, to: "ready", outside: true, error: :InvalidTransition,
|
|
46
|
+
effect: "a new run starts with the same subject and arguments"),
|
|
47
|
+
Transition.new(verb: :skip, from: %w[waiting], to: "ready", outside: true, error: :InvalidTransition,
|
|
48
|
+
effect: "the waiting step is skipped (a signal it holds is consumed) and the next step is " \
|
|
49
|
+
"scheduled, or the run finishes after the last"),
|
|
50
|
+
# signal leaves the state alone unless it wakes a waiting step (wake).
|
|
51
|
+
Transition.new(verb: :signal, from: ONGOING, to: nil, outside: true, error: :NotOngoing,
|
|
52
|
+
effect: "the signal is stored; a waiting step it matches is scheduled again, otherwise it " \
|
|
53
|
+
"stays pending until a step claims it"),
|
|
54
|
+
# Runner verbs
|
|
55
|
+
Transition.new(verb: :claim, from: %w[ready], to: "performing", outside: false, error: :InvalidTransition,
|
|
56
|
+
effect: "the scheduled step moves to in_progress"),
|
|
57
|
+
Transition.new(verb: :advance, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
|
|
58
|
+
effect: "the step is completed or skipped and the next step is scheduled"),
|
|
59
|
+
Transition.new(verb: :finish, from: %w[performing paused], to: "finished", outside: false,
|
|
60
|
+
error: :InvalidTransition, effect: "the last step (or one that called finished!) completed"),
|
|
61
|
+
Transition.new(verb: :reattempt, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
|
|
62
|
+
effect: "the step failed and its next attempt is scheduled"),
|
|
63
|
+
Transition.new(verb: :step_pause, from: %w[performing], to: "paused", outside: false,
|
|
64
|
+
error: :InvalidTransition,
|
|
65
|
+
effect: "the step completed after pause!, or failed (an exception or a stall)"),
|
|
66
|
+
Transition.new(verb: :step_cancel, from: %w[performing paused], to: "cancelled", outside: false,
|
|
67
|
+
error: :InvalidTransition, effect: "the step called cancel!, or cancel_if or a policy cancelled it"),
|
|
68
|
+
Transition.new(verb: :unpause, from: %w[paused], to: "performing", outside: false, error: :NotPaused,
|
|
69
|
+
effect: "resume! while the step that was running at the outside pause! is still running: " \
|
|
70
|
+
"the run goes on performing it"),
|
|
71
|
+
Transition.new(verb: :hold, from: %w[paused], to: "paused", outside: false, error: :InvalidTransition,
|
|
72
|
+
effect: "an outcome that arrived after an outside pause!: recorded on the step; a " \
|
|
73
|
+
"completed, skipped or reattempted step has its next row scheduled; the workflow " \
|
|
74
|
+
"stays paused"),
|
|
75
|
+
Transition.new(verb: :park, from: %w[performing], to: "waiting", outside: false, error: :InvalidTransition,
|
|
76
|
+
effect: "the step waits for a matching signal or for its fan-out batch; nothing is sent " \
|
|
77
|
+
"until it is woken"),
|
|
78
|
+
Transition.new(verb: :wake, from: %w[waiting], to: "ready", outside: false, error: :InvalidTransition,
|
|
79
|
+
effect: "a matching signal arrived, or the wait timed out with on_timeout: :run: the " \
|
|
80
|
+
"waiting step is scheduled again"),
|
|
81
|
+
Transition.new(verb: :resolve, from: %w[waiting], to: "performing", outside: false,
|
|
82
|
+
error: :InvalidTransition,
|
|
83
|
+
effect: "the fan-out batch finished, the wait timed out, or an operator skipped the step: " \
|
|
84
|
+
"its outcome is applied as for a finished attempt"),
|
|
85
|
+
Transition.new(verb: :rewait, from: %w[paused], to: "waiting", outside: false, error: :NotPaused,
|
|
86
|
+
effect: "resume! on a run paused while its step was waiting: it waits again")
|
|
87
|
+
].to_h { |transition| [transition.verb, transition] }
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
module_function
|
|
91
|
+
|
|
92
|
+
def fetch(verb)
|
|
93
|
+
TRANSITIONS.fetch(verb.to_sym) { raise ArgumentError, "unknown workflow verb #{verb.inspect}" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def from(verb) = fetch(verb).from
|
|
97
|
+
|
|
98
|
+
def to(verb) = fetch(verb).to
|
|
99
|
+
|
|
100
|
+
def legal?(verb, status) = from(verb).include?(status.to_s)
|
|
101
|
+
|
|
102
|
+
# Returns the target state, or raises the verb's documented error.
|
|
103
|
+
def check!(verb, status)
|
|
104
|
+
transition = fetch(verb)
|
|
105
|
+
return transition.to if transition.from.include?(status.to_s)
|
|
106
|
+
|
|
107
|
+
raise Workflow.const_get(transition.error),
|
|
108
|
+
"cannot #{verb.to_s.tr("_", " ")} a #{status} workflow (allowed from: #{transition.from.join(", ")})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The outside verbs legal in +status+: the dashboard's buttons and the
|
|
112
|
+
# MCP write tools.
|
|
113
|
+
def available_actions(status)
|
|
114
|
+
state = status.to_s
|
|
115
|
+
TRANSITIONS.each_value.select { |transition| transition.outside && transition.from.include?(state) }.map(&:verb)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# The workflow step running in this fiber, while its body runs:
|
|
6
|
+
#
|
|
7
|
+
# Pgbus::Workflow::StepContext.workflow_id # => "6f1c…" (nil outside a step)
|
|
8
|
+
# Rails.logger.tagged("wf=#{Pgbus::Workflow::StepContext.workflow_id}") { ... }
|
|
9
|
+
#
|
|
10
|
+
# Not named Current: every constant under Pgbus::Workflow is visible from
|
|
11
|
+
# a workflow subclass's step bodies, and a bare `Current` there must stay
|
|
12
|
+
# the app's ActiveSupport::CurrentAttributes.
|
|
13
|
+
#
|
|
14
|
+
# Fiber-local (Thread.current[] is per fiber), saved and restored around
|
|
15
|
+
# each step so a nested or concurrent step never sees another's context.
|
|
16
|
+
# Pgbus's own log lines inside a step carry workflow=… step=… attempt=…
|
|
17
|
+
# through Pgbus::LogFormatter's context.
|
|
18
|
+
module StepContext
|
|
19
|
+
KEY = :pgbus_workflow_step_context
|
|
20
|
+
|
|
21
|
+
# The running step, as the fiber-local value.
|
|
22
|
+
Frame = Data.define(:workflow_id, :workflow_class, :step_name, :attempt)
|
|
23
|
+
|
|
24
|
+
# Which attempt a workflow instance is running its body for. Set by the
|
|
25
|
+
# StepJob on the fresh instance it runs the body on, never by app code.
|
|
26
|
+
Attempt = Data.define(:step_id, :step_name, :attempt)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def with(workflow_id:, workflow_class:, step_name:, attempt:, &)
|
|
31
|
+
previous = Thread.current[KEY]
|
|
32
|
+
Thread.current[KEY] = Frame.new(workflow_id: workflow_id, workflow_class: workflow_class,
|
|
33
|
+
step_name: step_name, attempt: attempt)
|
|
34
|
+
LogFormatter.with_context(workflow: workflow_id, step: step_name, attempt: attempt, &)
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[KEY] = previous
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def context = Thread.current[KEY]
|
|
40
|
+
|
|
41
|
+
def workflow_id = context&.workflow_id
|
|
42
|
+
|
|
43
|
+
def workflow_class = context&.workflow_class
|
|
44
|
+
|
|
45
|
+
def step_name = context&.step_name
|
|
46
|
+
|
|
47
|
+
def attempt = context&.attempt
|
|
48
|
+
|
|
49
|
+
# "workflow=… step=… attempt=…" for a log line, or "" outside a step.
|
|
50
|
+
def log_tag
|
|
51
|
+
current = context
|
|
52
|
+
return "" unless current
|
|
53
|
+
|
|
54
|
+
"workflow=#{current.workflow_id} step=#{current.step_name} attempt=#{current.attempt}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# One declared step: its name, its body (a block or the name of an
|
|
6
|
+
# instance method) and its options. Built and validated when the class
|
|
7
|
+
# body runs, so a bad option fails at load, not on the first run.
|
|
8
|
+
class StepDefinition
|
|
9
|
+
OPTIONS = %i[wait skip_if job_options timeout on_exception wait_for wait_timeout on_timeout].freeze
|
|
10
|
+
# What a wait_for: step does when its wait_timeout: passes first.
|
|
11
|
+
ON_TIMEOUT = %i[pause! cancel! skip! run notify].freeze
|
|
12
|
+
# The values written without a bang (they do not end the wait as a verb would).
|
|
13
|
+
PLAIN_ON_TIMEOUT = %w[run notify].freeze
|
|
14
|
+
# The StepJob options a class (set_step_job_options) or a step
|
|
15
|
+
# (job_options:) may set. Anything else raises at class load.
|
|
16
|
+
JOB_OPTIONS = %i[queue priority wait wait_until].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
|
|
19
|
+
:wait_for, :wait_for_name, :wait_timeout, :on_timeout
|
|
20
|
+
|
|
21
|
+
def self.validate_job_options!(options)
|
|
22
|
+
raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
options.to_h do |key, value|
|
|
25
|
+
key = key.to_sym
|
|
26
|
+
unless JOB_OPTIONS.include?(key)
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"unknown job_options: #{key.inspect} — accepted: #{JOB_OPTIONS.map(&:inspect).join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
[key, job_option_value(key, value)]
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.job_option_value(key, value)
|
|
36
|
+
case key
|
|
37
|
+
when :queue
|
|
38
|
+
raise ArgumentError, "job_options: queue must be a queue name, got #{value.inspect}" if value.to_s.strip.empty?
|
|
39
|
+
|
|
40
|
+
-value.to_s
|
|
41
|
+
when :priority
|
|
42
|
+
return value if value.is_a?(Integer)
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "job_options: priority must be an Integer, got #{value.inspect}"
|
|
45
|
+
when :wait then seconds(value, "job_options: wait", positive: false)
|
|
46
|
+
when :wait_until
|
|
47
|
+
# Time, DateTime and TimeWithZone; not a String, which ActiveSupport
|
|
48
|
+
# also gives #to_time (nil for an unparseable one).
|
|
49
|
+
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String) && value.to_time
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "job_options: wait_until must be a Time, got #{value.inspect}"
|
|
52
|
+
else value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Seconds as an Integer when whole, else a Float. Durations are
|
|
57
|
+
# accepted; nil stays nil.
|
|
58
|
+
def self.seconds(value, option, positive:)
|
|
59
|
+
return nil if value.nil?
|
|
60
|
+
|
|
61
|
+
seconds = duration?(value) ? value.to_f : value
|
|
62
|
+
unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && (positive ? seconds.positive? : !seconds.negative?)
|
|
63
|
+
requirement = positive ? "a positive" : "a non-negative"
|
|
64
|
+
raise ArgumentError, "#{option}: must be #{requirement} number of seconds or a Duration, got #{value.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
seconds == seconds.to_i ? seconds.to_i : seconds.to_f
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.duration?(value)
|
|
71
|
+
defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(name:, body:, wait: nil, skip_if: nil, job_options: {}, timeout: nil, on_exception: nil,
|
|
75
|
+
wait_for: nil, wait_timeout: nil, on_timeout: nil)
|
|
76
|
+
@name = -name.to_s
|
|
77
|
+
@body = body
|
|
78
|
+
@wait = self.class.seconds(wait, "wait", positive: false) || 0
|
|
79
|
+
@skip_if = validate_skip_if(skip_if)
|
|
80
|
+
@job_options = self.class.validate_job_options!(job_options.nil? ? {} : job_options)
|
|
81
|
+
@timeout = self.class.seconds(timeout, "timeout", positive: true)
|
|
82
|
+
@exception_policies = ExceptionPolicy.coerce_all(on_exception)
|
|
83
|
+
assign_wait(wait_for, wait_timeout, on_timeout)
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def kind = "step"
|
|
88
|
+
|
|
89
|
+
# True for a wait_for: step: it parks until a matching signal arrives.
|
|
90
|
+
def waits_for_signal? = !wait_for.nil?
|
|
91
|
+
|
|
92
|
+
def removed? = false
|
|
93
|
+
|
|
94
|
+
# Runs the body on +workflow+; the return value is ignored.
|
|
95
|
+
def invoke(workflow)
|
|
96
|
+
body.is_a?(Symbol) ? workflow.send(body) : workflow.instance_exec(&body)
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def skip?(workflow)
|
|
101
|
+
return false if skip_if.nil?
|
|
102
|
+
|
|
103
|
+
result = skip_if.is_a?(Symbol) ? workflow.send(skip_if) : workflow.instance_exec(&skip_if)
|
|
104
|
+
result ? true : false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def assign_wait(wait_for, wait_timeout, on_timeout)
|
|
110
|
+
@wait_for = wait_for.nil? ? nil : SignalMatcher.coerce(wait_for)
|
|
111
|
+
@wait_for_name = @wait_for && SignalMatcher.label(@wait_for)
|
|
112
|
+
raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
|
|
113
|
+
raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
|
|
114
|
+
|
|
115
|
+
@wait_timeout = self.class.seconds(wait_timeout, "wait_timeout", positive: true)
|
|
116
|
+
@on_timeout = @wait_timeout && normalize_on_timeout(on_timeout.nil? ? :pause! : on_timeout)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_on_timeout(value)
|
|
120
|
+
verb = PLAIN_ON_TIMEOUT.include?(value.to_s) ? value.to_sym : :"#{value.to_s.delete_suffix("!")}!"
|
|
121
|
+
return verb if ON_TIMEOUT.include?(verb)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "on_timeout: must be one of #{ON_TIMEOUT.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_skip_if(value)
|
|
127
|
+
return value if value.nil? || value.is_a?(Symbol) || value.is_a?(Proc)
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, "skip_if: must be a lambda, a Proc or a method name, got #{value.inspect}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|