pgbus 0.19.4 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +97 -7
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +148 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/blocked_execution.rb +12 -0
  16. data/app/models/pgbus/uniqueness_key.rb +88 -3
  17. data/app/models/pgbus/workflow_entry.rb +172 -0
  18. data/app/models/pgbus/workflow_event.rb +43 -0
  19. data/app/models/pgbus/workflow_signal.rb +77 -0
  20. data/app/models/pgbus/workflow_step.rb +149 -0
  21. data/app/views/layouts/pgbus/application.html.erb +2 -0
  22. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  23. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  24. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  25. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  26. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  27. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  28. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  29. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  30. data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
  31. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  32. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  33. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  34. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  35. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  36. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  37. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  38. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  39. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  40. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  41. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  42. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  43. data/app/views/pgbus/workflows/index.html.erb +15 -0
  44. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  45. data/app/views/pgbus/workflows/show.html.erb +93 -0
  46. data/config/i18n-tasks.yml +6 -0
  47. data/config/locales/da.yml +445 -0
  48. data/config/locales/de.yml +445 -0
  49. data/config/locales/en.yml +445 -0
  50. data/config/locales/es.yml +445 -0
  51. data/config/locales/fi.yml +445 -0
  52. data/config/locales/fr.yml +445 -0
  53. data/config/locales/it.yml +445 -0
  54. data/config/locales/ja.yml +445 -0
  55. data/config/locales/nb.yml +445 -0
  56. data/config/locales/nl.yml +445 -0
  57. data/config/locales/pt.yml +445 -0
  58. data/config/locales/sv.yml +445 -0
  59. data/config/routes.rb +26 -0
  60. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  61. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  62. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  63. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  64. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  65. data/lib/pgbus/active_job/adapter.rb +81 -3
  66. data/lib/pgbus/active_job/delivery.rb +63 -0
  67. data/lib/pgbus/active_job/executor.rb +20 -8
  68. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +64 -4
  71. data/lib/pgbus/dead_letter_header.rb +9 -1
  72. data/lib/pgbus/generators/migration_detector.rb +40 -0
  73. data/lib/pgbus/instrumentation.rb +33 -0
  74. data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
  75. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  76. data/lib/pgbus/mcp/rack_app.rb +7 -2
  77. data/lib/pgbus/mcp/runner.rb +2 -1
  78. data/lib/pgbus/mcp/serialization.rb +25 -0
  79. data/lib/pgbus/mcp/server.rb +53 -10
  80. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  81. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  83. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  84. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  85. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  86. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  87. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  88. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  90. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  91. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  92. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  93. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  94. data/lib/pgbus/mcp/write_tool.rb +134 -0
  95. data/lib/pgbus/mcp.rb +15 -0
  96. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  97. data/lib/pgbus/metrics/subscriber.rb +14 -1
  98. data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
  99. data/lib/pgbus/process/consumer.rb +2 -1
  100. data/lib/pgbus/process/dispatcher.rb +39 -3
  101. data/lib/pgbus/process/worker.rb +10 -1
  102. data/lib/pgbus/recurring/schedule.rb +1 -1
  103. data/lib/pgbus/streams/presence.rb +65 -36
  104. data/lib/pgbus/uniqueness.rb +23 -6
  105. data/lib/pgbus/version.rb +1 -1
  106. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  107. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  108. data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
  109. data/lib/pgbus/web/data_source.rb +126 -15
  110. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  111. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  112. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  113. data/lib/pgbus/web/workflow_payload.rb +36 -0
  114. data/lib/pgbus/web/workflow_query.rb +82 -0
  115. data/lib/pgbus/web/workflow_state.rb +89 -0
  116. data/lib/pgbus/web/workflow_timeline.rb +130 -0
  117. data/lib/pgbus/web/workflow_window.rb +51 -0
  118. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  119. data/lib/pgbus/workflow/dsl.rb +105 -0
  120. data/lib/pgbus/workflow/error_capture.rb +63 -0
  121. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  122. data/lib/pgbus/workflow/fan_out.rb +157 -0
  123. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  124. data/lib/pgbus/workflow/flow_control.rb +94 -0
  125. data/lib/pgbus/workflow/instance.rb +183 -0
  126. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  127. data/lib/pgbus/workflow/payload.rb +53 -0
  128. data/lib/pgbus/workflow/registry.rb +53 -0
  129. data/lib/pgbus/workflow/removed_step.rb +14 -0
  130. data/lib/pgbus/workflow/runner.rb +218 -0
  131. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  132. data/lib/pgbus/workflow/signals.rb +132 -0
  133. data/lib/pgbus/workflow/states.rb +119 -0
  134. data/lib/pgbus/workflow/step_context.rb +58 -0
  135. data/lib/pgbus/workflow/step_definition.rb +133 -0
  136. data/lib/pgbus/workflow/step_execution.rb +242 -0
  137. data/lib/pgbus/workflow/step_job.rb +28 -0
  138. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  139. data/lib/pgbus/workflow/steps.rb +103 -0
  140. data/lib/pgbus/workflow/sweep.rb +156 -0
  141. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  142. data/lib/pgbus/workflow/wait_timeout.rb +94 -0
  143. data/lib/pgbus/workflow/waiting.rb +82 -0
  144. data/lib/pgbus/workflow.rb +183 -0
  145. data/sig/pgbus/configuration.rbs +23 -0
  146. metadata +92 -1
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # One StepJob delivery: claim the step row, check the guards, run the
6
+ # body in a fresh workflow instance, apply the outcome.
7
+ #
8
+ # 1. Claim, under the run's row lock: scheduled → in_progress (only once
9
+ # due) and the run ready → performing. A row that is not claimable
10
+ # (already running elsewhere, finished, early, or its run is paused or
11
+ # gone) makes this delivery a no-op, except a redelivery of the very
12
+ # message that claimed an in_progress row while the visibility
13
+ # heartbeat is on: its worker died, and the stall action applies now.
14
+ # 2. Guards, still under the lock: the class or the step no longer
15
+ # defined (pause with StepNotDefined, or skip a removed_step),
16
+ # cancel_if, skip_if.
17
+ # 3. The body, outside any transaction, inside `catch` for the
18
+ # flow-control verbs and `rescue StandardError` for the policies.
19
+ # 4. The outcome, under the lock again (StepOutcome).
20
+ #
21
+ # Whatever the step's outcome, the job itself completes normally: the
22
+ # step row and the pgbus.workflow.* events carry the truth.
23
+ class StepExecution
24
+ Claim = Data.define(:record, :step, :klass, :definition, :workflow, :guard_error)
25
+
26
+ def initialize(step_id, delivery: Pgbus::ActiveJob::Delivery.current, config: Pgbus.configuration)
27
+ @step_id = step_id
28
+ @delivery = delivery
29
+ @config = config
30
+ end
31
+
32
+ def call
33
+ step = WorkflowStep.find_by(id: @step_id)
34
+ return debug("step #{@step_id} no longer exists (its run was deleted)") unless step
35
+
36
+ StepContext.with(workflow_id: step.workflow_id, workflow_class: step.workflow_class, step_name: step.step_name,
37
+ attempt: step.attempt) do
38
+ claim = claim(step)
39
+ run(claim) if claim
40
+ end
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :config
47
+
48
+ # The worker tag and the app revision (which may call the app's
49
+ # app_revision callable or read a file) are resolved before the run's
50
+ # row lock is taken.
51
+ def claim(step)
52
+ @stamp = { worker: Runner.worker_tag, app_revision: Deploy::Revision.current(config) }
53
+ Runner.locked(step.workflow_id) do |record, notes|
54
+ step = WorkflowStep.find_by(id: step.id) if record
55
+ if record.nil? || step.nil?
56
+ debug("step #{@step_id}'s run no longer exists")
57
+ elsif step.status == "in_progress"
58
+ check_redelivery(record, step, notes)
59
+ elsif claimable?(record, step)
60
+ claim_step(record, step, notes)
61
+ else
62
+ debug("step #{step.id} is not claimable (step #{step.status}, run #{record.status}, due #{step.scheduled_at})")
63
+ end
64
+ end
65
+ end
66
+
67
+ def claimable?(record, step)
68
+ step.status == "scheduled" && States.legal?(:claim, record.status) && step.scheduled_at <= Time.current + 1
69
+ end
70
+
71
+ # A live worker keeps its message invisible (VisibilityHeartbeat), so
72
+ # the same message coming back for a row it claimed means that worker
73
+ # is gone. Without the heartbeat a long live step can outlive its
74
+ # visibility timeout, so the sweep's stall_at decides instead.
75
+ def check_redelivery(record, step, notes)
76
+ msg_id = @delivery && @delivery[:msg_id]
77
+ redelivered = !msg_id.nil? && step.msg_id == msg_id.to_i && @delivery[:read_ct].to_i > 1 && config.visibility_heartbeat
78
+ return debug("step #{step.id} is already in progress elsewhere") unless redelivered
79
+
80
+ StepOutcome.stall!(record, step, detection: "redelivery", notes: notes, config: config)
81
+ nil
82
+ end
83
+
84
+ def claim_step(record, step, notes)
85
+ now = Time.current
86
+ klass = record.workflow_klass
87
+ definition = klass&.workflow_steps&.fetch(step.step_name)
88
+ attrs = { stall_at: now + (definition&.timeout || config.workflow_stall_threshold), msg_id: @delivery&.dig(:msg_id),
89
+ **@stamp }
90
+ return nil if WorkflowStep.claim!(step.id, now: now, **attrs).zero?
91
+
92
+ step.assign_attributes(status: "in_progress", started_at: now, **attrs)
93
+ Runner.transition!(record, :claim, current_step: step.step_name)
94
+ Runner.event!(record, "step_started", actor: Runner::SYSTEM_ACTOR, step: step,
95
+ details: attrs.slice(:worker, :app_revision, :msg_id).compact)
96
+ guard(record, step, klass, definition, notes)
97
+ end
98
+
99
+ # Returns the Claim to run, or nil when a guard already ended the
100
+ # attempt. A guard that raises is handed to the exception policies,
101
+ # like a body that raises.
102
+ def guard(record, step, klass, definition, notes)
103
+ return undefined!(record, step, notes, "#{record.workflow_class} is not a Pgbus::Workflow class any more") unless klass
104
+
105
+ unless definition
106
+ return skip!(record, step, notes, klass) if klass.workflow_steps.removed?(step.step_name)
107
+
108
+ return undefined!(record, step, notes, "#{klass.name} has no step #{step.step_name} " \
109
+ "(declare `removed_step :#{step.step_name}` to skip it)")
110
+ end
111
+
112
+ 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)
115
+ begin
116
+ verdict = workflow.send(:guarding) do
117
+ next FlowControl::Outcome.cancel if cancel?(klass, workflow)
118
+
119
+ FlowControl::Outcome.skip if definition.skip?(workflow)
120
+ end
121
+ rescue StandardError => e
122
+ return claim.with(guard_error: e)
123
+ end
124
+ return outcome!(record, step, notes, klass, verdict) if verdict
125
+
126
+ gate(record, step, definition, workflow, claim)
127
+ end
128
+
129
+ # A wait_for: step runs only with a signal (or once its wait timed
130
+ # out); without one it parks and this delivery ends. A matcher that
131
+ # raises is handed to the exception policies, like a guard.
132
+ def gate(record, step, definition, workflow, claim)
133
+ return claim unless definition.waits_for_signal?
134
+
135
+ Signals.gate(record, step, definition, workflow) ? claim : nil
136
+ rescue StandardError => e
137
+ claim.with(guard_error: e)
138
+ end
139
+
140
+ def cancel?(klass, workflow)
141
+ klass.cancel_conditions.any? do |condition|
142
+ condition.is_a?(Symbol) ? workflow.send(condition) : workflow.instance_exec(&condition)
143
+ end
144
+ end
145
+
146
+ def undefined!(record, step, notes, message)
147
+ StepOutcome.apply(record, step, FlowControl::Outcome.pause, notes: notes, error: StepNotDefined.new(message), klass: nil)
148
+ nil
149
+ end
150
+
151
+ def skip!(record, step, notes, klass) = outcome!(record, step, notes, klass, FlowControl::Outcome.skip)
152
+
153
+ def outcome!(record, step, notes, klass, outcome)
154
+ StepOutcome.apply(record, step, outcome, notes: notes, klass: klass, duration: 0.0)
155
+ nil
156
+ end
157
+
158
+ def run(claim)
159
+ started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
160
+ outcome, error = claim.guard_error ? [nil, claim.guard_error] : execute(claim)
161
+ duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started
162
+ finish(claim, outcome, error, duration)
163
+ end
164
+
165
+ def execute(claim)
166
+ outcome = catch(FlowControl::THROW_TAG) do
167
+ claim.definition.invoke(claim.workflow)
168
+ nil
169
+ end
170
+ [outcome || FlowControl::Outcome.complete, nil]
171
+ rescue StandardError => e
172
+ [nil, e]
173
+ end
174
+
175
+ # The exception policy is decided before the run's lock is taken
176
+ # again: an imperative on_exception block is app code, and must not
177
+ # hold the row lock (and an open transaction) while it runs.
178
+ def finish(claim, outcome, error, duration)
179
+ return finish_late(claim, error) if error && !attempt_active?(claim)
180
+
181
+ decision = error && decide(claim, error)
182
+ outcome = decision.outcome if decision
183
+ Runner.locked(claim.record.workflow_id) do |record, notes|
184
+ next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
185
+ next park_fan_out(record, claim, outcome, notes) if outcome&.action == :fan_out
186
+
187
+ notes.report(decision.handler_error, handler_context(claim)) if decision&.handler_error
188
+ StepOutcome.apply(record, claim.step, outcome, notes: notes, error: error, output: claim.workflow.pending_output,
189
+ report: decision ? decision.report : true, duration: duration,
190
+ klass: claim.klass)
191
+ end
192
+ end
193
+
194
+ def park_fan_out(record, claim, outcome, notes)
195
+ FanOut.park(record, claim.step, outcome, notes: notes, output: claim.workflow.pending_output)
196
+ end
197
+
198
+ # While its body runs, a row leaves in_progress only for a terminal
199
+ # status (a parked row went waiting before any body ran), so an attempt
200
+ # that finds it gone has been declared stalled (or its run cancelled): late.
201
+ def attempt_active?(claim) = WorkflowStep.where(id: claim.step.id, status: "in_progress").exists?
202
+
203
+ # A late attempt that raised: its outcome is ignored either way, so an
204
+ # imperative on_exception block must not run (and cause side effects)
205
+ # for it. A declarative policy still names the outcome it would have
206
+ # applied and decides the report; an imperative one records none.
207
+ def finish_late(claim, error)
208
+ policy = policy_for(claim, error)
209
+ decision = policy.handler ? nil : decide(claim, error, policy: policy)
210
+ column = decision && StepOutcome::STEP_OUTCOME.fetch(decision.outcome.action)
211
+ report = decision ? decision.report : policy.report != :never
212
+ Runner.locked(claim.record.workflow_id) do |record, notes|
213
+ next debug("step #{claim.step.id}'s run was deleted while the step ran") unless record
214
+
215
+ StepOutcome.ignore_late_completion(record, claim.step, "failed", column, error)
216
+ notes.report(error, StepOutcome.error_context(record, claim.step, "late_completion_ignored")) if report
217
+ end
218
+ end
219
+
220
+ def policy_for(claim, error)
221
+ ExceptionPolicy.resolve(error, step_policies: claim.definition.exception_policies,
222
+ class_policies: claim.klass.exception_policies)
223
+ end
224
+
225
+ def decide(claim, error, policy: policy_for(claim, error))
226
+ step = claim.step
227
+ policy.decide(error, workflow: claim.workflow,
228
+ consecutive_reattempts: -> { WorkflowStep.consecutive_reattempts(step.workflow_id, step.step_name) })
229
+ end
230
+
231
+ def handler_context(claim)
232
+ { action: "workflow_on_exception", workflow_class: claim.klass.name, workflow_id: claim.step.workflow_id,
233
+ step_name: claim.step.step_name, attempt: claim.step.attempt }
234
+ end
235
+
236
+ def debug(message)
237
+ Pgbus.logger.debug { "[Pgbus::Workflow] #{message} #{StepContext.log_tag}".rstrip }
238
+ nil
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Pgbus
6
+ class Workflow
7
+ # The job behind every step attempt: perform(step_id) runs one
8
+ # StepExecution. Enqueued only by Pgbus::WorkflowStep's after-commit
9
+ # callback, on config.workflow_queue unless the class
10
+ # (set_step_job_options) or the step (job_options:) says otherwise.
11
+ #
12
+ # No retry_on and no discard_on: a step's failure is the exception
13
+ # policies' business, and the job returns normally whatever the step's
14
+ # outcome. Redelivery is reserved for a worker that died mid-step, which
15
+ # the claim recognises. The visibility heartbeat must stay on for this
16
+ # class (never `pgbus_visibility_heartbeat false`): fast crash detection
17
+ # depends on a live worker keeping its message invisible.
18
+ class StepJob < ::ActiveJob::Base
19
+ # Always on, whatever ActiveJob::Base or the app set: the executor reads
20
+ # this, and a redelivery means a dead worker only while it is true.
21
+ def self.pgbus_visibility_heartbeat_enabled = true # rubocop:disable Naming/PredicateMethod -- JobMixin's reader
22
+
23
+ def perform(step_id)
24
+ StepExecution.new(step_id).call
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Applies how a step attempt ended to its row and to the run, under the
6
+ # run's row lock: the StepJob after a body, a guard (cancel_if, skip_if,
7
+ # a removed or unknown step), crash detection and the sweep.
8
+ #
9
+ # The step row is written first, guarded on status = 'in_progress'. A
10
+ # row that is no longer in_progress was declared stalled (or the run was
11
+ # cancelled) while this attempt was still running: the outcome and its
12
+ # output are discarded and only a late_completion_ignored event is
13
+ # recorded. Otherwise the run moves by the States table: advance (the
14
+ # next step is scheduled), finish, reattempt, step_pause, step_cancel, or
15
+ # hold when an outside pause! got there first.
16
+ module StepOutcome
17
+ STEP_STATUS = { complete: "completed", finish: "completed", pause: "completed",
18
+ skip: "skipped", cancel: "cancelled", reattempt: "failed" }.freeze
19
+ STEP_OUTCOME = { complete: "completed", finish: "finished_early", pause: "paused",
20
+ skip: "skipped", cancel: "cancelled", reattempt: "reattempted" }.freeze
21
+
22
+ module_function
23
+
24
+ # Returns false when the attempt had lost its in_progress guard.
25
+ def apply(record, step, outcome, notes:, error: nil, output: nil, report: true, duration: nil, klass: record.workflow_klass) # rubocop:disable Naming/PredicateMethod
26
+ now = Time.current
27
+ status = error ? "failed" : STEP_STATUS.fetch(outcome.action)
28
+ column = STEP_OUTCOME.fetch(outcome.action)
29
+ captured = error && ErrorCapture.capture(error)
30
+ attrs = { status: status, outcome: column, finished_at: now, output: output, **captured.to_h }
31
+ if WorkflowStep.finish!(step.id, **attrs).zero?
32
+ ignore_late_completion(record, step, status, column, error)
33
+ notes.report(error, error_context(record, step, "late_completion_ignored")) if error && report
34
+ return false
35
+ end
36
+
37
+ Signals.settle(step, status, column, now: now)
38
+ step_events(record, step, outcome, column, error)
39
+ move_run(record, step, outcome, notes: notes, captured: captured, klass: klass, now: now)
40
+ notes.instrument("pgbus.workflow.step", step_payload(record, step, column, status, error, duration))
41
+ notes.report(error, error_context(record, step, column)) if error && report
42
+ true
43
+ end
44
+
45
+ # A step whose worker died (detection: "redelivery") or that outlived
46
+ # its stall_at (detection: "sweep"): config.workflow_stall_action
47
+ # applies, with Pgbus::Workflow::StepStalled recorded on the row.
48
+ def stall!(record, step, detection:, notes:, config: Pgbus.configuration)
49
+ why = if detection == "redelivery"
50
+ "its message came back while it was in_progress: the worker #{step.worker} died"
51
+ else
52
+ "it was still in_progress at its stall_at #{step.stall_at&.utc&.iso8601} (worker #{step.worker})"
53
+ end
54
+ error = StepStalled.new("#{step.step_name} attempt #{step.attempt} stalled: #{why}")
55
+ Runner.event!(record, "stall_detected", actor: Runner::SYSTEM_ACTOR, step: step,
56
+ details: { detection: detection, worker: step.worker,
57
+ stall_at: step.stall_at&.utc&.iso8601(3) }.compact)
58
+ notes.instrument("pgbus.workflow.stall_detected",
59
+ Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, detection: detection))
60
+ apply(record, step, stall_outcome(config.workflow_stall_action), notes: notes, error: error)
61
+ end
62
+
63
+ def stall_outcome(action)
64
+ case action
65
+ when :reattempt then FlowControl::Outcome.reattempt
66
+ when :cancel then FlowControl::Outcome.cancel
67
+ else FlowControl::Outcome.pause
68
+ end
69
+ end
70
+
71
+ def move_run(record, step, outcome, notes:, captured:, klass:, now:)
72
+ paused = record.status == "paused"
73
+ case outcome.action
74
+ when :complete, :skip
75
+ following = klass&.workflow_steps&.next_after(step.step_name)
76
+ following ? advance(record, following, paused: paused, now: now) : finish(record, notes)
77
+ when :finish then finish(record, notes)
78
+ when :reattempt
79
+ Runner.schedule!(record, step.step_name, at: now + (outcome.wait || 0), actor: Runner::SYSTEM_ACTOR)
80
+ Runner.transition!(record, paused ? :hold : :reattempt, current_step: step.step_name)
81
+ when :pause then pause(record, step, outcome, notes, captured: captured, paused: paused, now: now)
82
+ when :cancel then cancel(record, step, outcome, notes, now: now)
83
+ end
84
+ end
85
+
86
+ def advance(record, following, paused:, now:)
87
+ Runner.schedule!(record, following.name, at: now + following.wait, actor: Runner::SYSTEM_ACTOR)
88
+ Runner.transition!(record, paused ? :hold : :advance, current_step: following.name)
89
+ end
90
+
91
+ def finish(record, notes)
92
+ Runner.transition!(record, :finish, finished_at: Time.current)
93
+ Runner.event!(record, "finished", actor: Runner::SYSTEM_ACTOR)
94
+ notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
95
+ end
96
+
97
+ # The run row keeps the error that paused it (cleared by resume!).
98
+ def pause(record, step, outcome, notes, captured:, paused:, now:)
99
+ run_error = captured ? captured.except(:error_causes) : {}
100
+ return Runner.transition!(record, :hold, **run_error) if paused
101
+
102
+ error_class = captured&.fetch(:error_class)
103
+ Runner.transition!(record, :step_pause, paused_at: now, **run_error)
104
+ Runner.event!(record, "paused", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step,
105
+ details: { error_class: error_class }.compact)
106
+ notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: step.step_name, error_class: error_class))
107
+ end
108
+
109
+ def cancel(record, step, outcome, notes, now:)
110
+ Runner.transition!(record, :step_cancel, cancelled_at: now)
111
+ WorkflowStep.cancel_open!(record.workflow_id, now: now)
112
+ Runner.event!(record, "cancelled", actor: outcome.actor || Runner::SYSTEM_ACTOR, reason: outcome.reason, step: step)
113
+ notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: step.step_name))
114
+ end
115
+
116
+ def step_events(record, step, outcome, column, error)
117
+ actor = Runner::SYSTEM_ACTOR
118
+ if error
119
+ Runner.event!(record, "step_failed", actor: actor, step: step,
120
+ details: { error_class: error.class.name, error_message: error.message.to_s })
121
+ end
122
+ case outcome.action
123
+ when :complete, :finish, :pause
124
+ Runner.event!(record, "step_completed", actor: actor, step: step, details: { outcome: column }) unless error
125
+ when :skip then Runner.event!(record, "step_skipped", actor: outcome.actor || actor, reason: outcome.reason, step: step)
126
+ when :reattempt then Runner.event!(record, "step_reattempted", actor: actor, step: step, details: { wait: outcome.wait }.compact)
127
+ end
128
+ end
129
+
130
+ def ignore_late_completion(record, step, status, column, error)
131
+ details = { status: status, outcome: column, error_class: error&.class&.name }.compact
132
+ Runner.event!(record, "late_completion_ignored", actor: Runner::SYSTEM_ACTOR, step: step, details: details)
133
+ Pgbus.logger.warn do
134
+ "[Pgbus] Workflow #{record.workflow_class} #{record.workflow_id}: step #{step.step_name} attempt #{step.attempt} " \
135
+ "finished after it stopped being in_progress (stalled or cancelled); its outcome (#{column}) and output were discarded"
136
+ end
137
+ end
138
+
139
+ def step_payload(record, step, column, status, error, duration)
140
+ pickup = step.started_at && step.scheduled_at ? [step.started_at - step.scheduled_at, 0].max.to_f : nil
141
+ Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, outcome: column, status: status,
142
+ error_class: error&.class&.name, duration: duration, pickup_latency: pickup)
143
+ end
144
+
145
+ def error_context(record, step, column)
146
+ { workflow_class: record.workflow_class, workflow_id: record.workflow_id, step_name: step.step_name,
147
+ attempt: step.attempt, outcome: column }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The ordered step list of one workflow class: StepDefinitions and
6
+ # RemovedStep gravestones, in declaration order. Immutable: every
7
+ # change (`step`, `removed_step`) builds a new frozen Steps that the
8
+ # class stores whole, so a subclass extends its parent's list without
9
+ # touching it and a reader never sees a list change under it
10
+ # (docs/concurrency.md).
11
+ class Steps
12
+ attr_reader :entries
13
+
14
+ def initialize(entries = [])
15
+ @entries = entries.dup.freeze
16
+ freeze
17
+ end
18
+
19
+ EMPTY = new
20
+
21
+ def add(definition, before: nil, after: nil)
22
+ raise ArgumentError, "use before_step: or after_step:, not both" if before && after
23
+ raise ArgumentError, "#{definition.name} is a removed step (removed_step); pick another name" if removed?(definition.name)
24
+ raise ArgumentError, "duplicate step #{definition.name}" if fetch(definition.name)
25
+
26
+ list = entries.dup
27
+ anchor = before || after
28
+ if anchor
29
+ index = position(anchor)
30
+ unless index
31
+ raise ArgumentError,
32
+ "#{before ? "before_step" : "after_step"}: #{anchor.to_s.to_sym.inspect} — no step named #{anchor}"
33
+ end
34
+
35
+ list.insert(before ? index : index + 1, definition)
36
+ else
37
+ list << definition
38
+ end
39
+ self.class.new(list)
40
+ end
41
+
42
+ # Replaces a live step with a gravestone at its position, or appends a
43
+ # gravestone for a step that is already gone from the class.
44
+ def remove(name)
45
+ name = -name.to_s
46
+ raise ArgumentError, "#{name} is already a removed step" if removed?(name)
47
+
48
+ list = entries.dup
49
+ index = position(name)
50
+ gravestone = RemovedStep.new(name: name)
51
+ index ? list[index] = gravestone : list << gravestone
52
+ self.class.new(list)
53
+ end
54
+
55
+ def definitions = entries.reject(&:removed?)
56
+
57
+ def names = definitions.map(&:name)
58
+
59
+ def removed_names = entries.select(&:removed?).map(&:name)
60
+
61
+ def fetch(name)
62
+ name = name.to_s
63
+ definitions.find { |entry| entry.name == name }
64
+ end
65
+
66
+ def removed?(name)
67
+ name = name.to_s
68
+ entries.any? { |entry| entry.removed? && entry.name == name }
69
+ end
70
+
71
+ def first = definitions.first
72
+
73
+ # The next live step declared after +name+ (a step or a gravestone),
74
+ # or nil when +name+ is the last one or unknown.
75
+ def next_after(name)
76
+ index = position(name)
77
+ return nil unless index
78
+
79
+ entries.drop(index + 1).find { |entry| !entry.removed? }
80
+ end
81
+
82
+ def empty? = definitions.empty?
83
+
84
+ # Name for an anonymous `step { ... }`: step_<n>, the first n from
85
+ # entries.size + 1 not taken by a step or a gravestone.
86
+ def anonymous_name
87
+ number = entries.size + 1
88
+ number += 1 while entries.any? { |entry| entry.name == "step_#{number}" }
89
+ "step_#{number}"
90
+ end
91
+
92
+ # What definition_digest hashes: the ordered [name, kind] pairs.
93
+ def digest_pairs = entries.map { |entry| [entry.name, entry.kind] }
94
+
95
+ private
96
+
97
+ def position(name)
98
+ name = name.to_s
99
+ entries.index { |entry| entry.name == name }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The dispatcher's workflow housekeeping.
6
+ #
7
+ # repair (every config.workflow_sweep_interval):
8
+ # - an in_progress step past its stall_at (started_at + the step's
9
+ # timeout:, or workflow_stall_threshold) gets workflow_stall_action,
10
+ # with StepStalled recorded. A dead worker is usually caught sooner, on
11
+ # redelivery (StepExecution); this catches a live but hung step, and
12
+ # every crash when the visibility heartbeat is off.
13
+ # - a scheduled step of a ready run, due more than workflow_stall_threshold
14
+ # ago and not sent within it, is sent again by bumping enqueued_at (the
15
+ # model callback re-fires). No queue inspection: a duplicate message is
16
+ # a no-op at the claim.
17
+ # - a waiting step of a waiting run past its timeout_at (wait_timeout:)
18
+ # gets its on_timeout (WaitTimeout), so a timeout fires within one
19
+ # workflow_sweep_interval of its deadline.
20
+ # - a fan_out step still waiting on a batch that finished more than
21
+ # workflow_stall_threshold ago lost its BatchFinishedJob (dead-lettered,
22
+ # or never sent): the sweep resolves it as the callback would have.
23
+ # - pgbus.workflow.gauge: runs by class and status, and by class and stuck
24
+ # reason (waiting_overdue: waiting longer than
25
+ # workflow_waiting_visibility_threshold on a step without a timeout).
26
+ #
27
+ # cleanup (hourly): finished and cancelled runs older than
28
+ # workflow_retention are deleted in batches; their steps and events go
29
+ # with them (ON DELETE CASCADE).
30
+ module Sweep
31
+ BATCH_SIZE = 500
32
+
33
+ # The schema probe's memo (a true answer is kept for the life of the
34
+ # process), read and written under the lock; the probe itself runs
35
+ # outside it.
36
+ @schema_lock = Mutex.new
37
+ @tables_ready = false
38
+ @waiting_ready = false
39
+
40
+ class << self
41
+ def repair(config: Pgbus.configuration, now: Time.current)
42
+ return nil unless tables_ready?
43
+
44
+ threshold = config.workflow_stall_threshold
45
+ summary = { stalled: repair_stalled(now: now, config: config), resent: resend_overdue(now: now, threshold: threshold) }
46
+ waiting = waiting_ready?
47
+ summary[:timed_out] = waiting ? WaitTimeout.expire_due(now: now) : 0
48
+ summary[:fan_outs] = waiting ? FanOut.resolve_lost(now: now, grace: threshold) : 0
49
+ emit_gauge(now: now, threshold: threshold, waiting_threshold: (config.workflow_waiting_visibility_threshold if waiting))
50
+ summary
51
+ end
52
+
53
+ def cleanup(retention: Pgbus.configuration.workflow_retention, now: Time.current, batch_size: BATCH_SIZE)
54
+ return 0 unless retention&.positive? && tables_ready?
55
+
56
+ cutoff = now - retention
57
+ deleted = 0
58
+ loop do
59
+ ids = WorkflowEntry.stale(before: cutoff).order(:id).limit(batch_size).pluck(:id)
60
+ break if ids.empty?
61
+
62
+ deleted += WorkflowEntry.where(id: ids).delete_all
63
+ end
64
+ deleted
65
+ end
66
+
67
+ # True once the three workflow tables exist (pgbus:add_workflows).
68
+ def tables_ready?
69
+ return true if @schema_lock.synchronize { @tables_ready }
70
+
71
+ ready = begin
72
+ [WorkflowEntry, WorkflowStep, WorkflowEvent].all?(&:table_exists?)
73
+ rescue StandardError => e
74
+ Pgbus.logger.debug { "[Pgbus] Workflow table probe failed: #{e.class}: #{e.message}" }
75
+ false
76
+ end
77
+ @schema_lock.synchronize { @tables_ready = true } if ready
78
+ ready
79
+ end
80
+
81
+ # True once the waiting-step schema exists (pgbus:add_workflow_signals,
82
+ # or an add_workflows that carries it): the signals table and the
83
+ # step columns. Asked of the database through the connection, not the
84
+ # schema cache (which keeps a negative answer). A process that loaded
85
+ # the step model before the migration still needs a restart to see
86
+ # the new columns, as for any column added under a running app.
87
+ def waiting_ready?
88
+ return true if @schema_lock.synchronize { @waiting_ready }
89
+
90
+ ready = begin
91
+ connection = WorkflowStep.connection
92
+ connection.table_exists?(WorkflowSignal.table_name) && connection.column_exists?(WorkflowStep.table_name, :timeout_at)
93
+ rescue StandardError => e
94
+ Pgbus.logger.debug { "[Pgbus] Workflow waiting-step schema probe failed: #{e.class}: #{e.message}" }
95
+ false
96
+ end
97
+ @schema_lock.synchronize { @waiting_ready = true } if ready
98
+ ready
99
+ end
100
+
101
+ # Specs only.
102
+ def reset_tables_ready!
103
+ @schema_lock.synchronize do
104
+ @tables_ready = false
105
+ @waiting_ready = false
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def repair_stalled(now:, config:)
112
+ WorkflowStep.stalled(now: now).order(:id).limit(BATCH_SIZE).pluck(:id, :workflow_id).count do |id, workflow_id|
113
+ stall(id, workflow_id, now: now, config: config)
114
+ end
115
+ end
116
+
117
+ # Re-checked under the run's lock: the step may have finished since
118
+ # the query.
119
+ def stall(id, workflow_id, now:, config:)
120
+ Runner.locked(workflow_id) do |record, notes|
121
+ step = record && WorkflowStep.find_by(id: id)
122
+ next false unless step&.status == "in_progress" && step.stall_at && step.stall_at < now
123
+
124
+ StepOutcome.stall!(record, step, detection: "sweep", notes: notes, config: config)
125
+ true
126
+ end
127
+ end
128
+
129
+ def resend_overdue(now:, threshold:)
130
+ WorkflowStep.overdue(threshold: threshold, now: now).order(:id).limit(BATCH_SIZE).to_a.each do |step|
131
+ step.update!(enqueued_at: now)
132
+ end.size
133
+ end
134
+
135
+ def emit_gauge(now:, threshold:, waiting_threshold: nil)
136
+ statuses = WorkflowEntry.status_counts
137
+ stuck = WorkflowEntry.stuck_reason_counts(threshold: threshold, now: now, waiting_threshold: waiting_threshold)
138
+ classes = (statuses.keys.map(&:first) + stuck.keys.map(&:first) + Workflow.registered_classes.map(&:name)).uniq.sort
139
+ Instrumentation.instrument(
140
+ "pgbus.workflow.gauge",
141
+ statuses: zero_filled(classes, States::WORKFLOW, statuses, :status),
142
+ stuck: zero_filled(classes, States::STUCK_REASONS, stuck, :reason)
143
+ )
144
+ end
145
+
146
+ # Every class × every value, so a count that drops to zero is
147
+ # reported as zero instead of keeping its last value.
148
+ def zero_filled(classes, values, counts, key)
149
+ classes.product(values).map do |klass, value|
150
+ { workflow_class: klass, key => value, count: counts.fetch([klass, value], 0) }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end