pgbus 0.20.0 → 0.21.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 +59 -6
- data/README.md +3 -2
- data/app/frontend/pgbus/modules/charts.js +18 -0
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/helpers/pgbus/workflow_insights_helper.rb +8 -0
- data/app/helpers/pgbus/workflows_helper.rb +2 -2
- 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 +3 -2
- data/app/models/pgbus/workflow_step.rb +7 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +3 -0
- data/config/locales/da.yml +7 -1
- data/config/locales/de.yml +7 -1
- data/config/locales/en.yml +7 -1
- data/config/locales/es.yml +7 -1
- data/config/locales/fi.yml +7 -1
- data/config/locales/fr.yml +7 -1
- data/config/locales/it.yml +7 -1
- data/config/locales/ja.yml +7 -1
- data/config/locales/nb.yml +7 -1
- data/config/locales/nl.yml +7 -1
- data/config/locales/pt.yml +7 -1
- data/config/locales/sv.yml +7 -1
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +3 -1
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -1
- data/lib/pgbus/active_job/adapter.rb +81 -3
- data/lib/pgbus/active_job/delivery.rb +20 -6
- data/lib/pgbus/active_job/executor.rb +18 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +1 -1
- data/lib/pgbus/configuration.rb +43 -1
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/instrumentation.rb +3 -2
- data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +1 -1
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +1 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +13 -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_list.rb +52 -7
- data/lib/pgbus/web/data_source.rb +123 -15
- data/lib/pgbus/web/workflow_insights_report.rb +11 -3
- data/lib/pgbus/web/workflow_state.rb +19 -3
- data/lib/pgbus/web/workflow_timeline.rb +23 -6
- data/lib/pgbus/workflow/instance.rb +36 -3
- data/lib/pgbus/workflow/runner.rb +2 -2
- data/lib/pgbus/workflow/signal_peek.rb +47 -0
- data/lib/pgbus/workflow/signals.rb +31 -10
- data/lib/pgbus/workflow/step_definition.rb +49 -5
- data/lib/pgbus/workflow/step_execution.rb +27 -10
- data/lib/pgbus/workflow/wait_timeout.rb +56 -4
- data/lib/pgbus/workflow.rb +11 -5
- data/sig/pgbus/configuration.rbs +1 -0
- data/sig/pgbus.rbs +43 -0
- metadata +2 -1
|
@@ -12,6 +12,8 @@ module Pgbus
|
|
|
12
12
|
# lock: a signal this step already holds (woken by signal!, or claimed by
|
|
13
13
|
# an earlier attempt that reattempted or paused on a failure), else the
|
|
14
14
|
# oldest pending one its matcher takes, else the step parks as waiting.
|
|
15
|
+
# The guards run first; woken? and signal_ready? (issue #572) show them
|
|
16
|
+
# what the gate holds and would take, through SignalPeek, claiming nothing.
|
|
15
17
|
#
|
|
16
18
|
# A claimed signal is consumed when its step ends, except when that
|
|
17
19
|
# attempt reattempts or fails into a pause: the next attempt runs with it.
|
|
@@ -38,23 +40,38 @@ module Pgbus
|
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
# 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
|
|
42
|
-
# timed_out? on +workflow+.
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# or timed out), false when the step parked. Sets received_signal,
|
|
44
|
+
# timed_out?, woken? and signal_ready? on +workflow+. Which signal it
|
|
45
|
+
# takes is SignalPeek#ready: given the attempt's +peek+ (the StepJob
|
|
46
|
+
# passes the one its guards read), the very answer signal_ready? gave.
|
|
47
|
+
def gate(record, step, definition, workflow, now: Time.current, peek: nil)
|
|
48
|
+
peek ||= SignalPeek.new(record, step, definition, now: now)
|
|
49
|
+
if peek.timed_out?
|
|
45
50
|
workflow.send(:enter_wait, signal: nil, timed_out: true)
|
|
46
51
|
return true
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
signal =
|
|
50
|
-
return park(record, step, definition, now) unless signal
|
|
54
|
+
signal = peek.ready
|
|
55
|
+
return park(record, step, definition, workflow, now) unless signal
|
|
51
56
|
|
|
57
|
+
woken = !peek.held.nil?
|
|
52
58
|
claim!(record, step, signal, actor: Runner::SYSTEM_ACTOR) if signal.status == "pending"
|
|
53
59
|
step.update_columns(signal_id: signal.id) unless step.signal_id == signal.id
|
|
54
|
-
workflow.send(:enter_wait, signal: signal, timed_out: false)
|
|
60
|
+
workflow.send(:enter_wait, signal: signal, timed_out: false, woken: woken)
|
|
55
61
|
true
|
|
56
62
|
end
|
|
57
63
|
|
|
64
|
+
# An attempt that ended before its gate (cancel_if, skip_if, a guard
|
|
65
|
+
# that raised, a removed_step) records +held+, the signal the step
|
|
66
|
+
# holds, on its row, so settle treats it as for any attempt: consumed
|
|
67
|
+
# when the step ends, kept when it reattempts or fails into a pause. A
|
|
68
|
+
# post-reattempt row carries no signal_id until its gate runs. A
|
|
69
|
+
# pending signal stays pending: no step took it, and a later wait_for:
|
|
70
|
+
# step may.
|
|
71
|
+
def adopt_held(step, held)
|
|
72
|
+
step.update_columns(signal_id: held.id) if held && step.signal_id != held.id
|
|
73
|
+
end
|
|
74
|
+
|
|
58
75
|
# The step's attempt ended (StepOutcome.apply): consume the signal it
|
|
59
76
|
# holds, unless the next attempt runs with it.
|
|
60
77
|
def settle(step, status, column, now:)
|
|
@@ -116,14 +133,18 @@ module Pgbus
|
|
|
116
133
|
nil
|
|
117
134
|
end
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
# A per-run wait_timeout: is evaluated here, before Waiting.park! writes
|
|
137
|
+
# the waiting state: one that raises (an invalid return raises
|
|
138
|
+
# ArgumentError) leaves the attempt in_progress for the step's exception
|
|
139
|
+
# policies; nil parks it without a deadline.
|
|
140
|
+
def park(record, step, definition, workflow, now) # rubocop:disable Naming/PredicateMethod -- the gate's false: the body does not run
|
|
141
|
+
timeout_at = definition.timeout_at_for(workflow, now)
|
|
121
142
|
Waiting.park!(record, step, now: now, wait_for: definition.wait_for_name, timeout_at: timeout_at)
|
|
122
143
|
false
|
|
123
144
|
end
|
|
124
145
|
|
|
125
146
|
# This attempt waited, holds no signal, and its wait_timeout passed: the
|
|
126
|
-
# sweep woke it with on_timeout: :run.
|
|
147
|
+
# sweep woke it with on_timeout: :run (SignalPeek#timed_out?).
|
|
127
148
|
def timed_out?(step, now)
|
|
128
149
|
!step.waiting_since.nil? && step.signal_id.nil? && !step.timeout_at.nil? && step.timeout_at <= now + 1
|
|
129
150
|
end
|
|
@@ -8,13 +8,18 @@ module Pgbus
|
|
|
8
8
|
class StepDefinition
|
|
9
9
|
OPTIONS = %i[wait skip_if job_options timeout on_exception wait_for wait_timeout on_timeout].freeze
|
|
10
10
|
# What a wait_for: step does when its wait_timeout: passes first.
|
|
11
|
-
ON_TIMEOUT = %i[pause! cancel! skip! run].freeze
|
|
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
|
|
12
14
|
# The StepJob options a class (set_step_job_options) or a step
|
|
13
15
|
# (job_options:) may set. Anything else raises at class load.
|
|
14
16
|
JOB_OPTIONS = %i[queue priority wait wait_until].freeze
|
|
15
17
|
|
|
18
|
+
# wait_timeout is the static wait_timeout: in seconds (nil for a per-run
|
|
19
|
+
# one); wait_timeout_callable the per-run one as given, a lambda or a
|
|
20
|
+
# method name (issue #574).
|
|
16
21
|
attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
|
|
17
|
-
:wait_for, :wait_for_name, :wait_timeout, :on_timeout
|
|
22
|
+
:wait_for, :wait_for_name, :wait_timeout, :wait_timeout_callable, :on_timeout
|
|
18
23
|
|
|
19
24
|
def self.validate_job_options!(options)
|
|
20
25
|
raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
|
|
@@ -102,6 +107,44 @@ module Pgbus
|
|
|
102
107
|
result ? true : false
|
|
103
108
|
end
|
|
104
109
|
|
|
110
|
+
# True when wait_timeout: is a lambda or a method name, evaluated per run.
|
|
111
|
+
def dynamic_wait_timeout? = !wait_timeout_callable.nil?
|
|
112
|
+
|
|
113
|
+
# The timeout_at of the step parking now on +workflow+: now plus a
|
|
114
|
+
# static wait_timeout:, what a per-run one returns (a deadline already
|
|
115
|
+
# past is now, so the next sweep applies on_timeout), or nil.
|
|
116
|
+
def timeout_at_for(workflow, now)
|
|
117
|
+
return now + wait_timeout if wait_timeout
|
|
118
|
+
return nil unless dynamic_wait_timeout?
|
|
119
|
+
|
|
120
|
+
deadline = resolve_wait_timeout(workflow)
|
|
121
|
+
deadline.is_a?(Time) ? [deadline, now].max : deadline && (now + deadline)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What the per-run wait_timeout: returns on +workflow+: a Time (an
|
|
125
|
+
# absolute deadline), seconds from now, or nil (no timeout). It runs as
|
|
126
|
+
# a guard does (a flow-control verb raises NotInStep), in a savepoint:
|
|
127
|
+
# a query of its own that fails in the database must not abort the
|
|
128
|
+
# run's transaction, which the step's outcome is written in. Anything
|
|
129
|
+
# else raises ArgumentError, as an error it raises goes up, for the
|
|
130
|
+
# step's exception policies.
|
|
131
|
+
def resolve_wait_timeout(workflow)
|
|
132
|
+
callable = wait_timeout_callable
|
|
133
|
+
value = WorkflowEntry.transaction(requires_new: true) do
|
|
134
|
+
workflow.send(:guarding) { callable.is_a?(Symbol) ? workflow.send(callable) : workflow.instance_exec(&callable) }
|
|
135
|
+
end
|
|
136
|
+
return nil if value.nil?
|
|
137
|
+
if value.is_a?(Numeric) || self.class.duration?(value)
|
|
138
|
+
return self.class.seconds(value, "wait_timeout (step #{name})", positive: true)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
time = value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
|
|
142
|
+
return time if time.is_a?(Time)
|
|
143
|
+
|
|
144
|
+
raise ArgumentError, "wait_timeout: of step #{name} must return a Time, a Duration, a number of seconds or nil, " \
|
|
145
|
+
"got #{value.inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
105
148
|
private
|
|
106
149
|
|
|
107
150
|
def assign_wait(wait_for, wait_timeout, on_timeout)
|
|
@@ -110,12 +153,13 @@ module Pgbus
|
|
|
110
153
|
raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
|
|
111
154
|
raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
|
|
112
155
|
|
|
113
|
-
@
|
|
114
|
-
@
|
|
156
|
+
@wait_timeout_callable = wait_timeout if wait_timeout.is_a?(Symbol) || wait_timeout.is_a?(Proc)
|
|
157
|
+
@wait_timeout = @wait_timeout_callable ? nil : self.class.seconds(wait_timeout, "wait_timeout", positive: true)
|
|
158
|
+
@on_timeout = (@wait_timeout || @wait_timeout_callable) && normalize_on_timeout(on_timeout.nil? ? :pause! : on_timeout)
|
|
115
159
|
end
|
|
116
160
|
|
|
117
161
|
def normalize_on_timeout(value)
|
|
118
|
-
verb = value.to_s
|
|
162
|
+
verb = PLAIN_ON_TIMEOUT.include?(value.to_s) ? value.to_sym : :"#{value.to_s.delete_suffix("!")}!"
|
|
119
163
|
return verb if ON_TIMEOUT.include?(verb)
|
|
120
164
|
|
|
121
165
|
raise ArgumentError, "on_timeout: must be one of #{ON_TIMEOUT.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
@@ -13,7 +13,8 @@ module Pgbus
|
|
|
13
13
|
# heartbeat is on: its worker died, and the stall action applies now.
|
|
14
14
|
# 2. Guards, still under the lock: the class or the step no longer
|
|
15
15
|
# defined (pause with StepNotDefined, or skip a removed_step),
|
|
16
|
-
# cancel_if, skip_if
|
|
16
|
+
# cancel_if, skip_if (which read woken? and signal_ready? through the
|
|
17
|
+
# attempt's SignalPeek), then a wait_for: step's signal gate.
|
|
17
18
|
# 3. The body, outside any transaction, inside `catch` for the
|
|
18
19
|
# flow-control verbs and `rescue StandardError` for the policies.
|
|
19
20
|
# 4. The outcome, under the lock again (StepOutcome).
|
|
@@ -21,7 +22,9 @@ module Pgbus
|
|
|
21
22
|
# Whatever the step's outcome, the job itself completes normally: the
|
|
22
23
|
# step row and the pgbus.workflow.* events carry the truth.
|
|
23
24
|
class StepExecution
|
|
24
|
-
|
|
25
|
+
# +adopt_held+: a guard raised before the gate, so the signal the step
|
|
26
|
+
# holds is put on this attempt's row before its outcome (Signals.adopt_held).
|
|
27
|
+
Claim = Data.define(:record, :step, :klass, :definition, :workflow, :guard_error, :adopt_held)
|
|
25
28
|
|
|
26
29
|
def initialize(step_id, delivery: Pgbus::ActiveJob::Delivery.current, config: Pgbus.configuration)
|
|
27
30
|
@step_id = step_id
|
|
@@ -103,15 +106,23 @@ module Pgbus
|
|
|
103
106
|
return undefined!(record, step, notes, "#{record.workflow_class} is not a Pgbus::Workflow class any more") unless klass
|
|
104
107
|
|
|
105
108
|
unless definition
|
|
106
|
-
|
|
109
|
+
if klass.workflow_steps.removed?(step.step_name)
|
|
110
|
+
# The definition is gone, so whether it waited is unknown: look for a
|
|
111
|
+
# held signal only where the signal schema exists.
|
|
112
|
+
Signals.adopt_held(step, WorkflowSignal.held_by_step(record.workflow_id, step.step_name)) if Sweep.waiting_ready?
|
|
113
|
+
return skip!(record, step, notes, klass)
|
|
114
|
+
end
|
|
107
115
|
|
|
108
116
|
return undefined!(record, step, notes, "#{klass.name} has no step #{step.step_name} " \
|
|
109
117
|
"(declare `removed_step :#{step.step_name}` to skip it)")
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
workflow = klass.new(record)
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
peek = SignalPeek.new(record, step, definition)
|
|
122
|
+
workflow.send(:enter_step, StepContext::Attempt.new(step_id: step.id, step_name: step.step_name, attempt: step.attempt),
|
|
123
|
+
signals: peek)
|
|
124
|
+
claim = Claim.new(record: record, step: step, klass: klass, definition: definition, workflow: workflow, guard_error: nil,
|
|
125
|
+
adopt_held: false)
|
|
115
126
|
begin
|
|
116
127
|
verdict = workflow.send(:guarding) do
|
|
117
128
|
next FlowControl::Outcome.cancel if cancel?(klass, workflow)
|
|
@@ -119,20 +130,23 @@ module Pgbus
|
|
|
119
130
|
FlowControl::Outcome.skip if definition.skip?(workflow)
|
|
120
131
|
end
|
|
121
132
|
rescue StandardError => e
|
|
122
|
-
return claim.with(guard_error: e)
|
|
133
|
+
return claim.with(guard_error: e, adopt_held: definition.waits_for_signal?)
|
|
134
|
+
end
|
|
135
|
+
if verdict
|
|
136
|
+
Signals.adopt_held(step, peek.held)
|
|
137
|
+
return outcome!(record, step, notes, klass, verdict)
|
|
123
138
|
end
|
|
124
|
-
return outcome!(record, step, notes, klass, verdict) if verdict
|
|
125
139
|
|
|
126
|
-
gate(record, step, definition, workflow, claim)
|
|
140
|
+
gate(record, step, definition, workflow, claim, peek)
|
|
127
141
|
end
|
|
128
142
|
|
|
129
143
|
# A wait_for: step runs only with a signal (or once its wait timed
|
|
130
144
|
# out); without one it parks and this delivery ends. A matcher that
|
|
131
145
|
# raises is handed to the exception policies, like a guard.
|
|
132
|
-
def gate(record, step, definition, workflow, claim)
|
|
146
|
+
def gate(record, step, definition, workflow, claim, peek)
|
|
133
147
|
return claim unless definition.waits_for_signal?
|
|
134
148
|
|
|
135
|
-
Signals.gate(record, step, definition, workflow) ? claim : nil
|
|
149
|
+
Signals.gate(record, step, definition, workflow, peek: peek) ? claim : nil
|
|
136
150
|
rescue StandardError => e
|
|
137
151
|
claim.with(guard_error: e)
|
|
138
152
|
end
|
|
@@ -185,6 +199,9 @@ module Pgbus
|
|
|
185
199
|
next park_fan_out(record, claim, outcome, notes) if outcome&.action == :fan_out
|
|
186
200
|
|
|
187
201
|
notes.report(decision.handler_error, handler_context(claim)) if decision&.handler_error
|
|
202
|
+
# Here rather than in the claim's transaction, which the statement
|
|
203
|
+
# a guard failed on may have aborted.
|
|
204
|
+
Signals.adopt_held(claim.step, SignalPeek.new(record, claim.step, claim.definition).held) if claim.adopt_held
|
|
188
205
|
StepOutcome.apply(record, claim.step, outcome, notes: notes, error: error, output: claim.workflow.pending_output,
|
|
189
206
|
report: decision ? decision.report : true, duration: duration,
|
|
190
207
|
klass: claim.klass)
|
|
@@ -12,10 +12,14 @@ module Pgbus
|
|
|
12
12
|
# :skip! the step is skipped and the run moves on
|
|
13
13
|
# :run the step is scheduled again; its body runs with
|
|
14
14
|
# received_signal nil and timed_out? true
|
|
15
|
+
# :notify only the event and the instrumentation (issue #565): the
|
|
16
|
+
# same attempt keeps waiting and timeout_at moves to its next
|
|
17
|
+
# deadline, one wait_timeout on (see next_deadline); a per-run
|
|
18
|
+
# wait_timeout: is evaluated again for it (see notify_deadline)
|
|
15
19
|
#
|
|
16
20
|
# A signal that arrives after the timeout fired stays pending, like any
|
|
17
|
-
# signal no step waits for
|
|
18
|
-
# it is resumed.
|
|
21
|
+
# signal no step waits for, except under :notify, whose step still waits
|
|
22
|
+
# for it. A paused run's wait does not time out until it is resumed.
|
|
19
23
|
module WaitTimeout
|
|
20
24
|
module_function
|
|
21
25
|
|
|
@@ -53,17 +57,65 @@ module Pgbus
|
|
|
53
57
|
timeout_at: step.timeout_at&.utc&.iso8601(3) }.compact)
|
|
54
58
|
notes.instrument("pgbus.workflow.wait_timed_out",
|
|
55
59
|
Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, action: action.to_s))
|
|
56
|
-
apply(record, step, action, notes: notes, now: now)
|
|
60
|
+
apply(record, step, action, notes: notes, now: now, definition: definition)
|
|
57
61
|
action
|
|
58
62
|
end
|
|
59
63
|
|
|
60
|
-
def apply(record, step, action, notes:, now:)
|
|
64
|
+
def apply(record, step, action, notes:, now:, definition: nil)
|
|
65
|
+
return notify!(record, step, definition, notes: notes, now: now) if action == :notify
|
|
61
66
|
return Waiting.wake!(record, step, now: now, timeout_at: [step.timeout_at, now].compact.min) if action == :run
|
|
62
67
|
|
|
63
68
|
error = action == :pause! ? timed_out_error(step, now) : nil
|
|
64
69
|
Waiting.resolve!(record, step, FlowControl::Outcome.for(action), notes: notes, error: error)
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
# The run and the step row stay as they are (no outcome, no StepJob);
|
|
73
|
+
# only timeout_at moves. A per-run wait_timeout: that raises here, or
|
|
74
|
+
# returns what is no deadline, pauses the run with that error: the
|
|
75
|
+
# sweep holds the run's row lock, under which no on_exception block
|
|
76
|
+
# may run, so the exception policies do not apply.
|
|
77
|
+
def notify!(record, step, definition, notes:, now:)
|
|
78
|
+
timeout_at = notify_deadline(record, step, definition, now)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
Waiting.resolve!(record, step, FlowControl::Outcome.pause, notes: notes, error: e)
|
|
81
|
+
else
|
|
82
|
+
step.timeout_at = timeout_at if WorkflowStep.move_deadline!(step.id, timeout_at: timeout_at).positive?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The next deadline of a :notify wait. A per-run wait_timeout: (issue
|
|
86
|
+
# #574) is evaluated again: a Time after now is the next deadline, a
|
|
87
|
+
# duration moves it one period on as a static one does, and nil or a
|
|
88
|
+
# Time not after now leaves the wait without a deadline (it was
|
|
89
|
+
# reported once and keeps waiting).
|
|
90
|
+
def notify_deadline(record, step, definition, now)
|
|
91
|
+
return next_deadline(step.timeout_at, definition.wait_timeout, now) unless definition.dynamic_wait_timeout?
|
|
92
|
+
|
|
93
|
+
deadline = definition.resolve_wait_timeout(instance_for(record, step))
|
|
94
|
+
case deadline
|
|
95
|
+
when nil then nil
|
|
96
|
+
when Time then deadline if deadline > now
|
|
97
|
+
else next_deadline(step.timeout_at, deadline, now)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The workflow instance a per-run wait_timeout: is evaluated on, as the
|
|
102
|
+
# StepJob builds it for the attempt.
|
|
103
|
+
def instance_for(record, step)
|
|
104
|
+
record.workflow_klass.new(record).tap do |workflow|
|
|
105
|
+
workflow.send(:enter_step, StepContext::Attempt.new(step_id: step.id, step_name: step.step_name, attempt: step.attempt))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The first deadline after +now+ on the wait's wait_timeout grid: one
|
|
110
|
+
# wait_timeout on when the sweep is within a period of the deadline
|
|
111
|
+
# (and for expire_wait ahead of it). A sweep that missed several
|
|
112
|
+
# deadlines (the dispatcher was down) reports them once: a deadline
|
|
113
|
+
# still behind now would be selected again by the next batch.
|
|
114
|
+
def next_deadline(timeout_at, wait_timeout, now)
|
|
115
|
+
missed = timeout_at > now ? 0 : ((now - timeout_at) / wait_timeout).floor
|
|
116
|
+
timeout_at + ((missed + 1) * wait_timeout)
|
|
117
|
+
end
|
|
118
|
+
|
|
67
119
|
def timed_out_error(step, now)
|
|
68
120
|
WaitTimedOut.new("#{step.step_name} waited for #{step.wait_for} since #{step.waiting_since&.utc&.iso8601} " \
|
|
69
121
|
"and timed out at #{(step.timeout_at || now).utc.iso8601}")
|
data/lib/pgbus/workflow.rb
CHANGED
|
@@ -111,12 +111,18 @@ module Pgbus
|
|
|
111
111
|
#
|
|
112
112
|
# SignupWorkflow.start!(user, plan: "pro") # arguments["plan"]
|
|
113
113
|
# CheckoutWorkflow.start!(order, idempotency_key: "checkout-#{order.id}")
|
|
114
|
+
# ReportWorkflow.start!(actor: "alice", reason: "monthly close")
|
|
114
115
|
#
|
|
115
|
-
# +idempotency_key+
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
# +idempotency_key+, +actor+ and +reason+ are reserved and never reach
|
|
117
|
+
# the arguments. With an idempotency_key, a second start! with the same
|
|
118
|
+
# key returns the first run (any status) with duplicate_start? true and
|
|
119
|
+
# records nothing. +actor+ (default "app") and +reason+ go on the
|
|
120
|
+
# started event, and the actor on the first step_scheduled, as on every
|
|
121
|
+
# outside verb. Raises AlreadyRunning when the subject already has an
|
|
122
|
+
# ongoing run.
|
|
123
|
+
def start!(subject = nil, idempotency_key: nil, actor: nil, reason: nil, **arguments)
|
|
124
|
+
Runner.start(self, subject: subject, arguments: arguments, idempotency_key: idempotency_key,
|
|
125
|
+
actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
|
|
120
126
|
end
|
|
121
127
|
|
|
122
128
|
# The run with this workflow_id, as an instance of the class that
|
data/sig/pgbus/configuration.rbs
CHANGED
|
@@ -293,6 +293,7 @@ module Pgbus
|
|
|
293
293
|
def visibility_heartbeat_interval=: (untyped value) -> untyped
|
|
294
294
|
def visibility_timeout: () -> Numeric?
|
|
295
295
|
def visibility_timeout=: (untyped value) -> untyped
|
|
296
|
+
def warn_queue_prefix_underscores: () -> untyped
|
|
296
297
|
def warn_read_ahead_without_heartbeat: (untyped settings) -> untyped
|
|
297
298
|
def web_actor: () -> untyped
|
|
298
299
|
def web_actor=: (untyped) -> untyped
|
data/sig/pgbus.rbs
CHANGED
|
@@ -56,6 +56,49 @@ module Pgbus
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Partial: only the readers a workflow step's guards and body use to see
|
|
60
|
+
# its signal (issue #572), and a step definition's wait_timeout: (issue
|
|
61
|
+
# #574). The rest of Workflow is not covered by spec/pgbus/rbs_spec.rb yet.
|
|
62
|
+
class Workflow
|
|
63
|
+
include Instance
|
|
64
|
+
|
|
65
|
+
class Error < Pgbus::Error
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A step reader (woken?, signal_ready?, ...) called outside a step.
|
|
69
|
+
class NotInStep < Error
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module Instance
|
|
73
|
+
# The Pgbus::WorkflowSignal a wait_for: step took, or nil when its wait timed out.
|
|
74
|
+
def received_signal: () -> untyped
|
|
75
|
+
# The body runs because the wait timed out (on_timeout: :run).
|
|
76
|
+
def timed_out?: () -> bool
|
|
77
|
+
# This attempt holds a signal before its gate: signal! woke it, or an
|
|
78
|
+
# earlier attempt took one and reattempted or paused.
|
|
79
|
+
def woken?: () -> bool
|
|
80
|
+
# The gate runs this attempt with a signal: the held one, or a pending
|
|
81
|
+
# one its wait_for: matcher takes.
|
|
82
|
+
def signal_ready?: () -> bool
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One declared step (klass.workflow_steps.fetch(:name)): its wait_timeout:.
|
|
86
|
+
class StepDefinition
|
|
87
|
+
# A static wait_timeout: in seconds; nil without one, or for a per-run one.
|
|
88
|
+
def wait_timeout: () -> Numeric?
|
|
89
|
+
# A per-run wait_timeout: as declared: a method name or a lambda.
|
|
90
|
+
def wait_timeout_callable: () -> (Symbol | Proc)?
|
|
91
|
+
# wait_timeout: is a method name or a lambda, evaluated per run.
|
|
92
|
+
def dynamic_wait_timeout?: () -> bool
|
|
93
|
+
# The timeout_at of the step parking at +now+ on +workflow+; nil for no timeout.
|
|
94
|
+
# +now+ and the result are a Time or an ActiveSupport::TimeWithZone (Time.current
|
|
95
|
+
# in an app with a time zone), which sig/ does not declare: untyped.
|
|
96
|
+
def timeout_at_for: (Workflow workflow, untyped now) -> untyped
|
|
97
|
+
# What a per-run wait_timeout: returns on +workflow+: a deadline, seconds from now, or nil.
|
|
98
|
+
def resolve_wait_timeout: (Workflow workflow) -> (Time | Numeric)?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
59
102
|
def self.configuration: () -> Configuration
|
|
60
103
|
def self.configuration_snapshot: () -> Configuration::Snapshot
|
|
61
104
|
def self.configure: () { (Configuration) -> void } -> Configuration
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgbus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -588,6 +588,7 @@ files:
|
|
|
588
588
|
- lib/pgbus/workflow/removed_step.rb
|
|
589
589
|
- lib/pgbus/workflow/runner.rb
|
|
590
590
|
- lib/pgbus/workflow/signal_matcher.rb
|
|
591
|
+
- lib/pgbus/workflow/signal_peek.rb
|
|
591
592
|
- lib/pgbus/workflow/signals.rb
|
|
592
593
|
- lib/pgbus/workflow/states.rb
|
|
593
594
|
- lib/pgbus/workflow/step_context.rb
|