pgbus 0.20.1 → 0.21.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -8
  3. data/README.md +1 -1
  4. data/app/helpers/pgbus/workflow_insights_helper.rb +8 -0
  5. data/app/models/pgbus/workflow_entry.rb +3 -2
  6. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +3 -0
  7. data/config/locales/da.yml +3 -0
  8. data/config/locales/de.yml +3 -0
  9. data/config/locales/en.yml +3 -0
  10. data/config/locales/es.yml +3 -0
  11. data/config/locales/fi.yml +3 -0
  12. data/config/locales/fr.yml +3 -0
  13. data/config/locales/it.yml +3 -0
  14. data/config/locales/ja.yml +3 -0
  15. data/config/locales/nb.yml +3 -0
  16. data/config/locales/nl.yml +3 -0
  17. data/config/locales/pt.yml +3 -0
  18. data/config/locales/sv.yml +3 -0
  19. data/lib/generators/pgbus/templates/add_workflows.rb.erb +3 -1
  20. data/lib/generators/pgbus/templates/migration.rb.erb +3 -1
  21. data/lib/pgbus/batch/sweep.rb +10 -5
  22. data/lib/pgbus/client.rb +109 -42
  23. data/lib/pgbus/configuration/workflow_settings.rb +1 -1
  24. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +1 -1
  25. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +1 -1
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/data_source/workflow_list.rb +6 -4
  28. data/lib/pgbus/web/data_source.rb +39 -22
  29. data/lib/pgbus/web/workflow_insights_report.rb +11 -3
  30. data/lib/pgbus/web/workflow_state.rb +5 -2
  31. data/lib/pgbus/workflow/instance.rb +36 -3
  32. data/lib/pgbus/workflow/runner.rb +2 -2
  33. data/lib/pgbus/workflow/signal_peek.rb +47 -0
  34. data/lib/pgbus/workflow/signals.rb +31 -10
  35. data/lib/pgbus/workflow/step_definition.rb +45 -3
  36. data/lib/pgbus/workflow/step_execution.rb +27 -10
  37. data/lib/pgbus/workflow/wait_timeout.rb +38 -7
  38. data/lib/pgbus/workflow.rb +11 -5
  39. data/sig/pgbus/client.rbs +7 -5
  40. data/sig/pgbus.rbs +43 -0
  41. metadata +2 -1
@@ -15,8 +15,11 @@ module Pgbus
15
15
  # (job_options:) may set. Anything else raises at class load.
16
16
  JOB_OPTIONS = %i[queue priority wait wait_until].freeze
17
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).
18
21
  attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
19
- :wait_for, :wait_for_name, :wait_timeout, :on_timeout
22
+ :wait_for, :wait_for_name, :wait_timeout, :wait_timeout_callable, :on_timeout
20
23
 
21
24
  def self.validate_job_options!(options)
22
25
  raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
@@ -104,6 +107,44 @@ module Pgbus
104
107
  result ? true : false
105
108
  end
106
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
+
107
148
  private
108
149
 
109
150
  def assign_wait(wait_for, wait_timeout, on_timeout)
@@ -112,8 +153,9 @@ module Pgbus
112
153
  raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
113
154
  raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
114
155
 
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)
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)
117
159
  end
118
160
 
119
161
  def normalize_on_timeout(value)
@@ -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)
@@ -14,7 +14,8 @@ module Pgbus
14
14
  # received_signal nil and timed_out? true
15
15
  # :notify only the event and the instrumentation (issue #565): the
16
16
  # same attempt keeps waiting and timeout_at moves to its next
17
- # deadline, one wait_timeout on (see next_deadline)
17
+ # deadline, one wait_timeout on (see next_deadline); a per-run
18
+ # wait_timeout: is evaluated again for it (see notify_deadline)
18
19
  #
19
20
  # A signal that arrives after the timeout fired stays pending, like any
20
21
  # signal no step waits for, except under :notify, whose step still waits
@@ -56,12 +57,12 @@ module Pgbus
56
57
  timeout_at: step.timeout_at&.utc&.iso8601(3) }.compact)
57
58
  notes.instrument("pgbus.workflow.wait_timed_out",
58
59
  Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, action: action.to_s))
59
- apply(record, step, action, notes: notes, now: now, wait_timeout: definition&.wait_timeout)
60
+ apply(record, step, action, notes: notes, now: now, definition: definition)
60
61
  action
61
62
  end
62
63
 
63
- def apply(record, step, action, notes:, now:, wait_timeout: nil)
64
- return notify!(step, wait_timeout, now: now) if action == :notify
64
+ def apply(record, step, action, notes:, now:, definition: nil)
65
+ return notify!(record, step, definition, notes: notes, now: now) if action == :notify
65
66
  return Waiting.wake!(record, step, now: now, timeout_at: [step.timeout_at, now].compact.min) if action == :run
66
67
 
67
68
  error = action == :pause! ? timed_out_error(step, now) : nil
@@ -69,12 +70,42 @@ module Pgbus
69
70
  end
70
71
 
71
72
  # The run and the step row stay as they are (no outcome, no StepJob);
72
- # only timeout_at moves.
73
- def notify!(step, wait_timeout, now:)
74
- timeout_at = next_deadline(step.timeout_at, wait_timeout, now)
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
75
82
  step.timeout_at = timeout_at if WorkflowStep.move_deadline!(step.id, timeout_at: timeout_at).positive?
76
83
  end
77
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
+
78
109
  # The first deadline after +now+ on the wait's wait_timeout grid: one
79
110
  # wait_timeout on when the sweep is within a period of the deadline
80
111
  # (and for expire_wait ahead of it). A sweep that missed several
@@ -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
data/sig/pgbus/client.rbs CHANGED
@@ -13,7 +13,7 @@ module Pgbus
13
13
  def convert_archive_partitioned: (untyped queue_name, ?partition_interval: untyped, ?retention_interval: untyped, ?leading_partition: untyped) -> untyped
14
14
  def create_fifo_index: (untyped queue_name) -> untyped
15
15
  def create_fifo_indexes_all: () -> untyped
16
- def dead_letter_physical_name: (untyped queue_name) -> untyped
16
+ def dead_letter_physical_name: (untyped queue_name, ?prefixed: untyped) -> untyped
17
17
  def delete_batch: (untyped queue_name, untyped msg_ids, ?prefixed: untyped) -> untyped
18
18
  def delete_message: (untyped queue_name, untyped msg_id, ?prefixed: untyped) -> untyped
19
19
  def drop_queue: (untyped queue_name, ?prefixed: untyped) -> untyped
@@ -25,10 +25,12 @@ module Pgbus
25
25
  def in_recovery?: () -> bool
26
26
  def list_notify_insert_throttles: () -> untyped
27
27
  def list_queues: () -> untyped
28
- def message_archived?: (untyped queue_name, msg_id: untyped) -> bool
29
- def message_exists?: (untyped queue_name, ?msg_id: untyped, ?uniqueness_key: untyped) -> bool
30
- def message_in_queue?: (untyped queue_name, msg_id: untyped) -> bool
31
- def message_with_job_id?: (untyped queue_name, job_id: untyped) -> bool
28
+ def locate_message: (untyped queue_name, msg_id: untyped, ?job_id: untyped, ?prefixed: untyped) -> String?
29
+ def locate_messages: (untyped queue_name, Array[[untyped, untyped]] messages, ?prefixed: untyped) -> Hash[Integer, String]
30
+ def message_archived?: (untyped queue_name, msg_id: untyped, ?prefixed: untyped) -> bool?
31
+ def message_exists?: (untyped queue_name, ?msg_id: untyped, ?uniqueness_key: untyped, ?prefixed: untyped) -> bool?
32
+ def message_in_queue?: (untyped queue_name, msg_id: untyped, ?prefixed: untyped) -> bool?
33
+ def message_with_job_id?: (untyped queue_name, job_id: untyped, ?prefixed: untyped) -> bool?
32
34
  def metrics: (?untyped queue_name) -> untyped
33
35
  def move_to_dead_letter: (untyped queue_name, untyped message, ?headers: untyped) -> untyped
34
36
  def notify_enabled?: (untyped queue_name) -> bool
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.1
4
+ version: 0.21.1
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