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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # A `fan_out` step (issue #541): a StepDefinition whose body adds jobs
6
+ # to a builder instead of doing the work itself. FanOut runs the body,
7
+ # opens the batch and parks the step on it.
8
+ class FanOutDefinition < StepDefinition
9
+ # What a batch with dead-lettered members does to the run.
10
+ FAILURE_ACTIONS = %i[pause! cancel! skip!].freeze
11
+ # The step options a fan_out takes (on_batch_failure: besides): every
12
+ # one but the signal waits.
13
+ OPTIONS = (StepDefinition::OPTIONS - %i[wait_for wait_timeout on_timeout]).freeze
14
+
15
+ attr_reader :on_batch_failure
16
+
17
+ def initialize(on_batch_failure: :pause!, **)
18
+ @on_batch_failure = normalize_failure_action(on_batch_failure)
19
+ super(**)
20
+ end
21
+
22
+ def kind = "fan_out"
23
+
24
+ # Runs the body with a recording builder, opens the batch, and ends the
25
+ # attempt with the :fan_out outcome (FanOut.run throws it).
26
+ def invoke(workflow)
27
+ FanOut.run(self, workflow)
28
+ end
29
+
30
+ private
31
+
32
+ def normalize_failure_action(value)
33
+ verb = value.nil? ? nil : :"#{value.to_s.delete_suffix("!")}!"
34
+ return verb if FAILURE_ACTIONS.include?(verb)
35
+
36
+ raise ArgumentError,
37
+ "on_batch_failure: must be one of #{FAILURE_ACTIONS.map(&:inspect).join(", ")}, got #{value.inspect}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The flow-control verbs. Inside a step body each one ends the body at
6
+ # once (`throw :pgbus_workflow_flow, Outcome`) and the StepJob applies the
7
+ # outcome under the workflow lock. Outside a step, `pause!` and `cancel!`
8
+ # are operator verbs (they change the run through the Runner, with
9
+ # actor:/reason: on the audit trail); the step-only verbs raise NotInStep.
10
+ module FlowControl
11
+ THROW_TAG = :pgbus_workflow_flow
12
+ ACTIONS = %i[complete reattempt pause cancel skip finish fan_out].freeze
13
+
14
+ # What a step attempt ended with. +wait+ is the reattempt delay in
15
+ # seconds (nil = now); +actor+ and +reason+ are what a step's own
16
+ # pause!/cancel! (or an operator's skip!) passed, for the audit trail;
17
+ # +details+ carries a fan_out's batch_id and size.
18
+ Outcome = Data.define(:action, :wait, :actor, :reason, :details) do
19
+ def initialize(action:, wait: nil, actor: nil, reason: nil, details: nil) = super
20
+
21
+ def self.complete = new(action: :complete, wait: nil)
22
+ def self.pause(actor: nil, reason: nil) = new(action: :pause, actor: actor, reason: reason)
23
+ def self.cancel(actor: nil, reason: nil) = new(action: :cancel, actor: actor, reason: reason)
24
+ def self.skip(actor: nil, reason: nil) = new(action: :skip, actor: actor, reason: reason)
25
+ def self.finish = new(action: :finish, wait: nil)
26
+ def self.reattempt(wait: nil) = new(action: :reattempt, wait: StepDefinition.seconds(wait, "wait", positive: false))
27
+ # A fan_out body returned and its batch is open: the step parks on it.
28
+ def self.fan_out(batch_id:, size:) = new(action: :fan_out, details: { batch_id: batch_id, size: size }.freeze)
29
+
30
+ # An ExceptionPolicy action (:pause!, :reattempt!, :cancel!, :skip!).
31
+ def self.for(verb, wait: nil)
32
+ case verb.to_s.delete_suffix("!").to_sym
33
+ when :pause then pause
34
+ when :cancel then cancel
35
+ when :skip then skip
36
+ when :reattempt then reattempt(wait: wait)
37
+ when :finished, :finish then finish
38
+ else raise ArgumentError, "unknown flow-control verb #{verb.inspect}"
39
+ end
40
+ end
41
+
42
+ def reattempt? = action == :reattempt
43
+ end
44
+
45
+ # Inside a step: end it here, completed, with the workflow paused.
46
+ # Outside: pause the run (ready, performing or waiting → paused).
47
+ def pause!(actor: nil, reason: nil)
48
+ return throw_outcome(Outcome.pause(actor: actor, reason: reason)) if in_step?
49
+
50
+ apply_outside_verb(:pause, actor: actor, reason: reason)
51
+ end
52
+
53
+ # Inside a step: end it here and cancel the workflow.
54
+ # Outside: cancel the run from any ongoing state.
55
+ def cancel!(actor: nil, reason: nil)
56
+ return throw_outcome(Outcome.cancel(actor: actor, reason: reason)) if in_step?
57
+
58
+ apply_outside_verb(:cancel, actor: actor, reason: reason)
59
+ end
60
+
61
+ # Ends this attempt as failed and schedules the next one, +wait+
62
+ # seconds from now. Step bodies only.
63
+ def reattempt!(wait: nil)
64
+ throw_outcome(Outcome.reattempt(wait: wait), verb: :reattempt!)
65
+ end
66
+
67
+ # Inside a step: end it here, skipped; the workflow moves on.
68
+ # Outside: skip the step a waiting run waits on (a signal it holds is
69
+ # consumed) and schedule the next one.
70
+ def skip!(actor: nil, reason: nil)
71
+ return throw_outcome(Outcome.skip) if in_step?
72
+
73
+ apply_outside_verb(:skip, actor: actor, reason: reason)
74
+ end
75
+
76
+ # Ends the step as completed and finishes the workflow, skipping the
77
+ # steps after it. Step bodies only.
78
+ def finished!
79
+ throw_outcome(Outcome.finish, verb: :finished!)
80
+ end
81
+
82
+ private
83
+
84
+ def in_step? = !step_context.nil?
85
+
86
+ def throw_outcome(outcome, verb: nil)
87
+ raise NotInStep, "#{verb} can only be called inside a step body" if verb && !in_step?
88
+ raise NotInStep, "flow-control verbs end a step from its body or an on_exception block, not from cancel_if or skip_if" if @guarding
89
+
90
+ throw THROW_TAG, outcome
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The instance side of a workflow: the run's readers, its outputs and
6
+ # idempotency keys, and the outside verbs. An instance wraps one
7
+ # Pgbus::WorkflowEntry row; inside a step it also knows which step
8
+ # attempt it is running (the step context).
9
+ module Instance
10
+ def workflow_id = record.workflow_id
11
+
12
+ def workflow_class = record.workflow_class
13
+
14
+ def status = record.status
15
+
16
+ def current_step = record.current_step
17
+
18
+ # True when start!(…, idempotency_key:) found an existing run with that
19
+ # key and returned it instead of starting another.
20
+ def duplicate_start? = @duplicate_start
21
+
22
+ # The record the run was started for, loaded through its GlobalID;
23
+ # nil for a subject-less run or when the record is gone.
24
+ def subject
25
+ return @subject if defined?(@subject)
26
+
27
+ @subject = Payload.locate_subject(record.subject_gid)
28
+ end
29
+
30
+ # start!'s keyword arguments, with indifferent access.
31
+ def arguments
32
+ return @arguments if defined?(@arguments)
33
+
34
+ @arguments = Payload.load(record.arguments) || {}.with_indifferent_access
35
+ end
36
+
37
+ def reload
38
+ @record = WorkflowEntry.find_by!(workflow_id: workflow_id)
39
+ remove_instance_variable(:@subject) if defined?(@subject)
40
+ remove_instance_variable(:@arguments) if defined?(@arguments)
41
+ self
42
+ end
43
+
44
+ # The audit trail, oldest first.
45
+ def events = WorkflowEvent.where(workflow_id: workflow_id).order(:id).to_a
46
+
47
+ # The outside verbs legal in the current status (States).
48
+ def available_actions = States.available_actions(status)
49
+
50
+ # Inside a step: record durable output values. They are written in the
51
+ # same UPDATE that completes the step, so they exist exactly when the
52
+ # step does; later steps read them with output_of. Merges across calls.
53
+ def output(**values)
54
+ context = step_context!(:output)
55
+ raise NotInStep, "output can only be called from the step body, not from cancel_if or skip_if" if @guarding
56
+
57
+ merged = (@output_values || {}).merge(values)
58
+ @pending_output = Payload.dump(merged, what: "output of step #{context.step_name}")
59
+ @output_values = merged
60
+ nil
61
+ end
62
+
63
+ # What this attempt's output(...) calls recorded, serialized (or nil).
64
+ def pending_output = @pending_output
65
+
66
+ # The output of +step_name+'s latest completed attempt, or nil. A
67
+ # failed or reattempted attempt's output is never returned.
68
+ def output_of(step_name)
69
+ Payload.load(WorkflowStep.completed_output(workflow_id, step_name))
70
+ end
71
+
72
+ # { "step_name" => output } for every step with a completed attempt.
73
+ def outputs
74
+ WorkflowStep.completed_outputs(workflow_id).transform_values { |output| Payload.load(output) }
75
+ end
76
+
77
+ # The same on every attempt of this step (and on a resume_at! re-run):
78
+ # hand it to an external API so a repeated side effect is deduplicated.
79
+ def step_idempotency_key
80
+ "pgbus-wf:#{workflow_id}:#{step_context!(:step_idempotency_key).step_name}"
81
+ end
82
+
83
+ # Inside a wait_for: step: the Pgbus::WorkflowSignal the step took (its
84
+ # +payload+ has indifferent access), or nil when its wait timed out.
85
+ def received_signal
86
+ step_context!(:received_signal)
87
+ @received_signal
88
+ end
89
+
90
+ # Inside a wait_for: step: true when the body runs because the wait
91
+ # timed out (on_timeout: :run) rather than for a signal.
92
+ def timed_out?
93
+ step_context!(:timed_out?)
94
+ @timed_out == true
95
+ end
96
+
97
+ # Deliver a signal to this run. Stored under the run's lock; a step
98
+ # waiting for it (wait_for:) is woken at once, otherwise it waits,
99
+ # pending, for the step that claims it. With an idempotency_key a
100
+ # redelivery stores nothing and returns the first signal
101
+ # (duplicate_delivery? true). Raises NotOngoing on a finished or
102
+ # cancelled run. +reason+ goes on the signal_received event.
103
+ #
104
+ # wf.signal!(:payment_confirmed, payload: { amount: 42 }, idempotency_key: event.id)
105
+ def signal!(name, payload: {}, idempotency_key: nil, actor: nil, reason: nil)
106
+ @record, signal = Signals.deliver(workflow_id, name, payload: payload, idempotency_key: idempotency_key,
107
+ actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
108
+ signal
109
+ end
110
+
111
+ # Differs per attempt, for the rare call that must not be deduplicated
112
+ # across attempts.
113
+ def attempt_idempotency_key
114
+ "#{step_idempotency_key}:#{step_context!(:attempt_idempotency_key).attempt}"
115
+ end
116
+
117
+ # paused → ready. A failed step runs again as a new attempt, a
118
+ # completed one is followed by the next step.
119
+ def resume!(actor: nil, reason: nil) = apply_outside_verb(:resume, actor: actor, reason: reason)
120
+
121
+ # paused → ready, running +step_name+ next.
122
+ def resume_at!(step_name, actor: nil, reason: nil)
123
+ apply_outside_verb(:resume_at, actor: actor, reason: reason, step_name: step_name)
124
+ end
125
+
126
+ # The scheduled step runs now instead of after its wait.
127
+ def run_now!(actor: nil, reason: nil) = apply_outside_verb(:run_now, actor: actor, reason: reason)
128
+
129
+ # The scheduled step runs at +at+ instead.
130
+ def reschedule!(at:, actor: nil, reason: nil) # rubocop:disable Naming/MethodParameterName
131
+ apply_outside_verb(:reschedule, actor: actor, reason: reason, at: at)
132
+ end
133
+
134
+ # A finished or cancelled run, started again as a new run with the same
135
+ # subject and arguments. Returns the new run.
136
+ def rerun!(actor: nil, reason: nil)
137
+ Runner.rerun(self, actor: actor || Runner::DEFAULT_ACTOR, reason: reason)
138
+ end
139
+
140
+ def inspect
141
+ "#<#{self.class.name} workflow_id=#{workflow_id.inspect} status=#{status.inspect} current_step=#{current_step.inspect}>"
142
+ end
143
+
144
+ private
145
+
146
+ attr_reader :step_context
147
+
148
+ def apply_outside_verb(verb, actor:, reason:, **)
149
+ @record = Runner.apply(self, verb, actor: actor || Runner::DEFAULT_ACTOR, reason: reason, **)
150
+ self
151
+ end
152
+
153
+ def step_context!(reader)
154
+ step_context || raise(NotInStep, "#{reader} can only be called inside a step body")
155
+ end
156
+
157
+ # Used by the StepJob only: cancel_if and skip_if run inside this, where
158
+ # the flow-control verbs raise NotInStep (only the body and an
159
+ # on_exception block can end a step).
160
+ def guarding
161
+ @guarding = true
162
+ yield
163
+ ensure
164
+ @guarding = false
165
+ end
166
+
167
+ # Used by the StepJob only.
168
+ def enter_step(context)
169
+ @step_context = context
170
+ @output_values = nil
171
+ @pending_output = nil
172
+ @received_signal = nil
173
+ @timed_out = false
174
+ end
175
+
176
+ # Used by the StepJob's signal gate only.
177
+ def enter_wait(signal:, timed_out:)
178
+ @received_signal = signal
179
+ @timed_out = timed_out
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The outside verbs, each called by Runner.apply with the run's row
6
+ # locked and the transition already checked against the States table.
7
+ module OutsideVerbs
8
+ module_function
9
+
10
+ def pause(record, notes, actor:, reason:)
11
+ from = record.status
12
+ Runner.transition!(record, :pause, paused_at: Time.current)
13
+ Runner.event!(record, "paused", actor: actor, reason: reason, details: { from: from })
14
+ notes.instrument("pgbus.workflow_paused", Runner.payload_for(record, step_name: record.current_step, error_class: nil))
15
+ end
16
+
17
+ def cancel(record, notes, actor:, reason:)
18
+ now = Time.current
19
+ from = record.status
20
+ Runner.transition!(record, :cancel, cancelled_at: now)
21
+ WorkflowStep.cancel_open!(record.workflow_id, now: now)
22
+ Runner.event!(record, "cancelled", actor: actor, reason: reason, details: { from: from })
23
+ notes.instrument("pgbus.workflow_cancelled", Runner.payload_for(record, step_name: record.current_step))
24
+ end
25
+
26
+ # What runs next depends on the row the run paused on: a failed one
27
+ # (an exception or a stall) runs again as a new attempt; a completed or
28
+ # skipped one (the step called pause!) is followed by the next step, or
29
+ # finishes the run when it was the last; a scheduled one (an outside
30
+ # pause! before it ran) is sent again; one still running goes on; one
31
+ # still waiting (for a signal or its batch) waits again.
32
+ def resume(record, notes, actor:, reason:)
33
+ latest = WorkflowStep.latest_for(record.workflow_id)
34
+ resumed = { paused_at: nil, **ErrorCapture.cleared }
35
+ case latest&.status
36
+ when "in_progress"
37
+ Runner.transition!(record, :unpause, **resumed)
38
+ resumed_event(record, actor, reason, latest)
39
+ when "waiting"
40
+ extend_wait(record, latest)
41
+ Runner.transition!(record, :rewait, **resumed)
42
+ resumed_event(record, actor, reason, latest)
43
+ when "scheduled"
44
+ Runner.transition!(record, :resume, **resumed)
45
+ resumed_event(record, actor, reason, latest)
46
+ latest.update!(enqueued_at: Time.current)
47
+ when "failed"
48
+ Runner.transition!(record, :resume, **resumed)
49
+ resumed_event(record, actor, reason, latest)
50
+ Runner.schedule!(record, latest.step_name, at: Time.current, actor: actor)
51
+ else
52
+ resume_after(record, notes, latest, resumed, actor: actor, reason: reason)
53
+ end
54
+ end
55
+
56
+ # The step a waiting run waits on is skipped (a signal it holds is
57
+ # consumed), as if it had called skip!; the next step is scheduled, or
58
+ # the run finishes after the last. Its fan-out batch, if any, runs on and
59
+ # its callback finds nothing to resume.
60
+ def skip(record, notes, actor:, reason:)
61
+ step = WorkflowStep.latest_for(record.workflow_id)
62
+ unless step&.status == "waiting"
63
+ raise InvalidTransition, "workflow #{record.workflow_id} has no waiting step to skip (latest: #{step&.status || "none"})"
64
+ end
65
+
66
+ Waiting.resolve!(record, step, FlowControl::Outcome.skip(actor: actor, reason: reason), notes: notes)
67
+ end
68
+
69
+ def resume_at(record, _notes, actor:, reason:, step_name:)
70
+ definition = definition!(record, step_name)
71
+ now = Time.current
72
+ WorkflowStep.cancel_open!(record.workflow_id, now: now)
73
+ Runner.transition!(record, :resume_at, current_step: definition.name, paused_at: nil, **ErrorCapture.cleared)
74
+ Runner.event!(record, "resumed", actor: actor, reason: reason, details: { resume_at: definition.name })
75
+ Runner.schedule!(record, definition.name, at: now, actor: actor)
76
+ end
77
+
78
+ def run_now(record, _notes, actor:, reason:)
79
+ reschedule(record, nil, actor: actor, reason: reason, at: Time.current, event: "run_now")
80
+ end
81
+
82
+ def reschedule(record, _notes, actor:, reason:, at:, event: "rescheduled") # rubocop:disable Naming/MethodParameterName
83
+ time = at.respond_to?(:to_time) && !at.is_a?(String) ? at.to_time : nil
84
+ raise ArgumentError, "reschedule!(at:) takes a Time, got #{at.inspect}" unless time
85
+
86
+ step = WorkflowStep.where(workflow_id: record.workflow_id, status: "scheduled").order(id: :desc).first
87
+ raise InvalidTransition, "workflow #{record.workflow_id} has no scheduled step to #{event.tr("_", " ")}" unless step
88
+
89
+ verb = event == "run_now" ? :run_now : :reschedule
90
+ from = step.scheduled_at
91
+ Runner.transition!(record, verb)
92
+ step.update!(scheduled_at: time, enqueued_at: Time.current)
93
+ Runner.event!(record, event, actor: actor, reason: reason, step: step,
94
+ details: { from: from.utc.iso8601(3), to: step.scheduled_at.utc.iso8601(3) })
95
+ end
96
+
97
+ def resume_after(record, notes, latest, resumed, actor:, reason:)
98
+ klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
99
+ steps = klass.workflow_steps
100
+ if latest && steps.entries.none? { |entry| entry.name == latest.step_name }
101
+ raise StepNotDefined, "#{klass.name} has no step #{latest.step_name} (declare `removed_step :#{latest.step_name}`)"
102
+ end
103
+
104
+ following = latest ? steps.next_after(latest.step_name) : steps.first
105
+ if following.nil?
106
+ Runner.transition!(record, :finish, finished_at: Time.current, **resumed)
107
+ resumed_event(record, actor, reason, latest)
108
+ Runner.event!(record, "finished", actor: actor)
109
+ notes.instrument("pgbus.workflow_finished", Runner.payload_for(record))
110
+ return
111
+ end
112
+
113
+ Runner.transition!(record, :resume, current_step: following.name, **resumed)
114
+ resumed_event(record, actor, reason, latest)
115
+ Runner.schedule!(record, following.name, at: Time.current + following.wait, actor: actor)
116
+ end
117
+
118
+ # The time a run spent paused does not count against its waiting
119
+ # step's wait_timeout: the deadline moves by the length of the pause
120
+ # (from when the step started waiting, if that came later).
121
+ def extend_wait(record, step, now: Time.current)
122
+ return unless step.timeout_at && record.paused_at
123
+
124
+ paused_for = now - [record.paused_at, step.waiting_since].compact.max
125
+ step.update_columns(timeout_at: step.timeout_at + paused_for) if paused_for.positive?
126
+ end
127
+
128
+ def resumed_event(record, actor, reason, step)
129
+ Runner.event!(record, "resumed", actor: actor, reason: reason, step: step)
130
+ end
131
+
132
+ def definition!(record, step_name)
133
+ klass = record.workflow_klass || raise(StepNotDefined, "#{record.workflow_class} is not a Pgbus::Workflow class any more")
134
+ klass.workflow_steps.fetch(step_name) || raise(StepNotDefined, "#{klass.name} has no step #{step_name}")
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ class Workflow
7
+ # Arguments, step outputs and the subject, to and from the database.
8
+ # Values go through ActiveJob::Arguments (JSON, GlobalID-aware, never
9
+ # Marshal), are capped at config.workflow_max_payload_size, and come back
10
+ # with indifferent access. GlobalIDs are gated by
11
+ # config.allowed_global_id_models, as job arguments are.
12
+ module Payload
13
+ module_function
14
+
15
+ def dump(values, what:, limit: Pgbus.configuration.workflow_max_payload_size)
16
+ serialized = ::ActiveJob::Arguments.serialize([values.to_h]).first
17
+ size = JSON.generate(serialized).bytesize
18
+ raise PayloadTooLarge, "#{what} is #{size} bytes, over workflow_max_payload_size (#{limit})" if size > limit
19
+
20
+ serialized
21
+ end
22
+
23
+ def load(serialized, configuration: Pgbus.configuration)
24
+ return nil if serialized.nil?
25
+
26
+ Serializer.assert_job_global_ids_allowed!(serialized, configuration: configuration)
27
+ ::ActiveJob::Arguments.deserialize([serialized]).first.with_indifferent_access
28
+ end
29
+
30
+ # The subject columns of a run: the polymorphic type and id the
31
+ # one-ongoing index keys on, and the GlobalID +subject+ loads through.
32
+ def subject_attributes(subject)
33
+ return { subject_type: nil, subject_id: nil, subject_gid: nil } if subject.nil?
34
+ unless subject.respond_to?(:to_global_id) && subject.respond_to?(:id)
35
+ raise ArgumentError, "a workflow subject must be a GlobalID record (an ActiveRecord model), got #{subject.class}"
36
+ end
37
+
38
+ klass = subject.class
39
+ type = klass.respond_to?(:base_class) ? klass.base_class.name : klass.name
40
+ { subject_type: type, subject_id: subject.id.to_s, subject_gid: subject.to_global_id.to_s }
41
+ end
42
+
43
+ # nil when the run has no subject or the record is gone.
44
+ def locate_subject(gid, configuration: Pgbus.configuration)
45
+ return nil if gid.nil?
46
+
47
+ Serializer.locate_global_id(gid, configuration: configuration)
48
+ rescue ActiveRecord::RecordNotFound
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Pgbus
7
+ class Workflow
8
+ # Every Pgbus::Workflow subclass, registered from `inherited` (what the
9
+ # insights page lists). The list is a frozen Array replaced whole under a
10
+ # lock built at load (docs/concurrency.md): a reader never sees it change
11
+ # under it, and two classes defined at once are both kept.
12
+ #
13
+ # Classes are kept as objects, not names: `Class.new(Pgbus::Workflow)`
14
+ # has no name yet when `inherited` runs. Names are read when listing, and
15
+ # a name defined again (code reloading) lists its latest class only.
16
+ module Registry
17
+ @lock = Mutex.new
18
+ @classes = [].freeze
19
+
20
+ class << self
21
+ def register(klass)
22
+ @lock.synchronize { @classes = merged(@classes, klass) }
23
+ klass
24
+ end
25
+
26
+ def classes
27
+ named = @lock.synchronize { @classes }.select(&:name)
28
+ named.reverse.uniq(&:name).reverse
29
+ end
30
+
31
+ # Specs only.
32
+ def reset!
33
+ @lock.synchronize { @classes = [].freeze }
34
+ end
35
+
36
+ # SHA256 of the ordered [name, kind] pairs of a Steps list.
37
+ def digest(steps)
38
+ Digest::SHA256.hexdigest(JSON.generate(steps.digest_pairs))
39
+ end
40
+
41
+ private
42
+
43
+ # A class registered again by name (code reloading) replaces the old
44
+ # one, so reloads do not accumulate. Anonymous classes are kept until
45
+ # they get a name; classes lists them then.
46
+ def merged(list, klass)
47
+ name = klass.name
48
+ (list.reject { |existing| existing.equal?(klass) || (name && existing.name == name) } << klass).freeze
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # A removed_step gravestone: a step that existed once. A scheduled row
6
+ # still naming it is skipped and the workflow moves on to the next step
7
+ # declared after it.
8
+ RemovedStep = Data.define(:name) do
9
+ def kind = "removed"
10
+
11
+ def removed? = true
12
+ end
13
+ end
14
+ end