pgbus 0.19.4 → 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 +6 -0
- data/README.md +96 -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 +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/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- 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/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 +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 +92 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
class Workflow
|
|
8
|
+
# The transitions every caller shares: start!, the outside verbs, the
|
|
9
|
+
# StepJob and the sweep. Each change happens under the workflow row lock
|
|
10
|
+
# (WorkflowEntry.lock_for) as a guarded UPDATE whose WHERE status IN (...)
|
|
11
|
+
# comes from the States table, and writes its WorkflowEvent in the same
|
|
12
|
+
# transaction. Instrumentation and error reports wait until it commits.
|
|
13
|
+
module Runner
|
|
14
|
+
DEFAULT_ACTOR = "app"
|
|
15
|
+
SYSTEM_ACTOR = "system"
|
|
16
|
+
|
|
17
|
+
# Instrumentation events and error reports collected inside a
|
|
18
|
+
# transaction, sent once it is over.
|
|
19
|
+
class Notes
|
|
20
|
+
def initialize
|
|
21
|
+
@entries = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def instrument(event, payload) = @entries << [:instrument, event, payload]
|
|
25
|
+
|
|
26
|
+
def report(error, context) = @entries << [:report, error, context]
|
|
27
|
+
|
|
28
|
+
def flush
|
|
29
|
+
entries = @entries
|
|
30
|
+
@entries = []
|
|
31
|
+
entries.each do |kind, subject, detail|
|
|
32
|
+
kind == :instrument ? Instrumentation.instrument(subject, detail) : ErrorReporter.report(subject, detail)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sends now, or, when the transition ran inside an app's transaction
|
|
37
|
+
# on the same connection, once that transaction commits (and never
|
|
38
|
+
# if it rolls back): what is reported must have happened.
|
|
39
|
+
def flush_after_commit(connection)
|
|
40
|
+
return flush unless connection.transaction_open?
|
|
41
|
+
|
|
42
|
+
connection.add_transaction_record(AfterCommit.new(self))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The transaction-record interface ActiveRecord calls on commit and
|
|
46
|
+
# rollback (the one after_commit callbacks use), on 7.1 and later;
|
|
47
|
+
# ActiveRecord.after_all_transactions_commit needs 7.2.
|
|
48
|
+
class AfterCommit
|
|
49
|
+
def initialize(notes)
|
|
50
|
+
@notes = notes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trigger_transactional_callbacks? = true
|
|
54
|
+
|
|
55
|
+
def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- ActiveRecord's name
|
|
56
|
+
|
|
57
|
+
def before_committed!; end
|
|
58
|
+
|
|
59
|
+
def committed!(*, **) = @notes.flush
|
|
60
|
+
|
|
61
|
+
def rolledback!(*, **); end
|
|
62
|
+
|
|
63
|
+
def add_to_transaction(*); end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# --- start! and rerun! --------------------------------------------
|
|
70
|
+
|
|
71
|
+
def start(klass, subject:, arguments:, idempotency_key: nil)
|
|
72
|
+
attrs = run_attributes(klass, idempotency_key: idempotency_key)
|
|
73
|
+
.merge(Payload.subject_attributes(subject))
|
|
74
|
+
.merge(arguments: Payload.dump(arguments, what: "arguments of #{klass.name}"))
|
|
75
|
+
insert_run(klass, attrs, actor: DEFAULT_ACTOR)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A finished or cancelled run started again: same subject and
|
|
79
|
+
# arguments (copied as stored, not reloaded), no idempotency key.
|
|
80
|
+
def rerun(workflow, actor:, reason: nil)
|
|
81
|
+
WorkflowEntry.transaction do
|
|
82
|
+
old = WorkflowEntry.lock_for(workflow.workflow_id) || raise(ActiveRecord::RecordNotFound,
|
|
83
|
+
"workflow #{workflow.workflow_id} is gone")
|
|
84
|
+
States.check!(:rerun, old.status)
|
|
85
|
+
klass = old.workflow_klass || raise(StepNotDefined, "#{old.workflow_class} is not a Pgbus::Workflow class any more")
|
|
86
|
+
attrs = run_attributes(klass, rerun_of: old.workflow_id)
|
|
87
|
+
.merge(old.attributes.symbolize_keys.slice(:subject_type, :subject_id, :subject_gid, :arguments))
|
|
88
|
+
rerun = insert_run(klass, attrs, actor: actor, reason: reason)
|
|
89
|
+
event!(old, "rerun", actor: actor, reason: reason, details: { rerun_workflow_id: rerun.workflow_id })
|
|
90
|
+
rerun
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_attributes(klass, idempotency_key: nil, rerun_of: nil)
|
|
95
|
+
raise ArgumentError, "an anonymous workflow class cannot start: give it a constant name" unless klass.name
|
|
96
|
+
|
|
97
|
+
first = klass.workflow_steps.first
|
|
98
|
+
raise ArgumentError, "#{klass.name} declares no steps" unless first
|
|
99
|
+
|
|
100
|
+
Waiting.check_schema!(klass)
|
|
101
|
+
|
|
102
|
+
now = Time.current
|
|
103
|
+
{ workflow_id: SecureRandom.uuid, workflow_class: klass.name, status: "ready", current_step: first.name,
|
|
104
|
+
step_job_options: klass.step_job_options.transform_keys(&:to_s), idempotency_key: idempotency_key&.to_s,
|
|
105
|
+
definition_digest: klass.definition_digest, rerun_of_workflow_id: rerun_of, created_at: now, updated_at: now }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The run row and its first step row in one transaction (a savepoint
|
|
109
|
+
# inside an app transaction, so a unique violation leaves the app's
|
|
110
|
+
# transaction usable). The first StepJob goes out when it commits.
|
|
111
|
+
def insert_run(klass, attrs, actor:, reason: nil)
|
|
112
|
+
record, duplicate = WorkflowEntry.transaction(requires_new: true) do
|
|
113
|
+
next [existing_run(klass, attrs), true] unless WorkflowEntry.insert_run!(attrs)
|
|
114
|
+
|
|
115
|
+
record = WorkflowEntry.find_by!(workflow_id: attrs[:workflow_id])
|
|
116
|
+
event!(record, "started", actor: actor, reason: reason, details: { rerun_of: attrs[:rerun_of_workflow_id] }.compact)
|
|
117
|
+
first = klass.workflow_steps.first
|
|
118
|
+
schedule!(record, first.name, at: Time.current + first.wait, actor: actor)
|
|
119
|
+
[record, false]
|
|
120
|
+
end
|
|
121
|
+
klass.new(record, duplicate_start: duplicate)
|
|
122
|
+
rescue ActiveRecord::RecordNotUnique
|
|
123
|
+
# ON CONFLICT only absorbs the idempotency index. Two concurrent
|
|
124
|
+
# starts with the same key and subject can instead trip the
|
|
125
|
+
# one-ongoing index first; by then the winner has committed, so its
|
|
126
|
+
# key is visible: that is still a duplicate start, not a second run.
|
|
127
|
+
existing = attrs[:idempotency_key] && existing_run(klass, attrs, required: false)
|
|
128
|
+
return klass.new(existing, duplicate_start: true) if existing
|
|
129
|
+
|
|
130
|
+
holder = WorkflowEntry.ongoing.find_by(workflow_class: klass.name, subject_type: attrs[:subject_type],
|
|
131
|
+
subject_id: attrs[:subject_id])
|
|
132
|
+
raise AlreadyRunning.new("#{klass.name} already has an ongoing run for #{attrs[:subject_type]} " \
|
|
133
|
+
"#{attrs[:subject_id]} (#{holder&.workflow_id})", workflow: holder && klass.new(holder))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def existing_run(klass, attrs, required: true)
|
|
137
|
+
scope = WorkflowEntry.where(workflow_class: klass.name, idempotency_key: attrs[:idempotency_key])
|
|
138
|
+
required ? scope.take! : scope.take
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- outside verbs ------------------------------------------------
|
|
142
|
+
|
|
143
|
+
# pause!, cancel!, resume!, resume_at!, run_now!, reschedule! on a run.
|
|
144
|
+
# Returns the updated row; raises the States table's error when the
|
|
145
|
+
# verb is not legal in the run's state.
|
|
146
|
+
def apply(workflow, verb, actor:, reason: nil, **options)
|
|
147
|
+
locked(workflow.workflow_id) do |record, notes|
|
|
148
|
+
raise ActiveRecord::RecordNotFound, "workflow #{workflow.workflow_id} is gone" unless record
|
|
149
|
+
|
|
150
|
+
States.check!(verb, record.status)
|
|
151
|
+
OutsideVerbs.public_send(verb, record, notes, actor: actor, reason: reason, **options)
|
|
152
|
+
record
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- shared helpers -----------------------------------------------
|
|
157
|
+
|
|
158
|
+
# Runs the block in a transaction with the run's row locked (nil when
|
|
159
|
+
# the run is gone), and sends what the block noted once it commits.
|
|
160
|
+
#
|
|
161
|
+
# The notes are enrolled inside the transition's own transaction, the
|
|
162
|
+
# way a saved model is: they go out when that transaction's work really
|
|
163
|
+
# commits, whether it is a top-level transaction, part of an app's
|
|
164
|
+
# joinable one, or a savepoint inside a non-joinable one (Rails'
|
|
165
|
+
# transactional tests), and never on a rollback.
|
|
166
|
+
def locked(workflow_id)
|
|
167
|
+
notes = Notes.new
|
|
168
|
+
WorkflowEntry.transaction do
|
|
169
|
+
result = yield WorkflowEntry.lock_for(workflow_id), notes
|
|
170
|
+
notes.flush_after_commit(WorkflowEntry.connection)
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The guarded UPDATE for +verb+: WHERE status IN (States.from(verb)).
|
|
176
|
+
# Under the row lock it cannot lose once the caller checked the state;
|
|
177
|
+
# 0 rows raises rather than letting a transition pass silently.
|
|
178
|
+
def transition!(record, verb, **attrs)
|
|
179
|
+
to = States.to(verb)
|
|
180
|
+
updated = WorkflowEntry.transition!(record.workflow_id, from: States.from(verb), to: to, **attrs)
|
|
181
|
+
raise InvalidTransition, "#{verb} found workflow #{record.workflow_id} outside #{States.from(verb).join("/")}" if updated.zero?
|
|
182
|
+
|
|
183
|
+
record.assign_attributes(attrs.merge(status: to))
|
|
184
|
+
to
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def event!(record, event, actor:, reason: nil, step: nil, details: {})
|
|
188
|
+
WorkflowEvent.record!(workflow_id: record.workflow_id, event: event, actor: actor, reason: reason, step: step,
|
|
189
|
+
details: details)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A new attempt row of +step_name+ (its after-commit callback sends the
|
|
193
|
+
# StepJob), with its step_scheduled event. A job_options wait:/wait_until:
|
|
194
|
+
# (class or step) is folded into scheduled_at here, once, so the sweep
|
|
195
|
+
# does not take a deliberately delayed step for a lost one and
|
|
196
|
+
# run_now!/reschedule! move the one time that counts.
|
|
197
|
+
def schedule!(record, step_name, at:, actor:) # rubocop:disable Naming/MethodParameterName
|
|
198
|
+
at = job_delay_floor(record, step_name, at)
|
|
199
|
+
step = WorkflowStep.insert_next!(workflow_id: record.workflow_id, workflow_class: record.workflow_class,
|
|
200
|
+
step_name: step_name, scheduled_at: at)
|
|
201
|
+
event!(record, "step_scheduled", actor: actor, step: step, details: { scheduled_at: at.utc.iso8601(3) })
|
|
202
|
+
step
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def job_delay_floor(record, step_name, at) # rubocop:disable Naming/MethodParameterName
|
|
206
|
+
options = record.workflow_klass&.step_job_options_for(step_name) || {}
|
|
207
|
+
[at, options[:wait] && (Time.current + options[:wait]), options[:wait_until]].compact.max
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# "hostname:pid", stamped on every attempt a worker claims.
|
|
211
|
+
def worker_tag = "#{Socket.gethostname}:#{::Process.pid}"
|
|
212
|
+
|
|
213
|
+
def payload_for(record, **extra)
|
|
214
|
+
{ workflow_class: record.workflow_class, workflow_id: record.workflow_id }.merge(extra)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -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
|