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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -6
  3. data/README.md +3 -2
  4. data/app/frontend/pgbus/modules/charts.js +18 -0
  5. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  6. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  7. data/app/helpers/pgbus/workflow_insights_helper.rb +8 -0
  8. data/app/helpers/pgbus/workflows_helper.rb +2 -2
  9. data/app/models/pgbus/blocked_execution.rb +12 -0
  10. data/app/models/pgbus/uniqueness_key.rb +88 -3
  11. data/app/models/pgbus/workflow_entry.rb +3 -2
  12. data/app/models/pgbus/workflow_step.rb +7 -0
  13. data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
  14. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +3 -0
  15. data/config/locales/da.yml +7 -1
  16. data/config/locales/de.yml +7 -1
  17. data/config/locales/en.yml +7 -1
  18. data/config/locales/es.yml +7 -1
  19. data/config/locales/fi.yml +7 -1
  20. data/config/locales/fr.yml +7 -1
  21. data/config/locales/it.yml +7 -1
  22. data/config/locales/ja.yml +7 -1
  23. data/config/locales/nb.yml +7 -1
  24. data/config/locales/nl.yml +7 -1
  25. data/config/locales/pt.yml +7 -1
  26. data/config/locales/sv.yml +7 -1
  27. data/lib/generators/pgbus/templates/add_workflows.rb.erb +3 -1
  28. data/lib/generators/pgbus/templates/migration.rb.erb +3 -1
  29. data/lib/pgbus/active_job/adapter.rb +81 -3
  30. data/lib/pgbus/active_job/delivery.rb +20 -6
  31. data/lib/pgbus/active_job/executor.rb +18 -8
  32. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  33. data/lib/pgbus/configuration/workflow_settings.rb +1 -1
  34. data/lib/pgbus/configuration.rb +43 -1
  35. data/lib/pgbus/dead_letter_header.rb +9 -1
  36. data/lib/pgbus/instrumentation.rb +3 -2
  37. data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
  38. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +1 -1
  39. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +1 -1
  40. data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
  41. data/lib/pgbus/process/consumer.rb +2 -1
  42. data/lib/pgbus/process/dispatcher.rb +13 -3
  43. data/lib/pgbus/process/worker.rb +10 -1
  44. data/lib/pgbus/recurring/schedule.rb +1 -1
  45. data/lib/pgbus/streams/presence.rb +65 -36
  46. data/lib/pgbus/uniqueness.rb +23 -6
  47. data/lib/pgbus/version.rb +1 -1
  48. data/lib/pgbus/web/data_source/workflow_list.rb +52 -7
  49. data/lib/pgbus/web/data_source.rb +123 -15
  50. data/lib/pgbus/web/workflow_insights_report.rb +11 -3
  51. data/lib/pgbus/web/workflow_state.rb +19 -3
  52. data/lib/pgbus/web/workflow_timeline.rb +23 -6
  53. data/lib/pgbus/workflow/instance.rb +36 -3
  54. data/lib/pgbus/workflow/runner.rb +2 -2
  55. data/lib/pgbus/workflow/signal_peek.rb +47 -0
  56. data/lib/pgbus/workflow/signals.rb +31 -10
  57. data/lib/pgbus/workflow/step_definition.rb +49 -5
  58. data/lib/pgbus/workflow/step_execution.rb +27 -10
  59. data/lib/pgbus/workflow/wait_timeout.rb +56 -4
  60. data/lib/pgbus/workflow.rb +11 -5
  61. data/sig/pgbus/configuration.rbs +1 -0
  62. data/sig/pgbus.rbs +43 -0
  63. 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 and
42
- # timed_out? on +workflow+.
43
- def gate(record, step, definition, workflow, now: Time.current)
44
- if timed_out?(step, now)
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 = WorkflowSignal.held_by_step(record.workflow_id, step.step_name) || match(record, definition.wait_for)
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
- 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)
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
- @wait_timeout = self.class.seconds(wait_timeout, "wait_timeout", positive: true)
114
- @on_timeout = @wait_timeout && normalize_on_timeout(on_timeout.nil? ? :pause! : on_timeout)
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 == "run" ? :run : :"#{value.to_s.delete_suffix("!")}!"
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
- Claim = Data.define(:record, :step, :klass, :definition, :workflow, :guard_error)
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
- return skip!(record, step, notes, klass) if klass.workflow_steps.removed?(step.step_name)
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
- workflow.send(:enter_step, StepContext::Attempt.new(step_id: step.id, step_name: step.step_name, attempt: step.attempt))
114
- claim = Claim.new(record: record, step: step, klass: klass, definition: definition, workflow: workflow, guard_error: nil)
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. A paused run's wait does not time out until
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}")
@@ -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+ is reserved: with one, a second start! with the same
116
- # key returns the first run (any status) with duplicate_start? true.
117
- # Raises AlreadyRunning when the subject already has an ongoing run.
118
- def start!(subject = nil, idempotency_key: nil, **arguments)
119
- Runner.start(self, subject: subject, arguments: arguments, idempotency_key: idempotency_key)
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
@@ -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.20.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