pgbus 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +104 -9
  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 +130 -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/workflow_entry.rb +172 -0
  16. data/app/models/pgbus/workflow_event.rb +43 -0
  17. data/app/models/pgbus/workflow_signal.rb +77 -0
  18. data/app/models/pgbus/workflow_step.rb +142 -0
  19. data/app/views/layouts/pgbus/application.html.erb +2 -0
  20. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  21. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  22. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  23. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  24. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  25. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  26. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  27. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  28. data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
  29. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  30. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  31. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  32. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  33. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  34. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  35. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  36. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  37. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  38. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  39. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  40. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  41. data/app/views/pgbus/workflows/index.html.erb +15 -0
  42. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  43. data/app/views/pgbus/workflows/show.html.erb +93 -0
  44. data/config/i18n-tasks.yml +6 -0
  45. data/config/locales/da.yml +442 -0
  46. data/config/locales/de.yml +442 -0
  47. data/config/locales/en.yml +442 -0
  48. data/config/locales/es.yml +442 -0
  49. data/config/locales/fi.yml +442 -0
  50. data/config/locales/fr.yml +442 -0
  51. data/config/locales/it.yml +442 -0
  52. data/config/locales/ja.yml +442 -0
  53. data/config/locales/nb.yml +442 -0
  54. data/config/locales/nl.yml +442 -0
  55. data/config/locales/pt.yml +442 -0
  56. data/config/locales/sv.yml +442 -0
  57. data/config/routes.rb +26 -0
  58. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  59. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  60. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  61. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  62. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  63. data/lib/pgbus/active_job/adapter.rb +67 -26
  64. data/lib/pgbus/active_job/batch_id.rb +23 -1
  65. data/lib/pgbus/active_job/delivery.rb +49 -0
  66. data/lib/pgbus/active_job/executor.rb +3 -1
  67. data/lib/pgbus/batch/builder.rb +1 -0
  68. data/lib/pgbus/batch.rb +35 -3
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +21 -3
  71. data/lib/pgbus/generators/migration_detector.rb +40 -0
  72. data/lib/pgbus/instrumentation.rb +32 -0
  73. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  74. data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
  75. data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
  76. data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
  77. data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
  78. data/lib/pgbus/mcp/rack_app.rb +7 -2
  79. data/lib/pgbus/mcp/runner.rb +2 -1
  80. data/lib/pgbus/mcp/serialization.rb +25 -0
  81. data/lib/pgbus/mcp/server.rb +53 -10
  82. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  83. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  85. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  86. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  87. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  88. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  90. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  91. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  92. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  93. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  94. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  95. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  96. data/lib/pgbus/mcp/write_tool.rb +134 -0
  97. data/lib/pgbus/mcp.rb +15 -0
  98. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  99. data/lib/pgbus/metrics/subscriber.rb +14 -1
  100. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  101. data/lib/pgbus/process/dispatcher.rb +26 -0
  102. data/lib/pgbus/version.rb +1 -1
  103. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  104. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  105. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  106. data/lib/pgbus/web/data_source.rb +3 -0
  107. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  108. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  109. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  110. data/lib/pgbus/web/workflow_payload.rb +36 -0
  111. data/lib/pgbus/web/workflow_query.rb +82 -0
  112. data/lib/pgbus/web/workflow_state.rb +76 -0
  113. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  114. data/lib/pgbus/web/workflow_window.rb +51 -0
  115. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  116. data/lib/pgbus/workflow/dsl.rb +105 -0
  117. data/lib/pgbus/workflow/error_capture.rb +63 -0
  118. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  119. data/lib/pgbus/workflow/fan_out.rb +157 -0
  120. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  121. data/lib/pgbus/workflow/flow_control.rb +94 -0
  122. data/lib/pgbus/workflow/instance.rb +183 -0
  123. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  124. data/lib/pgbus/workflow/payload.rb +53 -0
  125. data/lib/pgbus/workflow/registry.rb +53 -0
  126. data/lib/pgbus/workflow/removed_step.rb +14 -0
  127. data/lib/pgbus/workflow/runner.rb +218 -0
  128. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  129. data/lib/pgbus/workflow/signals.rb +132 -0
  130. data/lib/pgbus/workflow/states.rb +119 -0
  131. data/lib/pgbus/workflow/step_context.rb +58 -0
  132. data/lib/pgbus/workflow/step_definition.rb +131 -0
  133. data/lib/pgbus/workflow/step_execution.rb +242 -0
  134. data/lib/pgbus/workflow/step_job.rb +28 -0
  135. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  136. data/lib/pgbus/workflow/steps.rb +103 -0
  137. data/lib/pgbus/workflow/sweep.rb +156 -0
  138. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  139. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  140. data/lib/pgbus/workflow/waiting.rb +82 -0
  141. data/lib/pgbus/workflow.rb +183 -0
  142. data/sig/pgbus/configuration.rbs +22 -0
  143. metadata +95 -1
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # One declared step: its name, its body (a block or the name of an
6
+ # instance method) and its options. Built and validated when the class
7
+ # body runs, so a bad option fails at load, not on the first run.
8
+ class StepDefinition
9
+ OPTIONS = %i[wait skip_if job_options timeout on_exception wait_for wait_timeout on_timeout].freeze
10
+ # What a wait_for: step does when its wait_timeout: passes first.
11
+ ON_TIMEOUT = %i[pause! cancel! skip! run].freeze
12
+ # The StepJob options a class (set_step_job_options) or a step
13
+ # (job_options:) may set. Anything else raises at class load.
14
+ JOB_OPTIONS = %i[queue priority wait wait_until].freeze
15
+
16
+ attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
17
+ :wait_for, :wait_for_name, :wait_timeout, :on_timeout
18
+
19
+ def self.validate_job_options!(options)
20
+ raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
21
+
22
+ options.to_h do |key, value|
23
+ key = key.to_sym
24
+ unless JOB_OPTIONS.include?(key)
25
+ raise ArgumentError,
26
+ "unknown job_options: #{key.inspect} — accepted: #{JOB_OPTIONS.map(&:inspect).join(", ")}"
27
+ end
28
+
29
+ [key, job_option_value(key, value)]
30
+ end.freeze
31
+ end
32
+
33
+ def self.job_option_value(key, value)
34
+ case key
35
+ when :queue
36
+ raise ArgumentError, "job_options: queue must be a queue name, got #{value.inspect}" if value.to_s.strip.empty?
37
+
38
+ -value.to_s
39
+ when :priority
40
+ return value if value.is_a?(Integer)
41
+
42
+ raise ArgumentError, "job_options: priority must be an Integer, got #{value.inspect}"
43
+ when :wait then seconds(value, "job_options: wait", positive: false)
44
+ when :wait_until
45
+ # Time, DateTime and TimeWithZone; not a String, which ActiveSupport
46
+ # also gives #to_time (nil for an unparseable one).
47
+ return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String) && value.to_time
48
+
49
+ raise ArgumentError, "job_options: wait_until must be a Time, got #{value.inspect}"
50
+ else value
51
+ end
52
+ end
53
+
54
+ # Seconds as an Integer when whole, else a Float. Durations are
55
+ # accepted; nil stays nil.
56
+ def self.seconds(value, option, positive:)
57
+ return nil if value.nil?
58
+
59
+ seconds = duration?(value) ? value.to_f : value
60
+ unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && (positive ? seconds.positive? : !seconds.negative?)
61
+ requirement = positive ? "a positive" : "a non-negative"
62
+ raise ArgumentError, "#{option}: must be #{requirement} number of seconds or a Duration, got #{value.inspect}"
63
+ end
64
+
65
+ seconds == seconds.to_i ? seconds.to_i : seconds.to_f
66
+ end
67
+
68
+ def self.duration?(value)
69
+ defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
70
+ end
71
+
72
+ def initialize(name:, body:, wait: nil, skip_if: nil, job_options: {}, timeout: nil, on_exception: nil,
73
+ wait_for: nil, wait_timeout: nil, on_timeout: nil)
74
+ @name = -name.to_s
75
+ @body = body
76
+ @wait = self.class.seconds(wait, "wait", positive: false) || 0
77
+ @skip_if = validate_skip_if(skip_if)
78
+ @job_options = self.class.validate_job_options!(job_options.nil? ? {} : job_options)
79
+ @timeout = self.class.seconds(timeout, "timeout", positive: true)
80
+ @exception_policies = ExceptionPolicy.coerce_all(on_exception)
81
+ assign_wait(wait_for, wait_timeout, on_timeout)
82
+ freeze
83
+ end
84
+
85
+ def kind = "step"
86
+
87
+ # True for a wait_for: step: it parks until a matching signal arrives.
88
+ def waits_for_signal? = !wait_for.nil?
89
+
90
+ def removed? = false
91
+
92
+ # Runs the body on +workflow+; the return value is ignored.
93
+ def invoke(workflow)
94
+ body.is_a?(Symbol) ? workflow.send(body) : workflow.instance_exec(&body)
95
+ nil
96
+ end
97
+
98
+ def skip?(workflow)
99
+ return false if skip_if.nil?
100
+
101
+ result = skip_if.is_a?(Symbol) ? workflow.send(skip_if) : workflow.instance_exec(&skip_if)
102
+ result ? true : false
103
+ end
104
+
105
+ private
106
+
107
+ def assign_wait(wait_for, wait_timeout, on_timeout)
108
+ @wait_for = wait_for.nil? ? nil : SignalMatcher.coerce(wait_for)
109
+ @wait_for_name = @wait_for && SignalMatcher.label(@wait_for)
110
+ raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
111
+ raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
112
+
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)
115
+ end
116
+
117
+ def normalize_on_timeout(value)
118
+ verb = value.to_s == "run" ? :run : :"#{value.to_s.delete_suffix("!")}!"
119
+ return verb if ON_TIMEOUT.include?(verb)
120
+
121
+ raise ArgumentError, "on_timeout: must be one of #{ON_TIMEOUT.map(&:inspect).join(", ")}, got #{value.inspect}"
122
+ end
123
+
124
+ def validate_skip_if(value)
125
+ return value if value.nil? || value.is_a?(Symbol) || value.is_a?(Proc)
126
+
127
+ raise ArgumentError, "skip_if: must be a lambda, a Proc or a method name, got #{value.inspect}"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -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