pgbus 0.19.4 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +96 -7
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +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/delivery.rb +49 -0
  64. data/lib/pgbus/active_job/executor.rb +3 -1
  65. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  66. data/lib/pgbus/configuration.rb +21 -3
  67. data/lib/pgbus/generators/migration_detector.rb +40 -0
  68. data/lib/pgbus/instrumentation.rb +32 -0
  69. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  70. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  71. data/lib/pgbus/mcp/rack_app.rb +7 -2
  72. data/lib/pgbus/mcp/runner.rb +2 -1
  73. data/lib/pgbus/mcp/serialization.rb +25 -0
  74. data/lib/pgbus/mcp/server.rb +53 -10
  75. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  76. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  77. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  78. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  79. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  80. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  81. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  83. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  85. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  86. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  87. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  88. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  89. data/lib/pgbus/mcp/write_tool.rb +134 -0
  90. data/lib/pgbus/mcp.rb +15 -0
  91. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  92. data/lib/pgbus/metrics/subscriber.rb +14 -1
  93. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  94. data/lib/pgbus/process/dispatcher.rb +26 -0
  95. data/lib/pgbus/version.rb +1 -1
  96. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  97. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  98. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  99. data/lib/pgbus/web/data_source.rb +3 -0
  100. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  101. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  102. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  103. data/lib/pgbus/web/workflow_payload.rb +36 -0
  104. data/lib/pgbus/web/workflow_query.rb +82 -0
  105. data/lib/pgbus/web/workflow_state.rb +76 -0
  106. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  107. data/lib/pgbus/web/workflow_window.rb +51 -0
  108. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  109. data/lib/pgbus/workflow/dsl.rb +105 -0
  110. data/lib/pgbus/workflow/error_capture.rb +63 -0
  111. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  112. data/lib/pgbus/workflow/fan_out.rb +157 -0
  113. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  114. data/lib/pgbus/workflow/flow_control.rb +94 -0
  115. data/lib/pgbus/workflow/instance.rb +183 -0
  116. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  117. data/lib/pgbus/workflow/payload.rb +53 -0
  118. data/lib/pgbus/workflow/registry.rb +53 -0
  119. data/lib/pgbus/workflow/removed_step.rb +14 -0
  120. data/lib/pgbus/workflow/runner.rb +218 -0
  121. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  122. data/lib/pgbus/workflow/signals.rb +132 -0
  123. data/lib/pgbus/workflow/states.rb +119 -0
  124. data/lib/pgbus/workflow/step_context.rb +58 -0
  125. data/lib/pgbus/workflow/step_definition.rb +131 -0
  126. data/lib/pgbus/workflow/step_execution.rb +242 -0
  127. data/lib/pgbus/workflow/step_job.rb +28 -0
  128. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  129. data/lib/pgbus/workflow/steps.rb +103 -0
  130. data/lib/pgbus/workflow/sweep.rb +156 -0
  131. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  132. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  133. data/lib/pgbus/workflow/waiting.rb +82 -0
  134. data/lib/pgbus/workflow.rb +183 -0
  135. data/sig/pgbus/configuration.rbs +22 -0
  136. metadata +92 -1
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "socket"
5
+
6
+ module Pgbus
7
+ class Workflow
8
+ # The transitions every caller shares: start!, the outside verbs, the
9
+ # StepJob and the sweep. Each change happens under the workflow row lock
10
+ # (WorkflowEntry.lock_for) as a guarded UPDATE whose WHERE status IN (...)
11
+ # comes from the States table, and writes its WorkflowEvent in the same
12
+ # transaction. Instrumentation and error reports wait until it commits.
13
+ module Runner
14
+ DEFAULT_ACTOR = "app"
15
+ SYSTEM_ACTOR = "system"
16
+
17
+ # Instrumentation events and error reports collected inside a
18
+ # transaction, sent once it is over.
19
+ class Notes
20
+ def initialize
21
+ @entries = []
22
+ end
23
+
24
+ def instrument(event, payload) = @entries << [:instrument, event, payload]
25
+
26
+ def report(error, context) = @entries << [:report, error, context]
27
+
28
+ def flush
29
+ entries = @entries
30
+ @entries = []
31
+ entries.each do |kind, subject, detail|
32
+ kind == :instrument ? Instrumentation.instrument(subject, detail) : ErrorReporter.report(subject, detail)
33
+ end
34
+ end
35
+
36
+ # Sends now, or, when the transition ran inside an app's transaction
37
+ # on the same connection, once that transaction commits (and never
38
+ # if it rolls back): what is reported must have happened.
39
+ def flush_after_commit(connection)
40
+ return flush unless connection.transaction_open?
41
+
42
+ connection.add_transaction_record(AfterCommit.new(self))
43
+ end
44
+
45
+ # The transaction-record interface ActiveRecord calls on commit and
46
+ # rollback (the one after_commit callbacks use), on 7.1 and later;
47
+ # ActiveRecord.after_all_transactions_commit needs 7.2.
48
+ class AfterCommit
49
+ def initialize(notes)
50
+ @notes = notes
51
+ end
52
+
53
+ def trigger_transactional_callbacks? = true
54
+
55
+ def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- ActiveRecord's name
56
+
57
+ def before_committed!; end
58
+
59
+ def committed!(*, **) = @notes.flush
60
+
61
+ def rolledback!(*, **); end
62
+
63
+ def add_to_transaction(*); end
64
+ end
65
+ end
66
+
67
+ module_function
68
+
69
+ # --- start! and rerun! --------------------------------------------
70
+
71
+ def start(klass, subject:, arguments:, idempotency_key: nil)
72
+ attrs = run_attributes(klass, idempotency_key: idempotency_key)
73
+ .merge(Payload.subject_attributes(subject))
74
+ .merge(arguments: Payload.dump(arguments, what: "arguments of #{klass.name}"))
75
+ insert_run(klass, attrs, actor: DEFAULT_ACTOR)
76
+ end
77
+
78
+ # A finished or cancelled run started again: same subject and
79
+ # arguments (copied as stored, not reloaded), no idempotency key.
80
+ def rerun(workflow, actor:, reason: nil)
81
+ WorkflowEntry.transaction do
82
+ old = WorkflowEntry.lock_for(workflow.workflow_id) || raise(ActiveRecord::RecordNotFound,
83
+ "workflow #{workflow.workflow_id} is gone")
84
+ States.check!(:rerun, old.status)
85
+ klass = old.workflow_klass || raise(StepNotDefined, "#{old.workflow_class} is not a Pgbus::Workflow class any more")
86
+ attrs = run_attributes(klass, rerun_of: old.workflow_id)
87
+ .merge(old.attributes.symbolize_keys.slice(:subject_type, :subject_id, :subject_gid, :arguments))
88
+ rerun = insert_run(klass, attrs, actor: actor, reason: reason)
89
+ event!(old, "rerun", actor: actor, reason: reason, details: { rerun_workflow_id: rerun.workflow_id })
90
+ rerun
91
+ end
92
+ end
93
+
94
+ def run_attributes(klass, idempotency_key: nil, rerun_of: nil)
95
+ raise ArgumentError, "an anonymous workflow class cannot start: give it a constant name" unless klass.name
96
+
97
+ first = klass.workflow_steps.first
98
+ raise ArgumentError, "#{klass.name} declares no steps" unless first
99
+
100
+ Waiting.check_schema!(klass)
101
+
102
+ now = Time.current
103
+ { workflow_id: SecureRandom.uuid, workflow_class: klass.name, status: "ready", current_step: first.name,
104
+ step_job_options: klass.step_job_options.transform_keys(&:to_s), idempotency_key: idempotency_key&.to_s,
105
+ definition_digest: klass.definition_digest, rerun_of_workflow_id: rerun_of, created_at: now, updated_at: now }
106
+ end
107
+
108
+ # The run row and its first step row in one transaction (a savepoint
109
+ # inside an app transaction, so a unique violation leaves the app's
110
+ # transaction usable). The first StepJob goes out when it commits.
111
+ def insert_run(klass, attrs, actor:, reason: nil)
112
+ record, duplicate = WorkflowEntry.transaction(requires_new: true) do
113
+ next [existing_run(klass, attrs), true] unless WorkflowEntry.insert_run!(attrs)
114
+
115
+ record = WorkflowEntry.find_by!(workflow_id: attrs[:workflow_id])
116
+ event!(record, "started", actor: actor, reason: reason, details: { rerun_of: attrs[:rerun_of_workflow_id] }.compact)
117
+ first = klass.workflow_steps.first
118
+ schedule!(record, first.name, at: Time.current + first.wait, actor: actor)
119
+ [record, false]
120
+ end
121
+ klass.new(record, duplicate_start: duplicate)
122
+ rescue ActiveRecord::RecordNotUnique
123
+ # ON CONFLICT only absorbs the idempotency index. Two concurrent
124
+ # starts with the same key and subject can instead trip the
125
+ # one-ongoing index first; by then the winner has committed, so its
126
+ # key is visible: that is still a duplicate start, not a second run.
127
+ existing = attrs[:idempotency_key] && existing_run(klass, attrs, required: false)
128
+ return klass.new(existing, duplicate_start: true) if existing
129
+
130
+ holder = WorkflowEntry.ongoing.find_by(workflow_class: klass.name, subject_type: attrs[:subject_type],
131
+ subject_id: attrs[:subject_id])
132
+ raise AlreadyRunning.new("#{klass.name} already has an ongoing run for #{attrs[:subject_type]} " \
133
+ "#{attrs[:subject_id]} (#{holder&.workflow_id})", workflow: holder && klass.new(holder))
134
+ end
135
+
136
+ def existing_run(klass, attrs, required: true)
137
+ scope = WorkflowEntry.where(workflow_class: klass.name, idempotency_key: attrs[:idempotency_key])
138
+ required ? scope.take! : scope.take
139
+ end
140
+
141
+ # --- outside verbs ------------------------------------------------
142
+
143
+ # pause!, cancel!, resume!, resume_at!, run_now!, reschedule! on a run.
144
+ # Returns the updated row; raises the States table's error when the
145
+ # verb is not legal in the run's state.
146
+ def apply(workflow, verb, actor:, reason: nil, **options)
147
+ locked(workflow.workflow_id) do |record, notes|
148
+ raise ActiveRecord::RecordNotFound, "workflow #{workflow.workflow_id} is gone" unless record
149
+
150
+ States.check!(verb, record.status)
151
+ OutsideVerbs.public_send(verb, record, notes, actor: actor, reason: reason, **options)
152
+ record
153
+ end
154
+ end
155
+
156
+ # --- shared helpers -----------------------------------------------
157
+
158
+ # Runs the block in a transaction with the run's row locked (nil when
159
+ # the run is gone), and sends what the block noted once it commits.
160
+ #
161
+ # The notes are enrolled inside the transition's own transaction, the
162
+ # way a saved model is: they go out when that transaction's work really
163
+ # commits, whether it is a top-level transaction, part of an app's
164
+ # joinable one, or a savepoint inside a non-joinable one (Rails'
165
+ # transactional tests), and never on a rollback.
166
+ def locked(workflow_id)
167
+ notes = Notes.new
168
+ WorkflowEntry.transaction do
169
+ result = yield WorkflowEntry.lock_for(workflow_id), notes
170
+ notes.flush_after_commit(WorkflowEntry.connection)
171
+ result
172
+ end
173
+ end
174
+
175
+ # The guarded UPDATE for +verb+: WHERE status IN (States.from(verb)).
176
+ # Under the row lock it cannot lose once the caller checked the state;
177
+ # 0 rows raises rather than letting a transition pass silently.
178
+ def transition!(record, verb, **attrs)
179
+ to = States.to(verb)
180
+ updated = WorkflowEntry.transition!(record.workflow_id, from: States.from(verb), to: to, **attrs)
181
+ raise InvalidTransition, "#{verb} found workflow #{record.workflow_id} outside #{States.from(verb).join("/")}" if updated.zero?
182
+
183
+ record.assign_attributes(attrs.merge(status: to))
184
+ to
185
+ end
186
+
187
+ def event!(record, event, actor:, reason: nil, step: nil, details: {})
188
+ WorkflowEvent.record!(workflow_id: record.workflow_id, event: event, actor: actor, reason: reason, step: step,
189
+ details: details)
190
+ end
191
+
192
+ # A new attempt row of +step_name+ (its after-commit callback sends the
193
+ # StepJob), with its step_scheduled event. A job_options wait:/wait_until:
194
+ # (class or step) is folded into scheduled_at here, once, so the sweep
195
+ # does not take a deliberately delayed step for a lost one and
196
+ # run_now!/reschedule! move the one time that counts.
197
+ def schedule!(record, step_name, at:, actor:) # rubocop:disable Naming/MethodParameterName
198
+ at = job_delay_floor(record, step_name, at)
199
+ step = WorkflowStep.insert_next!(workflow_id: record.workflow_id, workflow_class: record.workflow_class,
200
+ step_name: step_name, scheduled_at: at)
201
+ event!(record, "step_scheduled", actor: actor, step: step, details: { scheduled_at: at.utc.iso8601(3) })
202
+ step
203
+ end
204
+
205
+ def job_delay_floor(record, step_name, at) # rubocop:disable Naming/MethodParameterName
206
+ options = record.workflow_klass&.step_job_options_for(step_name) || {}
207
+ [at, options[:wait] && (Time.current + options[:wait]), options[:wait_until]].compact.max
208
+ end
209
+
210
+ # "hostname:pid", stamped on every attempt a worker claims.
211
+ def worker_tag = "#{Socket.gethostname}:#{::Process.pid}"
212
+
213
+ def payload_for(record, **extra)
214
+ { workflow_class: record.workflow_class, workflow_id: record.workflow_id }.merge(extra)
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Which signal a `wait_for:` step takes:
6
+ #
7
+ # step :capture, wait_for: :payment_confirmed do ... end
8
+ # step :ship, wait_for: Pgbus::Workflow::SignalMatcher.new(:shipped) { |payload| payload[:region] == "eu" }
9
+ # step :ship, wait_for: EuShipment.new # any object answering matches?(signal)
10
+ #
11
+ # A matcher runs under the workflow row lock (when the step reaches its
12
+ # gate, and when signal! looks for a waiting step), so it must be fast
13
+ # and free of side effects. It gets a Pgbus::WorkflowSignal whose
14
+ # +payload+ has indifferent access.
15
+ class SignalMatcher
16
+ attr_reader :signal_name, :condition
17
+
18
+ # A name becomes a matcher; a matcher, or anything answering matches?,
19
+ # is kept as it is.
20
+ def self.coerce(value)
21
+ return value if value.respond_to?(:matches?)
22
+ return new(value) if (value.is_a?(Symbol) || value.is_a?(String)) && !value.to_s.strip.empty?
23
+
24
+ raise ArgumentError, "wait_for: takes a signal name, a Pgbus::Workflow::SignalMatcher or an object " \
25
+ "answering matches?(signal), got #{value.inspect}"
26
+ end
27
+
28
+ # What the step row's wait_for column shows: the signal name, else the
29
+ # matcher's class.
30
+ def self.label(matcher)
31
+ return matcher.signal_name.to_s if matcher.respond_to?(:signal_name) && matcher.signal_name
32
+
33
+ matcher.class.name.to_s
34
+ end
35
+
36
+ def initialize(name, &condition)
37
+ raise ArgumentError, "a SignalMatcher needs a signal name, got #{name.inspect}" if name.to_s.strip.empty?
38
+
39
+ @signal_name = -name.to_s.strip
40
+ @condition = condition
41
+ freeze
42
+ end
43
+
44
+ def matches?(signal)
45
+ return false unless signal.name.to_s.strip == signal_name
46
+
47
+ condition.nil? || (condition.call(signal.payload) ? true : false)
48
+ end
49
+
50
+ def to_s = signal_name
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Signals (issue #541): what a wait_for: step waits for.
6
+ #
7
+ # deliver (signal!) stores the signal under the run's row lock and, when
8
+ # the run's current step is waiting for a signal it matches, claims it
9
+ # for that step and wakes it. Otherwise it stays pending (buffered).
10
+ #
11
+ # gate runs in the StepJob after the claim and the guards, under the same
12
+ # lock: a signal this step already holds (woken by signal!, or claimed by
13
+ # an earlier attempt that reattempted or paused on a failure), else the
14
+ # oldest pending one its matcher takes, else the step parks as waiting.
15
+ #
16
+ # A claimed signal is consumed when its step ends, except when that
17
+ # attempt reattempts or fails into a pause: the next attempt runs with it.
18
+ module Signals
19
+ module_function
20
+
21
+ # Returns [record, signal]. A redelivery with a stored idempotency key
22
+ # returns the stored signal (duplicate_delivery? true) and does nothing
23
+ # else. A blank key is no key: "" would otherwise dedup every signal
24
+ # sent with it against the first.
25
+ def deliver(workflow_id, name, payload:, idempotency_key:, actor:, reason: nil)
26
+ name = signal_name!(name)
27
+ serialized = Payload.dump(payload || {}, what: "payload of signal #{name}")
28
+ key = idempotency_key.to_s.presence
29
+ Runner.locked(workflow_id) do |record, notes|
30
+ raise ActiveRecord::RecordNotFound, "workflow #{workflow_id} is gone" unless record
31
+
32
+ States.check!(:signal, record.status)
33
+ signal = WorkflowSignal.insert_signal!(workflow_id: workflow_id, name: name, payload: serialized, idempotency_key: key)
34
+ signal ||= duplicate(record, key)
35
+ received!(record, signal, actor: actor, reason: reason, notes: notes)
36
+ [record, signal]
37
+ end
38
+ end
39
+
40
+ # The gate of a wait_for: step. True when the body runs (with a signal,
41
+ # or timed out), false when the step parked. Sets received_signal and
42
+ # timed_out? on +workflow+.
43
+ def gate(record, step, definition, workflow, now: Time.current)
44
+ if timed_out?(step, now)
45
+ workflow.send(:enter_wait, signal: nil, timed_out: true)
46
+ return true
47
+ end
48
+
49
+ signal = WorkflowSignal.held_by_step(record.workflow_id, step.step_name) || match(record, definition.wait_for)
50
+ return park(record, step, definition, now) unless signal
51
+
52
+ claim!(record, step, signal, actor: Runner::SYSTEM_ACTOR) if signal.status == "pending"
53
+ step.update_columns(signal_id: signal.id) unless step.signal_id == signal.id
54
+ workflow.send(:enter_wait, signal: signal, timed_out: false)
55
+ true
56
+ end
57
+
58
+ # The step's attempt ended (StepOutcome.apply): consume the signal it
59
+ # holds, unless the next attempt runs with it.
60
+ def settle(step, status, column, now:)
61
+ return unless step.respond_to?(:signal_id) && step.signal_id
62
+ return if column == "reattempted" || (status == "failed" && column == "paused")
63
+
64
+ WorkflowSignal.consume!(step.signal_id, now: now)
65
+ end
66
+
67
+ def signal_name!(name)
68
+ text = name.to_s.strip
69
+ raise ArgumentError, "signal! needs a signal name, got #{name.inspect}" if text.empty?
70
+
71
+ -text
72
+ end
73
+
74
+ def duplicate(record, key)
75
+ WorkflowSignal.find_by!(workflow_id: record.workflow_id, idempotency_key: key).tap do |signal|
76
+ signal.duplicate_delivery = true
77
+ end
78
+ end
79
+
80
+ def received!(record, signal, actor:, notes:, reason: nil)
81
+ duplicate = signal.duplicate_delivery?
82
+ Runner.event!(record, "signal_received", actor: actor, reason: reason,
83
+ details: { name: signal.name, idempotency_key: signal.idempotency_key,
84
+ signal_id: signal.id, duplicate: duplicate }.compact)
85
+ woke = !duplicate && wake_waiting(record, signal, actor: actor)
86
+ notes.instrument("pgbus.workflow.signal",
87
+ Runner.payload_for(record, name: signal.name, duplicate: duplicate, woke: woke))
88
+ end
89
+
90
+ def wake_waiting(record, signal, actor:) # rubocop:disable Naming/PredicateMethod -- true when it woke the step
91
+ step = WorkflowStep.latest_for(record.workflow_id)
92
+ return false unless step&.status == "waiting" && step.batch_id.nil?
93
+
94
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
95
+ return false unless definition&.waits_for_signal? && definition.wait_for.matches?(signal)
96
+
97
+ claim!(record, step, signal, actor: actor)
98
+ Waiting.wake!(record, step, now: Time.current, signal_id: signal.id)
99
+ true
100
+ end
101
+
102
+ def claim!(record, step, signal, actor:)
103
+ now = Time.current
104
+ WorkflowSignal.claim!(signal.id, step_id: step.id, now: now)
105
+ signal.assign_attributes(status: "claimed", claimed_by_step_id: step.id, claimed_at: now)
106
+ Runner.event!(record, "signal_claimed", actor: actor, step: step, details: { name: signal.name, signal_id: signal.id })
107
+ end
108
+
109
+ # The oldest pending signal +matcher+ takes. A SignalMatcher narrows the
110
+ # query to its name; any other matcher sees every pending signal.
111
+ def match(record, matcher)
112
+ name = matcher.respond_to?(:signal_name) ? matcher.signal_name : nil
113
+ WorkflowSignal.pending_for(record.workflow_id, name: name).find_each(batch_size: 100) do |signal|
114
+ return signal if matcher.matches?(signal)
115
+ end
116
+ nil
117
+ end
118
+
119
+ def park(record, step, definition, now) # rubocop:disable Naming/PredicateMethod -- the gate's false: the body does not run
120
+ timeout_at = definition.wait_timeout && (now + definition.wait_timeout)
121
+ Waiting.park!(record, step, now: now, wait_for: definition.wait_for_name, timeout_at: timeout_at)
122
+ false
123
+ end
124
+
125
+ # This attempt waited, holds no signal, and its wait_timeout passed: the
126
+ # sweep woke it with on_timeout: :run.
127
+ def timed_out?(step, now)
128
+ !step.waiting_since.nil? && step.signal_id.nil? && !step.timeout_at.nil? && step.timeout_at <= now + 1
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The one transition table (issue #540). Every verb, the workflow states
6
+ # it is legal from, the state it moves the workflow to, and what it does
7
+ # to the step rows. The runner's guarded UPDATEs take their WHERE status
8
+ # IN (...) from here, available_actions (dashboard buttons, MCP tools)
9
+ # reads it, and the docs page lists it. Nothing else decides whether a
10
+ # transition is legal.
11
+ #
12
+ # Outside verbs are the ones a person or a program calls on a run
13
+ # (console, dashboard, MCP); runner verbs are the StepJob's and the
14
+ # sweep's own moves, never offered as actions.
15
+ module States
16
+ WORKFLOW = %w[ready performing waiting paused finished cancelled].freeze
17
+ ONGOING = %w[ready performing waiting paused].freeze
18
+ TERMINAL = %w[finished cancelled].freeze
19
+ STEP = %w[scheduled waiting in_progress completed failed cancelled skipped].freeze
20
+ OPEN_STEP = %w[scheduled waiting in_progress].freeze
21
+
22
+ # Why an ongoing run needs a person (the sweep's gauge, and the
23
+ # dashboard's needs-attention view later).
24
+ STUCK_REASONS = %w[paused_error stalled paused_manual overdue waiting_overdue].freeze
25
+
26
+ Transition = Data.define(:verb, :from, :to, :outside, :error, :effect)
27
+
28
+ TRANSITIONS = Ractor.make_shareable(
29
+ [
30
+ # Outside verbs
31
+ Transition.new(verb: :pause, from: %w[ready performing waiting], to: "paused", outside: true,
32
+ error: :InvalidTransition,
33
+ effect: "a running step finishes and records its outcome; nothing new is claimed"),
34
+ Transition.new(verb: :resume, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
35
+ effect: "a failed step runs again as a new attempt; a completed or skipped one is " \
36
+ "followed by the next step; a scheduled one is sent again; a waiting one waits again"),
37
+ Transition.new(verb: :resume_at, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
38
+ effect: "the named step runs as a new attempt"),
39
+ Transition.new(verb: :cancel, from: ONGOING, to: "cancelled", outside: true, error: :InvalidTransition,
40
+ effect: "scheduled and in_progress steps are cancelled; a running step's outcome is ignored"),
41
+ Transition.new(verb: :run_now, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
42
+ effect: "the scheduled step's wait is cut short and it is sent now"),
43
+ Transition.new(verb: :reschedule, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
44
+ effect: "the scheduled step moves to the given time"),
45
+ Transition.new(verb: :rerun, from: TERMINAL, to: "ready", outside: true, error: :InvalidTransition,
46
+ effect: "a new run starts with the same subject and arguments"),
47
+ Transition.new(verb: :skip, from: %w[waiting], to: "ready", outside: true, error: :InvalidTransition,
48
+ effect: "the waiting step is skipped (a signal it holds is consumed) and the next step is " \
49
+ "scheduled, or the run finishes after the last"),
50
+ # signal leaves the state alone unless it wakes a waiting step (wake).
51
+ Transition.new(verb: :signal, from: ONGOING, to: nil, outside: true, error: :NotOngoing,
52
+ effect: "the signal is stored; a waiting step it matches is scheduled again, otherwise it " \
53
+ "stays pending until a step claims it"),
54
+ # Runner verbs
55
+ Transition.new(verb: :claim, from: %w[ready], to: "performing", outside: false, error: :InvalidTransition,
56
+ effect: "the scheduled step moves to in_progress"),
57
+ Transition.new(verb: :advance, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
58
+ effect: "the step is completed or skipped and the next step is scheduled"),
59
+ Transition.new(verb: :finish, from: %w[performing paused], to: "finished", outside: false,
60
+ error: :InvalidTransition, effect: "the last step (or one that called finished!) completed"),
61
+ Transition.new(verb: :reattempt, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
62
+ effect: "the step failed and its next attempt is scheduled"),
63
+ Transition.new(verb: :step_pause, from: %w[performing], to: "paused", outside: false,
64
+ error: :InvalidTransition,
65
+ effect: "the step completed after pause!, or failed (an exception or a stall)"),
66
+ Transition.new(verb: :step_cancel, from: %w[performing paused], to: "cancelled", outside: false,
67
+ error: :InvalidTransition, effect: "the step called cancel!, or cancel_if or a policy cancelled it"),
68
+ Transition.new(verb: :unpause, from: %w[paused], to: "performing", outside: false, error: :NotPaused,
69
+ effect: "resume! while the step that was running at the outside pause! is still running: " \
70
+ "the run goes on performing it"),
71
+ Transition.new(verb: :hold, from: %w[paused], to: "paused", outside: false, error: :InvalidTransition,
72
+ effect: "an outcome that arrived after an outside pause!: recorded on the step; a " \
73
+ "completed, skipped or reattempted step has its next row scheduled; the workflow " \
74
+ "stays paused"),
75
+ Transition.new(verb: :park, from: %w[performing], to: "waiting", outside: false, error: :InvalidTransition,
76
+ effect: "the step waits for a matching signal or for its fan-out batch; nothing is sent " \
77
+ "until it is woken"),
78
+ Transition.new(verb: :wake, from: %w[waiting], to: "ready", outside: false, error: :InvalidTransition,
79
+ effect: "a matching signal arrived, or the wait timed out with on_timeout: :run: the " \
80
+ "waiting step is scheduled again"),
81
+ Transition.new(verb: :resolve, from: %w[waiting], to: "performing", outside: false,
82
+ error: :InvalidTransition,
83
+ effect: "the fan-out batch finished, the wait timed out, or an operator skipped the step: " \
84
+ "its outcome is applied as for a finished attempt"),
85
+ Transition.new(verb: :rewait, from: %w[paused], to: "waiting", outside: false, error: :NotPaused,
86
+ effect: "resume! on a run paused while its step was waiting: it waits again")
87
+ ].to_h { |transition| [transition.verb, transition] }
88
+ )
89
+
90
+ module_function
91
+
92
+ def fetch(verb)
93
+ TRANSITIONS.fetch(verb.to_sym) { raise ArgumentError, "unknown workflow verb #{verb.inspect}" }
94
+ end
95
+
96
+ def from(verb) = fetch(verb).from
97
+
98
+ def to(verb) = fetch(verb).to
99
+
100
+ def legal?(verb, status) = from(verb).include?(status.to_s)
101
+
102
+ # Returns the target state, or raises the verb's documented error.
103
+ def check!(verb, status)
104
+ transition = fetch(verb)
105
+ return transition.to if transition.from.include?(status.to_s)
106
+
107
+ raise Workflow.const_get(transition.error),
108
+ "cannot #{verb.to_s.tr("_", " ")} a #{status} workflow (allowed from: #{transition.from.join(", ")})"
109
+ end
110
+
111
+ # The outside verbs legal in +status+: the dashboard's buttons and the
112
+ # MCP write tools.
113
+ def available_actions(status)
114
+ state = status.to_s
115
+ TRANSITIONS.each_value.select { |transition| transition.outside && transition.from.include?(state) }.map(&:verb)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The workflow step running in this fiber, while its body runs:
6
+ #
7
+ # Pgbus::Workflow::StepContext.workflow_id # => "6f1c…" (nil outside a step)
8
+ # Rails.logger.tagged("wf=#{Pgbus::Workflow::StepContext.workflow_id}") { ... }
9
+ #
10
+ # Not named Current: every constant under Pgbus::Workflow is visible from
11
+ # a workflow subclass's step bodies, and a bare `Current` there must stay
12
+ # the app's ActiveSupport::CurrentAttributes.
13
+ #
14
+ # Fiber-local (Thread.current[] is per fiber), saved and restored around
15
+ # each step so a nested or concurrent step never sees another's context.
16
+ # Pgbus's own log lines inside a step carry workflow=… step=… attempt=…
17
+ # through Pgbus::LogFormatter's context.
18
+ module StepContext
19
+ KEY = :pgbus_workflow_step_context
20
+
21
+ # The running step, as the fiber-local value.
22
+ Frame = Data.define(:workflow_id, :workflow_class, :step_name, :attempt)
23
+
24
+ # Which attempt a workflow instance is running its body for. Set by the
25
+ # StepJob on the fresh instance it runs the body on, never by app code.
26
+ Attempt = Data.define(:step_id, :step_name, :attempt)
27
+
28
+ module_function
29
+
30
+ def with(workflow_id:, workflow_class:, step_name:, attempt:, &)
31
+ previous = Thread.current[KEY]
32
+ Thread.current[KEY] = Frame.new(workflow_id: workflow_id, workflow_class: workflow_class,
33
+ step_name: step_name, attempt: attempt)
34
+ LogFormatter.with_context(workflow: workflow_id, step: step_name, attempt: attempt, &)
35
+ ensure
36
+ Thread.current[KEY] = previous
37
+ end
38
+
39
+ def context = Thread.current[KEY]
40
+
41
+ def workflow_id = context&.workflow_id
42
+
43
+ def workflow_class = context&.workflow_class
44
+
45
+ def step_name = context&.step_name
46
+
47
+ def attempt = context&.attempt
48
+
49
+ # "workflow=… step=… attempt=…" for a log line, or "" outside a step.
50
+ def log_tag
51
+ current = context
52
+ return "" unless current
53
+
54
+ "workflow=#{current.workflow_id} step=#{current.step_name} attempt=#{current.attempt}"
55
+ end
56
+ end
57
+ end
58
+ end