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,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
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Run workflow steps inline in a spec, ignoring their wait:.
6
+ #
7
+ # RSpec.configure { |config| config.include Pgbus::Workflow::TestHelpers }
8
+ #
9
+ # wf = SignupWorkflow.start!(user)
10
+ # perform_next_step(wf) # runs :welcome now
11
+ # run_step_inline(wf, :reminder) # runs :reminder, raising if another step is next
12
+ # speedrun_workflow(wf) # runs every step until the run stops being ready
13
+ # expect(wf.status).to eq("finished")
14
+ #
15
+ # deliver_signal(wf, :shipped, payload: { at: Time.current }) # signal!, then the step it wakes
16
+ # complete_fan_out(wf) # every member of the waiting fan_out's batch completes
17
+ # expire_wait(wf) # the waiting step's on_timeout applies now
18
+ #
19
+ # Each step runs through the real StepJob (claim, guards, policies, the
20
+ # audit trail) against the test database; the StepJob messages the
21
+ # model callback sends meanwhile go to whatever adapter the test uses
22
+ # and are not needed.
23
+ module TestHelpers
24
+ class Error < Workflow::Error; end
25
+
26
+ # Runs the run's scheduled step now. Returns the reloaded workflow.
27
+ def perform_next_step(workflow)
28
+ TestHelpers.ensure_ready!(workflow)
29
+ step = TestHelpers.scheduled_step(workflow) || raise(Error, "#{workflow.inspect} has no scheduled step")
30
+ TestHelpers.perform(step, workflow)
31
+ workflow.reload
32
+ end
33
+
34
+ # Runs the run's scheduled step when it is +step_name+; raises when the
35
+ # run is on another step.
36
+ def run_step_inline(workflow, step_name)
37
+ TestHelpers.ensure_ready!(workflow)
38
+ step = TestHelpers.scheduled_step(workflow)
39
+ unless step&.step_name == step_name.to_s
40
+ raise Error, "#{workflow.inspect} is not about to run #{step_name} (scheduled: #{step&.step_name || "nothing"})"
41
+ end
42
+
43
+ TestHelpers.perform(step, workflow)
44
+ workflow.reload
45
+ end
46
+
47
+ # Runs scheduled steps one after another until the run is no longer
48
+ # ready: it stops at paused, finished and cancelled, and raises
49
+ # Pgbus::Workflow::StepWaiting on waiting, naming what the step waits
50
+ # for (deliver_signal, complete_fan_out or expire_wait moves it on).
51
+ def speedrun_workflow(workflow, max_steps: 1_000)
52
+ max_steps.times do
53
+ case workflow.reload.status
54
+ when "ready" then perform_next_step(workflow)
55
+ when "waiting" then raise StepWaiting, "#{workflow.inspect} #{TestHelpers.waiting_for(workflow)}"
56
+ when "performing"
57
+ raise Error, "#{workflow.inspect} is performing: speedrun_workflow cannot move it on"
58
+ else return workflow
59
+ end
60
+ end
61
+ status = workflow.reload.status
62
+ raise StepWaiting, "#{workflow.inspect} #{TestHelpers.waiting_for(workflow)}" if status == "waiting"
63
+ return workflow unless %w[ready performing].include?(status)
64
+
65
+ raise Error, "#{workflow.inspect} was still #{workflow.status} after #{max_steps} steps"
66
+ end
67
+
68
+ # signal! the run, then run the step it scheduled (the waiting step it
69
+ # woke, or the step that claims a buffered signal). Returns the
70
+ # reloaded workflow.
71
+ # A redelivery (duplicate_delivery?) woke nothing, so it runs nothing.
72
+ def deliver_signal(workflow, name, payload: {}, idempotency_key: nil)
73
+ signal = workflow.signal!(name, payload: payload, idempotency_key: idempotency_key, actor: "test_helpers")
74
+ return workflow.reload if signal.duplicate_delivery?
75
+
76
+ perform_next_step(workflow)
77
+ end
78
+
79
+ # Completes every member of the batch the run's waiting fan_out step
80
+ # waits on (Pgbus::Batch.job_completed, as the executor does), then
81
+ # runs the batch's BatchFinishedJob inline. The members do not run.
82
+ # Returns the reloaded workflow.
83
+ def complete_fan_out(workflow)
84
+ step = TestHelpers.waiting_step(workflow)
85
+ raise Error, "#{workflow.inspect} is not waiting on a fan_out batch" unless step&.batch_id
86
+
87
+ if Pgbus::Batch.executions_migrated?
88
+ Pgbus::BatchExecution.where(batch_id: step.batch_id).order(:id).pluck(:job_id).each do |job_id|
89
+ Pgbus::Batch.job_completed(step.batch_id, job_id: job_id)
90
+ end
91
+ else
92
+ Pgbus::Batch.find(step.batch_id)&.pending_jobs.to_i.times { Pgbus::Batch.job_completed(step.batch_id) }
93
+ end
94
+ BatchFinishedJob.new(step.id).perform_now
95
+ workflow.reload
96
+ end
97
+
98
+ # Applies the waiting step's on_timeout now, whatever its timeout_at.
99
+ # Returns the reloaded workflow.
100
+ def expire_wait(workflow)
101
+ step = TestHelpers.waiting_step(workflow)
102
+ raise Error, "#{workflow.inspect} has no waiting step with a wait_timeout:" unless step&.timeout_at
103
+
104
+ Runner.locked(workflow.workflow_id) do |record, notes|
105
+ WaitTimeout.fire!(record, WorkflowStep.find(step.id), notes: notes)
106
+ end
107
+ workflow.reload
108
+ end
109
+
110
+ # A paused or finished run's scheduled step is not claimable: running
111
+ # it would silently do nothing (and pull its wait forward for resume!).
112
+ def self.ensure_ready!(workflow)
113
+ status = workflow.reload.status
114
+ return if status == "ready"
115
+ raise StepWaiting, "#{workflow.inspect} #{waiting_for(workflow)}" if status == "waiting"
116
+
117
+ hint = case status
118
+ when "paused" then "resume! it first"
119
+ when "finished", "cancelled" then "it is over; rerun! starts a new run"
120
+ when "performing" then "a step is still running"
121
+ else "nothing inline can move it on"
122
+ end
123
+ raise Error, "#{workflow.inspect} is #{status}, not ready: #{hint}"
124
+ end
125
+
126
+ def self.waiting_step(workflow)
127
+ WorkflowStep.where(workflow_id: workflow.workflow_id, status: "waiting").order(:id).last
128
+ end
129
+
130
+ # "is waiting for signal shipped (deliver_signal …)" / "… batch (complete_fan_out …)".
131
+ def self.waiting_for(workflow)
132
+ step = waiting_step(workflow)
133
+ return "is waiting" unless step
134
+ return "is waiting on step #{step.step_name}'s batch #{step.batch_id}: complete_fan_out moves it on" if step.batch_id
135
+
136
+ "is waiting on step #{step.step_name} for signal #{step.wait_for}: deliver_signal moves it on" \
137
+ "#{", expire_wait times it out" if step.timeout_at}"
138
+ end
139
+
140
+ def self.scheduled_step(workflow)
141
+ WorkflowStep.where(workflow_id: workflow.workflow_id, status: "scheduled").order(:id).last
142
+ end
143
+
144
+ # A step still waiting is pulled forward through run_now!, so the audit
145
+ # trail records the skipped wait (actor "test_helpers").
146
+ def self.perform(step, workflow)
147
+ workflow.run_now!(actor: "test_helpers", reason: "run inline by a test helper") if step.scheduled_at > Time.current
148
+ StepJob.new(step.id).perform_now
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # wait_timeout: on a wait_for: step (issue #541). The dispatcher's
6
+ # workflow sweep finds waiting steps of waiting runs whose timeout_at
7
+ # passed and applies their on_timeout under the run's row lock, so a
8
+ # timeout fires within config.workflow_sweep_interval of its deadline:
9
+ #
10
+ # :pause! the step fails with WaitTimedOut and the run pauses
11
+ # :cancel! the run is cancelled
12
+ # :skip! the step is skipped and the run moves on
13
+ # :run the step is scheduled again; its body runs with
14
+ # received_signal nil and timed_out? true
15
+ #
16
+ # A signal that arrives after the timeout fired stays pending, like any
17
+ # signal no step waits for. A paused run's wait does not time out until
18
+ # it is resumed.
19
+ module WaitTimeout
20
+ module_function
21
+
22
+ # Drains every due wait, Sweep::BATCH_SIZE at a time (at most
23
+ # +max_batches+ of them per sweep, so one sweep cannot run unbounded).
24
+ def expire_due(now: Time.current, limit: Sweep::BATCH_SIZE, max_batches: 20)
25
+ total = 0
26
+ max_batches.times do
27
+ batch = WorkflowStep.wait_expired(now: now).order(:id).limit(limit).pluck(:id, :workflow_id)
28
+ total += expire_batch(batch, now: now)
29
+ break if batch.size < limit
30
+ end
31
+ total
32
+ end
33
+
34
+ def expire_batch(batch, now:)
35
+ batch.count do |id, workflow_id|
36
+ Runner.locked(workflow_id) do |record, notes|
37
+ step = record && WorkflowStep.find_by(id: id)
38
+ next false unless record&.status == "waiting" && step&.status == "waiting" && step.timeout_at && step.timeout_at < now
39
+
40
+ fire!(record, step, notes: notes, now: now)
41
+ true
42
+ end
43
+ end
44
+ end
45
+
46
+ # Applies the step's on_timeout now (the sweep, and expire_wait in
47
+ # TestHelpers). Returns the action applied.
48
+ def fire!(record, step, notes:, now: Time.current)
49
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
50
+ action = definition&.on_timeout || :pause!
51
+ Runner.event!(record, "wait_timed_out", actor: Runner::SYSTEM_ACTOR, step: step,
52
+ details: { wait_for: step.wait_for, action: action.to_s,
53
+ timeout_at: step.timeout_at&.utc&.iso8601(3) }.compact)
54
+ notes.instrument("pgbus.workflow.wait_timed_out",
55
+ Runner.payload_for(record, step_name: step.step_name, attempt: step.attempt, action: action.to_s))
56
+ apply(record, step, action, notes: notes, now: now)
57
+ action
58
+ end
59
+
60
+ def apply(record, step, action, notes:, now:)
61
+ return Waiting.wake!(record, step, now: now, timeout_at: [step.timeout_at, now].compact.min) if action == :run
62
+
63
+ error = action == :pause! ? timed_out_error(step, now) : nil
64
+ Waiting.resolve!(record, step, FlowControl::Outcome.for(action), notes: notes, error: error)
65
+ end
66
+
67
+ def timed_out_error(step, now)
68
+ WaitTimedOut.new("#{step.step_name} waited for #{step.wait_for} since #{step.waiting_since&.utc&.iso8601} " \
69
+ "and timed out at #{(step.timeout_at || now).utc.iso8601}")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The waiting state wait_for: steps and fan_out steps share (issue #541),
6
+ # every move under the run's row lock:
7
+ #
8
+ # park! in_progress → waiting the gate found no signal, or the
9
+ # fan_out batch opened (run: park)
10
+ # wake! waiting → scheduled a signal it matches arrived, or the
11
+ # wait timed out with on_timeout: :run
12
+ # (run: wake); the model callback sends
13
+ # the StepJob once the write commits
14
+ # resolve! waiting → (an outcome) the batch finished, the wait timed
15
+ # out, or an operator skipped it: the
16
+ # outcome is applied by StepOutcome as
17
+ # for any finished attempt (run: resolve,
18
+ # then advance, finish, step_pause, ...)
19
+ #
20
+ # A run an outside pause! reached first stays paused (hold): the step row
21
+ # moves, nothing runs, and resume! picks it up.
22
+ module Waiting
23
+ module_function
24
+
25
+ # False when the attempt had already lost its in_progress row (stalled
26
+ # or cancelled while it ran).
27
+ # A fan_out's +output+ (the body's output(...)) waits on the row.
28
+ def park!(record, step, now:, batch_id: nil, wait_for: nil, timeout_at: nil, output: nil) # rubocop:disable Naming/PredicateMethod
29
+ attrs = { waiting_since: now, stall_at: nil, batch_id: batch_id, wait_for: wait_for, timeout_at: timeout_at }
30
+ attrs[:output] = output if output
31
+ return false if WorkflowStep.park!(step.id, **attrs).zero?
32
+
33
+ step.assign_attributes(status: "waiting", **attrs)
34
+ Runner.transition!(record, paused?(record) ? :hold : :park)
35
+ Runner.event!(record, "step_waiting", actor: Runner::SYSTEM_ACTOR, step: step,
36
+ details: { wait_for: wait_for, batch_id: batch_id,
37
+ timeout_at: timeout_at&.utc&.iso8601(3) }.compact)
38
+ true
39
+ end
40
+
41
+ # A paused run's step is written without the model callback, so no
42
+ # StepJob goes out that its claim would only drop; resume! sends it.
43
+ def wake!(record, step, now:, **attrs)
44
+ if paused?(record)
45
+ step.update_columns(status: "scheduled", scheduled_at: now, **attrs)
46
+ Runner.transition!(record, :hold)
47
+ else
48
+ step.update!(status: "scheduled", scheduled_at: now, **attrs)
49
+ Runner.transition!(record, :wake)
50
+ end
51
+ end
52
+
53
+ # Returns false when the row was no longer waiting.
54
+ def resolve!(record, step, outcome, notes:, error: nil)
55
+ return false if WorkflowStep.unpark!(step.id).zero?
56
+
57
+ step.assign_attributes(status: "in_progress")
58
+ Runner.transition!(record, :resolve) unless paused?(record)
59
+ output = step.respond_to?(:output) ? step.output : nil
60
+ StepOutcome.apply(record, step, outcome, notes: notes, error: error, output: output)
61
+ end
62
+
63
+ # start! (and rerun!) of a class with waiting steps needs their schema.
64
+ def check_schema!(klass)
65
+ definitions = klass.workflow_steps.definitions
66
+ fan_out = definitions.any? { |definition| definition.kind == "fan_out" }
67
+ return unless fan_out || definitions.any?(&:waits_for_signal?)
68
+
69
+ unless Sweep.waiting_ready?
70
+ raise WaitingStepsUnmigrated, "#{klass.name} has waiting steps (fan_out or wait_for:): run " \
71
+ "`rails generate pgbus:add_workflow_signals` and migrate first"
72
+ end
73
+ return unless fan_out && !Batch.callback_jobs_migrated?
74
+
75
+ raise BatchCallbacksUnmigrated, "#{klass.name} has a fan_out step, whose batch resumes the run through a " \
76
+ "callback job: run `rails generate pgbus:add_batch_callback_jobs` and migrate first"
77
+ end
78
+
79
+ def paused?(record) = record.status == "paused"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/hash/indifferent_access"
6
+
7
+ module Pgbus
8
+ # A durable multi-step workflow (issue #540). Subclass it, declare steps,
9
+ # start a run:
10
+ #
11
+ # class SignupWorkflow < Pgbus::Workflow
12
+ # cancel_if { subject.nil? || subject.closed? }
13
+ # on_exception Net::OpenTimeout, action: :reattempt!, wait: 15.seconds, max_reattempts: 5
14
+ #
15
+ # step :welcome do
16
+ # WelcomeMailer.with(user: subject).welcome.deliver_later
17
+ # end
18
+ #
19
+ # step :reminder, wait: 2.days, skip_if: -> { subject.onboarded? } do
20
+ # ReminderMailer.with(user: subject).remind.deliver_later
21
+ # end
22
+ # end
23
+ #
24
+ # wf = SignupWorkflow.start!(user, plan: "pro")
25
+ # wf.pause!(actor: "alice", reason: "SMTP down") / wf.resume! / wf.cancel!
26
+ #
27
+ # Each step attempt is a pgbus_workflow_steps row and one
28
+ # Pgbus::Workflow::StepJob message, sent after the row commits. The step
29
+ # body runs on a fresh instance of the class, with +subject+ and
30
+ # +arguments+. Pgbus::WorkflowEntry is the run's row; this class is the
31
+ # DSL and the handle.
32
+ class Workflow
33
+ class Error < Pgbus::Error; end
34
+
35
+ # A second start! for a subject that already has an ongoing run of this
36
+ # class. #workflow is the run that holds the slot.
37
+ class AlreadyRunning < Error
38
+ attr_reader :workflow
39
+
40
+ def initialize(message = nil, workflow: nil)
41
+ super(message)
42
+ @workflow = workflow
43
+ end
44
+ end
45
+
46
+ # A transition the States table does not allow from the workflow's state.
47
+ class InvalidTransition < Error; end
48
+
49
+ # resume!/resume_at! on a workflow that is not paused.
50
+ class NotPaused < InvalidTransition; end
51
+
52
+ # A row names a step the class does not define (and no removed_step
53
+ # gravestone covers it), or resume_at! names an unknown step.
54
+ class StepNotDefined < Error; end
55
+
56
+ # Recorded on a step whose worker died or that outlived its stall_at.
57
+ class StepStalled < Error; end
58
+
59
+ # Arguments or a step output over config.workflow_max_payload_size.
60
+ class PayloadTooLarge < Error; end
61
+
62
+ # A step-only verb or reader (reattempt!, finished!, output,
63
+ # step_idempotency_key, received_signal) called outside a step body.
64
+ class NotInStep < Error; end
65
+
66
+ # signal! on a finished or cancelled run, or by subject when the subject
67
+ # has no ongoing run of the class (missing: :raise).
68
+ class NotOngoing < InvalidTransition; end
69
+
70
+ # Recorded on a fan_out step whose batch had dead-lettered members.
71
+ class FanOutFailed < Error; end
72
+
73
+ # Recorded on a waiting step whose wait_timeout: passed with on_timeout: :pause!.
74
+ class WaitTimedOut < Error; end
75
+
76
+ # start! of a class with a fan_out step before the batch callback-job
77
+ # columns exist (rails generate pgbus:add_batch_callback_jobs).
78
+ class BatchCallbacksUnmigrated < Error; end
79
+
80
+ # start! of a class with a fan_out or wait_for: step before the
81
+ # waiting-step schema exists (rails generate pgbus:add_workflow_signals).
82
+ class WaitingStepsUnmigrated < Error; end
83
+
84
+ # speedrun_workflow reached a step that waits for a signal or a batch.
85
+ class StepWaiting < Error; end
86
+
87
+ extend DSL
88
+ include FlowControl
89
+ include Instance
90
+
91
+ # Class-level declarations: frozen values replaced whole by the DSL, set
92
+ # while the class body loads and only read afterwards.
93
+ # rubocop:disable ThreadSafety/ClassAndModuleAttributes -- written only by the class body at load, each a frozen value replaced whole
94
+ class_attribute :_workflow_steps, instance_accessor: false, instance_predicate: false, default: Steps::EMPTY
95
+ class_attribute :_cancel_conditions, instance_accessor: false, instance_predicate: false, default: [].freeze
96
+ class_attribute :_exception_policies, instance_accessor: false, instance_predicate: false, default: [].freeze
97
+ class_attribute :_step_job_options, instance_accessor: false, instance_predicate: false, default: {}.freeze
98
+ # rubocop:enable ThreadSafety/ClassAndModuleAttributes
99
+
100
+ class << self
101
+ def inherited(subclass)
102
+ super
103
+ Registry.register(subclass)
104
+ end
105
+
106
+ # Every subclass that has a name, once (what the insights page lists).
107
+ def registered_classes = Registry.classes
108
+
109
+ # Start a run: the workflow row and its first step row, in one
110
+ # transaction; the first StepJob is sent once that commits.
111
+ #
112
+ # SignupWorkflow.start!(user, plan: "pro") # arguments["plan"]
113
+ # CheckoutWorkflow.start!(order, idempotency_key: "checkout-#{order.id}")
114
+ #
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)
120
+ end
121
+
122
+ # The run with this workflow_id, as an instance of the class that
123
+ # started it; nil when there is none, or when that class is not this
124
+ # one or a subclass of it. Pgbus::Workflow.find finds any run (as a
125
+ # bare Pgbus::Workflow when its class no longer exists, so it can still
126
+ # be cancelled).
127
+ def find(workflow_id)
128
+ record = WorkflowEntry.find_by(workflow_id: workflow_id)
129
+ return nil unless record
130
+
131
+ klass = record.workflow_klass || Workflow
132
+ klass <= self ? klass.new(record) : nil
133
+ end
134
+
135
+ # Signal the ongoing run of this class for +subject+ (a webhook that
136
+ # knows the order, not the run):
137
+ #
138
+ # OrderWorkflow.signal!(order, :payment_confirmed, payload: { amount: 42 }, idempotency_key: event.id,
139
+ # missing: :ignore)
140
+ #
141
+ # The one-ongoing-per-subject index means at most one run matches. With
142
+ # none (never started, or already over), missing: :raise raises
143
+ # NotOngoing and missing: :ignore returns nil. A subject-less run is
144
+ # signalled through its handle: find(workflow_id).signal!.
145
+ def signal!(subject, name, payload: {}, idempotency_key: nil, missing: :raise, actor: nil, reason: nil)
146
+ raise ArgumentError, "missing: must be :raise or :ignore, got #{missing.inspect}" unless %i[raise ignore].include?(missing)
147
+ if subject.nil?
148
+ raise ArgumentError, "#{self.name}.signal! needs a subject; signal a subject-less run through its handle " \
149
+ "(#{self.name}.find(workflow_id).signal!)"
150
+ end
151
+
152
+ attrs = Payload.subject_attributes(subject)
153
+ record = WorkflowEntry.ongoing.find_by(workflow_class: self.name, subject_type: attrs[:subject_type],
154
+ subject_id: attrs[:subject_id])
155
+ raise NotOngoing, "#{self.name} has no ongoing run for #{attrs[:subject_type]} #{attrs[:subject_id]}" unless record
156
+
157
+ new(record).signal!(name, payload: payload, idempotency_key: idempotency_key, actor: actor, reason: reason)
158
+ rescue NotOngoing
159
+ raise if missing == :raise
160
+
161
+ nil
162
+ end
163
+
164
+ # How many ongoing runs still sit on each removed step:
165
+ # { "reminder" => 3 }. A gravestone can go once its count is zero.
166
+ def removed_steps_in_flight
167
+ names = _workflow_steps.removed_names
168
+ return {} if names.empty?
169
+
170
+ counts = WorkflowEntry.ongoing.where(workflow_class: name, current_step: names).group(:current_step).count
171
+ names.to_h { |step_name| [step_name, counts.fetch(step_name, 0)] }
172
+ end
173
+ end
174
+
175
+ attr_reader :record
176
+
177
+ def initialize(record, duplicate_start: false)
178
+ @record = record
179
+ @duplicate_start = duplicate_start
180
+ @step_context = nil
181
+ end
182
+ end
183
+ end