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.
- checksums.yaml +7 -0
- data/LICENSE.txt +648 -0
- data/README.md +427 -0
- data/app/assets/stylesheets/rigid_workflow/application.css +68 -0
- data/app/controllers/rigid_workflow/application_controller.rb +90 -0
- data/app/controllers/rigid_workflow/runs_controller.rb +133 -0
- data/app/controllers/rigid_workflow/workflows_controller.rb +11 -0
- data/app/helpers/rigid_workflow/runs_helper.rb +63 -0
- data/app/helpers/rigid_workflow/workflows_helper.rb +110 -0
- data/app/javascript/rigid_workflow/application.js +9 -0
- data/app/javascript/rigid_workflow/controllers/application.js +7 -0
- data/app/javascript/rigid_workflow/controllers/index.js +4 -0
- data/app/javascript/rigid_workflow/controllers/selection_controller.js +91 -0
- data/app/javascript/rigid_workflow/controllers/workflow_viz_controller.js +160 -0
- data/app/jobs/rigid_workflow/activity_job.rb +14 -0
- data/app/jobs/rigid_workflow/timer_job.rb +13 -0
- data/app/jobs/rigid_workflow/workflow_job.rb +17 -0
- data/app/models/rigid_workflow/run.rb +209 -0
- data/app/models/rigid_workflow/signal.rb +77 -0
- data/app/models/rigid_workflow/step.rb +182 -0
- data/app/models/rigid_workflow/step_attempt.rb +48 -0
- data/app/views/rigid_workflow/layouts/application.html.erb +77 -0
- data/app/views/rigid_workflow/runs/index.html.erb +113 -0
- data/app/views/rigid_workflow/runs/show.html.erb +130 -0
- data/app/views/rigid_workflow/workflows/index.html.erb +82 -0
- data/config/importmap.rb +11 -0
- data/config/routes.rb +17 -0
- data/lib/generators/rigid_workflow/activity_generator.rb +15 -0
- data/lib/generators/rigid_workflow/install_generator.rb +58 -0
- data/lib/generators/rigid_workflow/templates/activity.rb.erb +6 -0
- data/lib/generators/rigid_workflow/templates/initializer.rb.erb +24 -0
- data/lib/generators/rigid_workflow/templates/migration.rb.erb +54 -0
- data/lib/generators/rigid_workflow/templates/workflow.rb.erb +6 -0
- data/lib/generators/rigid_workflow/workflow_generator.rb +15 -0
- data/lib/rigid_workflow/activity.rb +54 -0
- data/lib/rigid_workflow/engine.rb +35 -0
- data/lib/rigid_workflow/id_generator.rb +21 -0
- data/lib/rigid_workflow/orchestrator.rb +83 -0
- data/lib/rigid_workflow/step_result.rb +50 -0
- data/lib/rigid_workflow/version.rb +5 -0
- data/lib/rigid_workflow/workflow.rb +59 -0
- data/lib/rigid_workflow/workflow_runner.rb +334 -0
- data/lib/rigid_workflow.rb +65 -0
- 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,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
|