rigid_workflow 1.0.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +648 -0
  3. data/README.md +427 -0
  4. data/app/assets/stylesheets/rigid_workflow/application.css +68 -0
  5. data/app/controllers/rigid_workflow/application_controller.rb +90 -0
  6. data/app/controllers/rigid_workflow/runs_controller.rb +133 -0
  7. data/app/controllers/rigid_workflow/workflows_controller.rb +11 -0
  8. data/app/helpers/rigid_workflow/runs_helper.rb +63 -0
  9. data/app/helpers/rigid_workflow/workflows_helper.rb +110 -0
  10. data/app/javascript/rigid_workflow/application.js +9 -0
  11. data/app/javascript/rigid_workflow/controllers/application.js +7 -0
  12. data/app/javascript/rigid_workflow/controllers/index.js +4 -0
  13. data/app/javascript/rigid_workflow/controllers/selection_controller.js +91 -0
  14. data/app/javascript/rigid_workflow/controllers/workflow_viz_controller.js +160 -0
  15. data/app/jobs/rigid_workflow/activity_job.rb +14 -0
  16. data/app/jobs/rigid_workflow/timer_job.rb +13 -0
  17. data/app/jobs/rigid_workflow/workflow_job.rb +17 -0
  18. data/app/models/rigid_workflow/run.rb +209 -0
  19. data/app/models/rigid_workflow/signal.rb +77 -0
  20. data/app/models/rigid_workflow/step.rb +182 -0
  21. data/app/models/rigid_workflow/step_attempt.rb +48 -0
  22. data/app/views/rigid_workflow/layouts/application.html.erb +77 -0
  23. data/app/views/rigid_workflow/runs/index.html.erb +113 -0
  24. data/app/views/rigid_workflow/runs/show.html.erb +130 -0
  25. data/app/views/rigid_workflow/workflows/index.html.erb +82 -0
  26. data/config/importmap.rb +11 -0
  27. data/config/routes.rb +17 -0
  28. data/lib/generators/rigid_workflow/activity_generator.rb +15 -0
  29. data/lib/generators/rigid_workflow/install_generator.rb +58 -0
  30. data/lib/generators/rigid_workflow/templates/activity.rb.erb +6 -0
  31. data/lib/generators/rigid_workflow/templates/initializer.rb.erb +24 -0
  32. data/lib/generators/rigid_workflow/templates/migration.rb.erb +54 -0
  33. data/lib/generators/rigid_workflow/templates/workflow.rb.erb +6 -0
  34. data/lib/generators/rigid_workflow/workflow_generator.rb +15 -0
  35. data/lib/rigid_workflow/activity.rb +54 -0
  36. data/lib/rigid_workflow/engine.rb +35 -0
  37. data/lib/rigid_workflow/id_generator.rb +21 -0
  38. data/lib/rigid_workflow/orchestrator.rb +83 -0
  39. data/lib/rigid_workflow/step_result.rb +50 -0
  40. data/lib/rigid_workflow/version.rb +5 -0
  41. data/lib/rigid_workflow/workflow.rb +59 -0
  42. data/lib/rigid_workflow/workflow_runner.rb +334 -0
  43. data/lib/rigid_workflow.rb +65 -0
  44. metadata +204 -0
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uuid7"
4
+
5
+ module RigidWorkflow
6
+ # Generates and validates UUIDv7 identifiers.
7
+ # UUIDv7 provides time-ordered IDs for efficient database indexing.
8
+ class IdGenerator
9
+ def self.uuid_v7
10
+ UUID7.generate
11
+ end
12
+
13
+ def self.valid_uuid?(value)
14
+ return false if value.nil? || value.to_s.empty?
15
+
16
+ value.to_s.match?(
17
+ /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Orchestrates workflow execution.
5
+ # Manages workflow starting, scheduling, and state advancement.
6
+ class Orchestrator
7
+ class << self
8
+ def start(workflow_class, params = {})
9
+ RigidWorkflow.instrument(
10
+ "workflow.start",
11
+ workflow_class: workflow_class.name
12
+ ) do
13
+ run =
14
+ RigidWorkflow::Run.create!(
15
+ workflow_class: workflow_class.name,
16
+ version: workflow_class.workflow_version,
17
+ params: params
18
+ )
19
+ schedule(run)
20
+ run
21
+ end
22
+ end
23
+
24
+ def schedule(run, options = {})
25
+ RigidWorkflow::WorkflowJob.set(
26
+ wait: options[:wait],
27
+ wait_until: options[:wait_until],
28
+ priority: options[:priority]
29
+ ).perform_later(run.id)
30
+ end
31
+
32
+ def advance!(run)
33
+ run.increment(:iterations)
34
+ return if run.finished?
35
+
36
+ raise "Cannot advance a compensating workflow" if run.compensating?
37
+
38
+ run.start! if run.pending?
39
+
40
+ # brakeman:ignore UnsafeReflection
41
+ # This is intentional - workflow_class is a validated string from the workflow definition
42
+ workflow_class = run.workflow_class.constantize
43
+ unless workflow_class && workflow_class < RigidWorkflow::Workflow
44
+ raise ArgumentError, "#{run.workflow_class} is not a valid Workflow"
45
+ end
46
+
47
+ workflow = workflow_class.new(run)
48
+
49
+ catch(:suspend) do
50
+ if run.running?
51
+ workflow.run
52
+ run.finish!
53
+ end
54
+ end
55
+
56
+ run.save! if run.changed?
57
+ rescue ActivityFailedError => error
58
+ # A step failed after exhausting all retries
59
+ if run.running? && !run.compensating?
60
+ failed_step = run.steps.where(status: :failed).last
61
+ if failed_step && step_exhausted_retries?(failed_step)
62
+ # Attempt compensation - if it raises, let it propagate
63
+ run.compensate!
64
+ # If compensate! succeeds, don't re-raise ActivityFailedError
65
+ return
66
+ end
67
+ end
68
+
69
+ # If we get here, no compensation was performed
70
+ run.fail! unless run.compensating?
71
+ raise error
72
+ rescue => error
73
+ # Handle non-ActivityFailedError exceptions
74
+ run.fail! unless run.compensating?
75
+ raise error
76
+ end
77
+
78
+ def step_exhausted_retries?(step)
79
+ step.attempts.count >= step.max_attempts
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Represents the result of a workflow step execution.
5
+ class StepResult
6
+ # @api private
7
+ def initialize(status, output = {})
8
+ @status = status.to_s
9
+ @output = (output || {}).with_indifferent_access
10
+ end
11
+
12
+ # Returns true if the step completed successfully.
13
+ # @return [Boolean]
14
+ def success?
15
+ @status == "completed"
16
+ end
17
+
18
+ # Returns true if the step failed all retry attempts.
19
+ # @return [Boolean]
20
+ def failure?
21
+ @status == "failed"
22
+ end
23
+
24
+ delegate :[], to: :@output
25
+
26
+ # Returns the output as a hash.
27
+ # @return [Hash]
28
+ def to_h
29
+ @output.to_h
30
+ end
31
+ end
32
+
33
+ # Exception raised when an activity fails after all retry attempts.
34
+ class ActivityFailedError < StandardError
35
+ def initialize(step)
36
+ @step = step
37
+ super(
38
+ "Activity #{step.activity_class} (step: #{step.step_name}) failed after #{step.attempt_count} attempts"
39
+ )
40
+ end
41
+ end
42
+
43
+ # Exception raised when parallel or race blocks are nested.
44
+ class NestedBlockError < StandardError
45
+ end
46
+
47
+ # Exception raised when an activity has a duplicate name.
48
+ class DuplicateNameError < StandardError
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,59 @@
1
+ require "forwardable"
2
+
3
+ module RigidWorkflow
4
+ # Base class for all workflow definitions.
5
+ # Subclasses must implement the #run method.
6
+ class Workflow
7
+ extend Forwardable
8
+
9
+ class_attribute :workflow_version, default: 1
10
+
11
+ # Sets the version for this workflow class.
12
+ # @param value [Integer] The version number
13
+ # @return [Integer]
14
+ def self.version(value)
15
+ self.workflow_version = value
16
+ end
17
+
18
+ # Starts a workflow run for this class
19
+ # @param params [Hash] Initial parameters
20
+ # @return [RigidWorkflow::Run] The created workflow run
21
+ def self.start!(params = {})
22
+ RigidWorkflow::Orchestrator.start(self, params)
23
+ end
24
+
25
+ # @api private
26
+ def initialize(workflow_run)
27
+ @workflow_run = workflow_run
28
+ @run_version = workflow_run.version
29
+ @runner = WorkflowRunner.new(workflow_run)
30
+ @params = workflow_run.params.with_indifferent_access
31
+ end
32
+
33
+ # The main workflow logic. Must be implemented by subclasses.
34
+ def run
35
+ raise NotImplementedError, "Subclass must implement #run"
36
+ end
37
+
38
+ # Logs a message that is persisted and associated with the workflow run.
39
+ # @param message [String] The message to log
40
+ def log(message, severity: Logger::INFO)
41
+ return if !RigidWorkflow.config.logging? && Rails.env.test?
42
+
43
+ @log_index ||= 0
44
+ @log_index += 1
45
+
46
+ memo "log_#{@log_index}" do
47
+ puts message unless Rails.env.test?
48
+ log_to_rails(severity, message)
49
+ end
50
+ end
51
+
52
+ def log_to_rails(severity, message)
53
+ Rails.logger.log(severity, "[#{@workflow_run.id.to_s[0..12]}] #{message}")
54
+ end
55
+
56
+ # Delegate these methods to the @runner object
57
+ def_delegators :@runner, :step, :memo, :loop, :wait, :parallel, :race
58
+ end
59
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Executes workflow definitions.
5
+ # Handles step execution, signal waiting, loops, parallel and race blocks.
6
+ class WorkflowRunner
7
+ def initialize(workflow_run)
8
+ @workflow_run = workflow_run
9
+ @params = workflow_run.params.with_indifferent_access
10
+ @activity_cache = workflow_run.steps.reload.index_by(&:step_name)
11
+ @signal_cache = workflow_run.signals.reload.index_by(&:name)
12
+ @activity_names_seen = Set.new
13
+ @parallel_mode = false
14
+ @race_mode = false
15
+ @loop_stack = []
16
+ @loop_index_stack = []
17
+ @suspensions = []
18
+ @parallel_results = nil
19
+ @race_winner = nil
20
+ end
21
+
22
+ def run(&block)
23
+ instance_eval(&block)
24
+ end
25
+
26
+ def step(name, activity_class, **options)
27
+ full_name = build_step_name(name)
28
+ if @activity_names_seen.include?(full_name)
29
+ raise DuplicateNameError.new(
30
+ "Activity '#{full_name}' cannot be called more than once"
31
+ )
32
+ end
33
+
34
+ @activity_names_seen << full_name
35
+ step = @activity_cache[full_name]
36
+
37
+ if step&.completed?
38
+ result = StepResult.new(step.status, step.current_attempt&.output)
39
+ handle_completed(name, result)
40
+ return result
41
+ end
42
+
43
+ if step&.failed?
44
+ unless step.attempt_count < step.max_attempts
45
+ raise ActivityFailedError.new(step)
46
+ end
47
+
48
+ schedule_retry(step)
49
+ handle_suspension
50
+ end
51
+
52
+ if step&.pending?
53
+ schedule_step(step.id, options)
54
+ handle_suspension
55
+ end
56
+
57
+ force_async =
58
+ activity_class.respond_to?(:force_async?) && activity_class.force_async?
59
+ should_async =
60
+ force_async || options[:async] || options[:wait] || options[:wait_until]
61
+
62
+ if !@parallel_mode && !@race_mode && !should_async && step.nil?
63
+ step = create_workflow_step(full_name, activity_class, options)
64
+
65
+ step.start!
66
+ activity = activity_class.new(step)
67
+ input_hash = step.input.deep_symbolize_keys
68
+
69
+ begin
70
+ result = activity.perform(**input_hash)
71
+ step.complete!(result)
72
+ rescue StandardError => error
73
+ handle_activity_error(step, error)
74
+ end
75
+
76
+ @activity_cache[full_name] = step
77
+ handle_completed(
78
+ name,
79
+ StepResult.new(step.status, step.current_attempt&.output)
80
+ )
81
+ return StepResult.new(step.status, step.current_attempt&.output)
82
+ end
83
+
84
+ if step.nil?
85
+ step = create_workflow_step(full_name, activity_class, options)
86
+ schedule_step(step.id, options)
87
+
88
+ if should_async && !@parallel_mode && !@race_mode
89
+ @suspensions << :suspend
90
+ throw :suspend
91
+ end
92
+ end
93
+
94
+ handle_suspension
95
+ end
96
+
97
+ def memo(name)
98
+ full_name = "memo_#{name}"
99
+ @memory ||= (@workflow_run.memory || {}).to_h.with_indifferent_access
100
+
101
+ return @memory[full_name] if @memory.key?(full_name)
102
+
103
+ value = yield
104
+ @memory[full_name] = value
105
+ @workflow_run.memory = @memory
106
+ value
107
+ end
108
+
109
+ def loop(name, collection, &block)
110
+ @memory ||= (@workflow_run.memory || {}).to_h.with_indifferent_access
111
+
112
+ full_name = name.to_s
113
+ full_name = "#{@loop_stack.last}_" + full_name if @loop_stack.any?
114
+
115
+ memory_key = "loop_#{full_name}"
116
+ index = @memory[memory_key] || 0
117
+ return if index >= collection.size
118
+
119
+ @loop_stack.push(name)
120
+ @loop_index_stack.push(index)
121
+
122
+ @workflow_run.memory = @memory
123
+ collection.each_with_index do |item, current_index|
124
+ next if current_index < index
125
+
126
+ @memory[memory_key] = current_index
127
+ @loop_index_stack[-1] = current_index
128
+ yield(item, current_index)
129
+ end
130
+
131
+ @memory.delete(memory_key)
132
+ ensure
133
+ @loop_stack.pop
134
+ @loop_index_stack.pop
135
+ end
136
+
137
+ def wait(name, timeout: nil, **options)
138
+ full_name = name.to_s
139
+ full_name =
140
+ "#{@loop_stack.last}_#{@loop_index_stack.last}_" +
141
+ full_name if @loop_stack.any?
142
+
143
+ signal = @signal_cache[full_name]
144
+
145
+ if signal&.received?
146
+ @signal_cache[full_name] = signal
147
+ result = signal.payload
148
+ handle_completed(name, result)
149
+ return result
150
+ end
151
+
152
+ if signal&.expired?
153
+ signal.update!(received_at: Time.current) unless signal.received?
154
+ @signal_cache[full_name] = signal
155
+ handle_completed(name, nil)
156
+ return nil
157
+ end
158
+
159
+ if signal.nil?
160
+ is_timer =
161
+ timeout.is_a?(ActiveSupport::Duration) || timeout.is_a?(Numeric)
162
+
163
+ if is_timer
164
+ signal =
165
+ @workflow_run.signals.create!(
166
+ name: full_name,
167
+ expires_at: Time.current + timeout,
168
+ payload: options[:payload] || {}
169
+ )
170
+ schedule_timer(signal.id, wait: timeout)
171
+ else
172
+ signal = @workflow_run.signals.create!(name: full_name)
173
+ end
174
+ end
175
+
176
+ @signal_cache[full_name] = signal
177
+
178
+ return handle_suspension if @race_mode
179
+
180
+ throw :suspend unless @parallel_mode
181
+ handle_suspension
182
+ nil
183
+ end
184
+
185
+ def parallel(name)
186
+ if @parallel_mode || @race_mode
187
+ raise NestedBlockError,
188
+ "Nesting parallel or race blocks is not supported"
189
+ end
190
+
191
+ @parallel_results = {}
192
+ old_mode = @parallel_mode
193
+ @parallel_mode = true
194
+ yield
195
+ @parallel_mode = old_mode
196
+
197
+ if @suspensions.any?
198
+ throw :suspend
199
+ else
200
+ @parallel_results.dup.tap { @parallel_results = nil }
201
+ end
202
+ ensure
203
+ @parallel_mode = old_mode
204
+ end
205
+
206
+ def race(name)
207
+ if @parallel_mode || @race_mode
208
+ raise NestedBlockError,
209
+ "Nesting parallel or race blocks is not supported"
210
+ end
211
+
212
+ @race_winner = nil
213
+ old_mode = @race_mode
214
+ @race_mode = true
215
+ yield
216
+ @race_mode = old_mode
217
+
218
+ if @race_winner
219
+ @race_winner.dup.tap { @race_winner = nil }
220
+ elsif @suspensions.any?
221
+ throw :suspend
222
+ end
223
+ ensure
224
+ @race_mode = old_mode
225
+ end
226
+
227
+ private
228
+
229
+ def create_workflow_step(full_name, activity_class, options)
230
+ @workflow_run.steps.create!(
231
+ step_name: full_name,
232
+ activity_class: activity_class.to_s,
233
+ status: :enqueued,
234
+ input: options[:input] || @params,
235
+ max_attempts:
236
+ options[:max_attempts] || RigidWorkflow.config.max_attempts
237
+ )
238
+ end
239
+
240
+ def handle_activity_error(step, error)
241
+ error_details = {
242
+ error: error.class.to_s,
243
+ message: error.message,
244
+ backtrace: error.backtrace&.first(5)
245
+ }
246
+
247
+ # Mark current attempt as failed (Bug 1 fix)
248
+ step.current_attempt&.update!(
249
+ status: :failed,
250
+ failed_at: Time.current,
251
+ error_details: error_details
252
+ )
253
+
254
+ if step.attempt_count >= step.max_attempts
255
+ step.update!(status: :failed)
256
+ raise ActivityFailedError.new(step)
257
+ else
258
+ schedule_retry(step, error_details)
259
+
260
+ @suspensions << :suspend
261
+ throw :suspend
262
+ end
263
+ end
264
+
265
+ def build_step_name(name)
266
+ full_name = name.to_s
267
+ if @loop_stack.any?
268
+ @loop_stack.reverse_each.with_index do |n, i|
269
+ full_name = "#{n}_#{@loop_index_stack[i]}_" + full_name
270
+ end
271
+ end
272
+ full_name
273
+ end
274
+
275
+ def schedule_step(step_id, options = {})
276
+ RigidWorkflow::ActivityJob.set(
277
+ wait: options[:wait],
278
+ wait_until: options[:wait_until],
279
+ priority: options[:priority] || @workflow_run.created_at.to_i
280
+ ).perform_later(step_id)
281
+ end
282
+
283
+ def schedule_timer(signal_id, options = {})
284
+ RigidWorkflow::TimerJob.set(
285
+ wait: options[:wait],
286
+ wait_until: options[:wait_until],
287
+ priority: options[:priority]
288
+ ).perform_later(signal_id)
289
+ end
290
+
291
+ def handle_completed(name, result)
292
+ if @parallel_mode
293
+ @parallel_results[name] = result
294
+ elsif @race_mode && @race_winner.nil?
295
+ @race_winner = { name: name, result: result }
296
+ end
297
+ end
298
+
299
+ def handle_suspension
300
+ @suspensions << :suspend
301
+ throw :suspend unless @parallel_mode || @race_mode
302
+ end
303
+
304
+ def schedule_retry(step, error_details = nil, retry_options = {})
305
+ retry_delay =
306
+ retry_options[:retry_delay] || RigidWorkflow.config.retry_delay
307
+ max_exponent = 10
308
+ exponent = [step.attempt_count, max_exponent].min
309
+ base_delay = retry_delay * (2**exponent)
310
+
311
+ # ±20% jitter
312
+ jitter = base_delay * 0.2 * (rand * 2 - 1)
313
+ delay = [base_delay + jitter, 1.second].max
314
+
315
+ step.attempts.create!(
316
+ attempt_number: step.attempt_count + 1,
317
+ status: :pending,
318
+ input: step.input
319
+ )
320
+
321
+ step.update!(status: :enqueued, scheduled_at: Time.current + delay)
322
+
323
+ RigidWorkflow.instrument(
324
+ "step.retry",
325
+ run_id: step.rigid_workflow_run_id,
326
+ step_id: step.id,
327
+ attempt: step.attempt_count,
328
+ delay: delay
329
+ )
330
+
331
+ schedule_step(step.id, wait: delay)
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_record"
5
+ require "action_view"
6
+ require "action_controller"
7
+ require "rails"
8
+
9
+ require "rigid_workflow/engine"
10
+
11
+ # RigidWorkflow provides a durable workflow orchestration engine for Rails.
12
+ # It allows you to define complex business processes as code that are
13
+ # persistent, observable, and resilient to failures.
14
+ module RigidWorkflow
15
+ #
16
+ # Configuration for RigidWorkflow
17
+ #
18
+ Configuration =
19
+ Struct.new(:admin_controller, :max_attempts, :retry_delay, :logging) do
20
+ def logging?
21
+ logging || false
22
+ end
23
+ end
24
+
25
+ # Returns the current configuration
26
+ # @return [Configuration]
27
+ def self.config
28
+ @config ||= Configuration.new(nil, 3, 15.seconds)
29
+ end
30
+
31
+ # @yield [config] The current configuration
32
+ def self.configure
33
+ yield config
34
+ end
35
+
36
+ #
37
+ # Base exception class for RigidWorkflow errors.
38
+ #
39
+ class Error < StandardError
40
+ end
41
+
42
+ #
43
+ # Subscribe to RigidWorkflow events
44
+ #
45
+ # @param event_name [String] The name of the event
46
+ # @yield [payload] The event payload
47
+ def self.on(event_name, &block)
48
+ ActiveSupport::Notifications.subscribe(
49
+ "#{event_name}.rigid_workflow"
50
+ ) do |*args|
51
+ block.call(ActiveSupport::Notifications::Event.new(*args).payload)
52
+ end
53
+ end
54
+
55
+ # Instrument a RigidWorkflow event
56
+ # @param event_name [String] The name of the event
57
+ # @param payload [Hash] The event payload
58
+ def self.instrument(event_name, payload = {}, &block)
59
+ ActiveSupport::Notifications.instrument(
60
+ "#{event_name}.rigid_workflow",
61
+ payload,
62
+ &block
63
+ )
64
+ end
65
+ end