geneva_drive 0.4.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +38 -0
- data/MANUAL.md +1037 -355
- data/README.md +3 -1
- data/Rakefile +13 -2
- data/bin/md2html +96 -19
- data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
- data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
- data/lib/geneva_drive/combined_exception_policy.rb +106 -0
- data/lib/geneva_drive/exception_policy.rb +354 -0
- data/lib/geneva_drive/executor.rb +430 -148
- data/lib/geneva_drive/flow_control.rb +82 -11
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
- data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
- data/lib/geneva_drive/migration_helpers.rb +55 -20
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/step_definition.rb +160 -40
- data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
- data/lib/geneva_drive/step_execution.rb +124 -2
- data/lib/geneva_drive/test_helpers.rb +123 -4
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +485 -73
- data/lib/geneva_drive.rb +100 -2
- data/test/dsl/class_level_on_exception_test.rb +200 -0
- data/test/dsl/exception_policy_test.rb +331 -0
- data/test/dsl/step_definition_test.rb +126 -2
- data/test/dsl/step_level_exception_policy_test.rb +143 -0
- data/test/dummy/config/storage.yml +7 -0
- data/test/dummy_install/config/storage.yml +7 -0
- data/test/jobs/housekeeping_job_test.rb +451 -5
- data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +137 -0
- data/test/step_execution/metadata_accessor_test.rb +147 -0
- data/test/test_helper.rb +37 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
- data/test/workflow/composable_exception_policy_test.rb +231 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/executor_test.rb +100 -4
- data/test/workflow/external_cancel_test.rb +245 -0
- data/test/workflow/flow_control_test.rb +4 -3
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/max_reattempts_test.rb +51 -0
- data/test/workflow/pause_resume_test.rb +574 -0
- data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/resume_and_skip_test.rb +57 -41
- data/test/workflow/with_inline_enqueue_test.rb +137 -0
- data/test/workflow/workflow_test.rb +117 -2
- metadata +47 -61
- data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
- data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
- data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/schema.rb +0 -72
- data/test/dummy/log/development.log +0 -112
- data/test/dummy/log/test.log +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
- data/test/dummy_install/Rakefile +0 -6
- data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
- data/test/dummy_install/app/controllers/application_controller.rb +0 -4
- data/test/dummy_install/app/helpers/application_helper.rb +0 -2
- data/test/dummy_install/app/jobs/application_job.rb +0 -7
- data/test/dummy_install/app/models/application_record.rb +0 -3
- data/test/dummy_install/app/models/user.rb +0 -9
- data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
- data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
- data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
- data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
- data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
- data/test/dummy_install/bin/dev +0 -2
- data/test/dummy_install/bin/rails +0 -4
- data/test/dummy_install/bin/rake +0 -4
- data/test/dummy_install/bin/setup +0 -34
- data/test/dummy_install/config/application.rb +0 -29
- data/test/dummy_install/config/boot.rb +0 -5
- data/test/dummy_install/config/cable.yml +0 -10
- data/test/dummy_install/config/database.yml +0 -13
- data/test/dummy_install/config/environment.rb +0 -5
- data/test/dummy_install/config/environments/development.rb +0 -51
- data/test/dummy_install/config/environments/production.rb +0 -70
- data/test/dummy_install/config/environments/test.rb +0 -39
- data/test/dummy_install/config/initializers/assets.rb +0 -7
- data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
- data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
- data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
- data/test/dummy_install/config/initializers/inflections.rb +0 -16
- data/test/dummy_install/config/locales/en.yml +0 -31
- data/test/dummy_install/config/puma.rb +0 -38
- data/test/dummy_install/config/routes.rb +0 -3
- data/test/dummy_install/config.ru +0 -6
- data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
- data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
- data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy_install/db/schema.rb +0 -76
- data/test/dummy_install/log/test.log +0 -4031
- data/test/dummy_install/public/400.html +0 -114
- data/test/dummy_install/public/404.html +0 -114
- data/test/dummy_install/public/406-unsupported-browser.html +0 -114
- data/test/dummy_install/public/422.html +0 -114
- data/test/dummy_install/public/500.html +0 -114
- data/test/dummy_install/public/icon.png +0 -0
- data/test/dummy_install/public/icon.svg +0 -3
- data/test/dummy_install/tmp/local_secret.txt +0 -1
- data/test/generators/install_generator_test.rb +0 -94
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
# Metadata about a step definition in a workflow.
|
|
4
4
|
# Holds the step name, callable (block or method name), wait time,
|
|
5
|
-
# skip conditions, and exception handling configuration.
|
|
5
|
+
# job options, skip conditions, and exception handling configuration.
|
|
6
6
|
#
|
|
7
7
|
# @api private
|
|
8
8
|
class GenevaDrive::StepDefinition
|
|
9
9
|
# Valid exception handler values
|
|
10
10
|
EXCEPTION_HANDLERS = %i[pause! cancel! reattempt! skip!].freeze
|
|
11
11
|
|
|
12
|
+
# Sentinel value to distinguish "on_exception not provided" from "on_exception: :pause!"
|
|
13
|
+
NOT_SET = Object.new.freeze
|
|
14
|
+
|
|
12
15
|
# Valid types for skip conditions
|
|
13
16
|
VALID_SKIP_CONDITION_TYPES = [Symbol, Proc, TrueClass, FalseClass, NilClass].freeze
|
|
14
17
|
|
|
@@ -21,11 +24,14 @@ class GenevaDrive::StepDefinition
|
|
|
21
24
|
# @return [ActiveSupport::Duration, nil] wait time before executing this step
|
|
22
25
|
attr_reader :wait
|
|
23
26
|
|
|
27
|
+
# @return [Hash] frozen, symbol-keyed Active Job options for this step
|
|
28
|
+
attr_reader :job_options
|
|
29
|
+
|
|
24
30
|
# @return [Proc, Symbol, Boolean, nil] condition for skipping this step
|
|
25
31
|
attr_reader :skip_condition
|
|
26
32
|
|
|
27
|
-
# @return [
|
|
28
|
-
attr_reader :
|
|
33
|
+
# @return [GenevaDrive::ExceptionPolicy, GenevaDrive::CombinedExceptionPolicy] exception handling policy
|
|
34
|
+
attr_reader :exception_policy
|
|
29
35
|
|
|
30
36
|
# @return [String, nil] name of step this should be placed before
|
|
31
37
|
attr_reader :before_step
|
|
@@ -33,9 +39,6 @@ class GenevaDrive::StepDefinition
|
|
|
33
39
|
# @return [String, nil] name of step this should be placed after
|
|
34
40
|
attr_reader :after_step
|
|
35
41
|
|
|
36
|
-
# @return [Integer, nil] maximum consecutive reattempts before pausing (nil = unlimited)
|
|
37
|
-
attr_reader :max_reattempts
|
|
38
|
-
|
|
39
42
|
# @return [Array<String, Integer>, nil] source location where step was called [path, lineno]
|
|
40
43
|
attr_reader :call_location
|
|
41
44
|
|
|
@@ -48,9 +51,13 @@ class GenevaDrive::StepDefinition
|
|
|
48
51
|
# @param callable [Proc, Symbol, nil] the code to execute (block or method name)
|
|
49
52
|
# @param options [Hash] additional options
|
|
50
53
|
# @option options [ActiveSupport::Duration, nil] :wait delay before execution
|
|
54
|
+
# @option options [Hash] :job_options options passed to Active Job's set method
|
|
51
55
|
# @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
|
|
52
56
|
# @option options [Proc, Symbol, Boolean, nil] :if condition for running (inverse of skip_if)
|
|
53
|
-
# @option options [Symbol] :on_exception
|
|
57
|
+
# @option options [Symbol, GenevaDrive::ExceptionPolicy, Proc, Array<GenevaDrive::ExceptionPolicy>] :on_exception
|
|
58
|
+
# how to handle exceptions. An Array of {ExceptionPolicy} objects is wrapped in a
|
|
59
|
+
# {CombinedExceptionPolicy} that resolves specific policies first, then blanket fallback.
|
|
60
|
+
# @option options [Integer, nil] :max_reattempts max consecutive reattempts (symbol form only)
|
|
54
61
|
# @option options [String, Symbol, nil] :before_step position before this step
|
|
55
62
|
# @option options [String, Symbol, nil] :after_step position after this step
|
|
56
63
|
# @param call_location [Array<String, Integer>, nil] source location where step was called
|
|
@@ -62,15 +69,38 @@ class GenevaDrive::StepDefinition
|
|
|
62
69
|
@call_location = call_location
|
|
63
70
|
@block_location = block_location
|
|
64
71
|
@wait = options[:wait]
|
|
72
|
+
@job_options = options.fetch(:job_options, {})
|
|
65
73
|
@skip_if_option = options[:skip_if]
|
|
66
74
|
@if_option = options[:if]
|
|
67
75
|
@skip_condition = @skip_if_option || @if_option
|
|
68
|
-
@
|
|
76
|
+
@on_exception_raw = options.fetch(:on_exception, NOT_SET)
|
|
77
|
+
@max_reattempts_raw = options[:max_reattempts]
|
|
78
|
+
@max_reattempts_explicitly_set = options.key?(:max_reattempts)
|
|
79
|
+
@terminal_action_raw = options[:terminal_action]
|
|
69
80
|
@before_step = options[:before_step]&.to_s
|
|
70
81
|
@after_step = options[:after_step]&.to_s
|
|
71
|
-
@max_reattempts = options.key?(:max_reattempts) ? options[:max_reattempts] : default_max_reattempts
|
|
72
82
|
|
|
73
83
|
validate!
|
|
84
|
+
@job_options = GenevaDrive::JobOptions.validate!(@job_options, context: "Step '#{@name}'").freeze
|
|
85
|
+
@exception_policy = build_exception_policy
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the action symbol from the exception policy.
|
|
89
|
+
# Provided for backward compatibility with code that reads step_def.on_exception.
|
|
90
|
+
#
|
|
91
|
+
# @return [Symbol, nil] the action symbol, or nil for imperative/combined policies
|
|
92
|
+
def on_exception
|
|
93
|
+
return nil unless @exception_policy
|
|
94
|
+
return nil if @exception_policy.is_a?(GenevaDrive::CombinedExceptionPolicy)
|
|
95
|
+
@exception_policy.action
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the max_reattempts from the exception policy.
|
|
99
|
+
# Provided for backward compatibility with code that reads step_def.max_reattempts.
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer, nil]
|
|
102
|
+
def max_reattempts
|
|
103
|
+
@exception_policy&.max_reattempts
|
|
74
104
|
end
|
|
75
105
|
|
|
76
106
|
# Evaluates whether this step should be skipped for the given workflow.
|
|
@@ -82,6 +112,13 @@ class GenevaDrive::StepDefinition
|
|
|
82
112
|
evaluate_condition(@skip_condition, workflow)
|
|
83
113
|
end
|
|
84
114
|
|
|
115
|
+
# Returns whether this is a resumable step.
|
|
116
|
+
#
|
|
117
|
+
# @return [Boolean] false for standard steps
|
|
118
|
+
def resumable?
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
85
122
|
# Executes the step callable in the context of the workflow.
|
|
86
123
|
#
|
|
87
124
|
# @param workflow [GenevaDrive::Workflow] the workflow instance
|
|
@@ -102,17 +139,48 @@ class GenevaDrive::StepDefinition
|
|
|
102
139
|
def validate!
|
|
103
140
|
validate_callable!
|
|
104
141
|
validate_wait!
|
|
105
|
-
|
|
142
|
+
validate_on_exception_raw!
|
|
143
|
+
validate_max_reattempts_raw!
|
|
144
|
+
validate_terminal_action_raw!
|
|
106
145
|
validate_positioning!
|
|
107
146
|
validate_skip_condition!
|
|
108
|
-
validate_max_reattempts!
|
|
109
147
|
end
|
|
110
148
|
|
|
111
|
-
#
|
|
149
|
+
# Builds the ExceptionPolicy from validated raw inputs.
|
|
150
|
+
# This is the single place where symbols, procs, arrays, and policies are normalized.
|
|
151
|
+
#
|
|
152
|
+
# @return [GenevaDrive::ExceptionPolicy, GenevaDrive::CombinedExceptionPolicy]
|
|
153
|
+
def build_exception_policy
|
|
154
|
+
return nil if @on_exception_raw == NOT_SET
|
|
155
|
+
|
|
156
|
+
case @on_exception_raw
|
|
157
|
+
when Array
|
|
158
|
+
GenevaDrive::CombinedExceptionPolicy.new(@on_exception_raw)
|
|
159
|
+
when GenevaDrive::ExceptionPolicy
|
|
160
|
+
@on_exception_raw
|
|
161
|
+
when Proc
|
|
162
|
+
GenevaDrive::ExceptionPolicy.new(&@on_exception_raw)
|
|
163
|
+
when Symbol
|
|
164
|
+
max = (@max_reattempts_raw.nil? && !explicitly_set_max_reattempts?) ? default_max_reattempts(@on_exception_raw) : @max_reattempts_raw
|
|
165
|
+
opts = {max_reattempts: max}
|
|
166
|
+
opts[:terminal_action] = @terminal_action_raw if @terminal_action_raw
|
|
167
|
+
GenevaDrive::ExceptionPolicy.new(@on_exception_raw, **opts)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Whether max_reattempts: was explicitly passed in options.
|
|
112
172
|
#
|
|
113
|
-
# @return [
|
|
114
|
-
def
|
|
115
|
-
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def explicitly_set_max_reattempts?
|
|
175
|
+
@max_reattempts_explicitly_set
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Returns the default max_reattempts value based on action.
|
|
179
|
+
#
|
|
180
|
+
# @param action [Symbol]
|
|
181
|
+
# @return [Integer, nil] 100 if action is :reattempt!, nil otherwise
|
|
182
|
+
def default_max_reattempts(action)
|
|
183
|
+
(action == :reattempt!) ? 100 : nil
|
|
116
184
|
end
|
|
117
185
|
|
|
118
186
|
# Validates that the callable is present and valid.
|
|
@@ -137,14 +205,86 @@ class GenevaDrive::StepDefinition
|
|
|
137
205
|
"Step '#{@name}' has invalid wait value: must be non-negative"
|
|
138
206
|
end
|
|
139
207
|
|
|
140
|
-
# Validates the
|
|
208
|
+
# Validates the raw on_exception value before unfolding.
|
|
141
209
|
#
|
|
142
210
|
# @raise [StepConfigurationError] if on_exception is invalid
|
|
143
|
-
def
|
|
144
|
-
return if
|
|
211
|
+
def validate_on_exception_raw!
|
|
212
|
+
return if @on_exception_raw == NOT_SET
|
|
145
213
|
|
|
146
|
-
|
|
147
|
-
|
|
214
|
+
case @on_exception_raw
|
|
215
|
+
when Symbol
|
|
216
|
+
return if EXCEPTION_HANDLERS.include?(@on_exception_raw)
|
|
217
|
+
raise GenevaDrive::StepConfigurationError,
|
|
218
|
+
"Step '#{@name}' has invalid on_exception: must be one of #{EXCEPTION_HANDLERS.join(", ")}"
|
|
219
|
+
when GenevaDrive::ExceptionPolicy, Proc
|
|
220
|
+
nil
|
|
221
|
+
when Array
|
|
222
|
+
@on_exception_raw.each do |element|
|
|
223
|
+
unless element.is_a?(GenevaDrive::ExceptionPolicy)
|
|
224
|
+
raise GenevaDrive::StepConfigurationError,
|
|
225
|
+
"Step '#{@name}' has invalid on_exception array element: " \
|
|
226
|
+
"every element must be a GenevaDrive::ExceptionPolicy, got #{element.class}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
raise GenevaDrive::StepConfigurationError,
|
|
231
|
+
"Step '#{@name}' has invalid on_exception: must be a Symbol, ExceptionPolicy, Proc, or Array of ExceptionPolicy"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Validates the raw max_reattempts value before unfolding.
|
|
236
|
+
#
|
|
237
|
+
# @raise [StepConfigurationError] if max_reattempts is invalid
|
|
238
|
+
def validate_max_reattempts_raw!
|
|
239
|
+
return if @max_reattempts_raw.nil?
|
|
240
|
+
|
|
241
|
+
on_exc = (@on_exception_raw == NOT_SET) ? :pause! : @on_exception_raw
|
|
242
|
+
|
|
243
|
+
# Can't pass max_reattempts when on_exception is already a policy, proc, or array
|
|
244
|
+
if on_exc.is_a?(GenevaDrive::ExceptionPolicy) || on_exc.is_a?(Proc) || on_exc.is_a?(Array)
|
|
245
|
+
raise GenevaDrive::StepConfigurationError,
|
|
246
|
+
"Step '#{@name}' has max_reattempts: but on_exception: is an ExceptionPolicy, Proc, or Array " \
|
|
247
|
+
"(set max_reattempts on the policy instead)"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# max_reattempts only makes sense with on_exception: :reattempt!
|
|
251
|
+
unless on_exc == :reattempt!
|
|
252
|
+
raise GenevaDrive::StepConfigurationError,
|
|
253
|
+
"Step '#{@name}' has max_reattempts: but on_exception: is not :reattempt!"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Must be a positive integer
|
|
257
|
+
unless @max_reattempts_raw.is_a?(Integer) && @max_reattempts_raw > 0
|
|
258
|
+
raise GenevaDrive::StepConfigurationError,
|
|
259
|
+
"Step '#{@name}' has invalid max_reattempts: must be a positive integer or nil"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Validates the terminal_action option.
|
|
264
|
+
#
|
|
265
|
+
# @raise [StepConfigurationError] if terminal_action is invalid
|
|
266
|
+
def validate_terminal_action_raw!
|
|
267
|
+
return if @terminal_action_raw.nil?
|
|
268
|
+
|
|
269
|
+
on_exc = (@on_exception_raw == NOT_SET) ? :pause! : @on_exception_raw
|
|
270
|
+
|
|
271
|
+
# Can't pass terminal_action when on_exception is already a policy, proc, or array
|
|
272
|
+
if on_exc.is_a?(GenevaDrive::ExceptionPolicy) || on_exc.is_a?(Proc) || on_exc.is_a?(Array)
|
|
273
|
+
raise GenevaDrive::StepConfigurationError,
|
|
274
|
+
"Step '#{@name}' has terminal_action: but on_exception: is an ExceptionPolicy, Proc, or Array " \
|
|
275
|
+
"(set terminal_action on the policy instead)"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# terminal_action only makes sense with on_exception: :reattempt!
|
|
279
|
+
unless on_exc == :reattempt!
|
|
280
|
+
raise GenevaDrive::StepConfigurationError,
|
|
281
|
+
"Step '#{@name}' has terminal_action: but on_exception: is not :reattempt!"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
unless GenevaDrive::ExceptionPolicy::VALID_TERMINAL_ACTIONS.include?(@terminal_action_raw)
|
|
285
|
+
raise GenevaDrive::StepConfigurationError,
|
|
286
|
+
"Step '#{@name}' has invalid terminal_action: must be one of #{GenevaDrive::ExceptionPolicy::VALID_TERMINAL_ACTIONS.join(", ")}"
|
|
287
|
+
end
|
|
148
288
|
end
|
|
149
289
|
|
|
150
290
|
# Validates the step positioning options.
|
|
@@ -174,26 +314,6 @@ class GenevaDrive::StepDefinition
|
|
|
174
314
|
"but was #{@skip_condition.class}"
|
|
175
315
|
end
|
|
176
316
|
|
|
177
|
-
# Validates the max_reattempts option.
|
|
178
|
-
#
|
|
179
|
-
# @raise [StepConfigurationError] if max_reattempts is invalid
|
|
180
|
-
def validate_max_reattempts!
|
|
181
|
-
# nil is always valid (disables the check)
|
|
182
|
-
return if @max_reattempts.nil?
|
|
183
|
-
|
|
184
|
-
# max_reattempts only makes sense with on_exception: :reattempt!
|
|
185
|
-
unless @on_exception == :reattempt!
|
|
186
|
-
raise GenevaDrive::StepConfigurationError,
|
|
187
|
-
"Step '#{@name}' has max_reattempts: but on_exception: is not :reattempt!"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Must be a positive integer
|
|
191
|
-
unless @max_reattempts.is_a?(Integer) && @max_reattempts > 0
|
|
192
|
-
raise GenevaDrive::StepConfigurationError,
|
|
193
|
-
"Step '#{@name}' has invalid max_reattempts: must be a positive integer or nil"
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
317
|
# Evaluates a condition in the workflow context.
|
|
198
318
|
#
|
|
199
319
|
# @param condition [Proc, Symbol, Boolean, nil] the condition to evaluate
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Provides safe read/write access to the freeform JSON metadata column
|
|
4
|
+
# on step executions. All "does the column exist?" logic lives here so
|
|
5
|
+
# the rest of the codebase can call read_metadata / write_metadata without
|
|
6
|
+
# guarding. When the metadata column has not been migrated yet, writes
|
|
7
|
+
# are silent no-ops and reads return nil.
|
|
8
|
+
#
|
|
9
|
+
# Once the migration becomes mandatory, delete this concern and replace with
|
|
10
|
+
# a plain `attribute :metadata, :json, default: -> { {} }` on StepExecution.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module GenevaDrive::StepExecution::MetadataAccessor
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
# Lazily checks whether the metadata column exists. Never hits the
|
|
18
|
+
# database at class definition time — only on the first runtime call.
|
|
19
|
+
#
|
|
20
|
+
# Serialization is handled manually in read/write_metadata rather
|
|
21
|
+
# than via `attribute :metadata, :json` to avoid timing issues —
|
|
22
|
+
# registering the JSON type after instances are already loaded
|
|
23
|
+
# causes those instances to persist Hashes via text-type `#to_s`
|
|
24
|
+
# instead of `#to_json`.
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def metadata_column?
|
|
28
|
+
if defined?(@_metadata_column)
|
|
29
|
+
return @_metadata_column
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@_metadata_column = table_exists? && column_names.include?("metadata")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Counts reattempted step executions in the given scope, excluding
|
|
36
|
+
# flow-control reattempts when the metadata column is available.
|
|
37
|
+
# Without the column, counts all reattempts (coarser but safe —
|
|
38
|
+
# errs on the side of pausing sooner).
|
|
39
|
+
#
|
|
40
|
+
# @param scope [ActiveRecord::Relation] a relation of step executions
|
|
41
|
+
# already filtered to outcome: "reattempted"
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def count_error_reattempts(scope)
|
|
44
|
+
unless metadata_column?
|
|
45
|
+
return scope.count
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
scope.pluck(:metadata).count do |raw|
|
|
49
|
+
meta = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
50
|
+
reason = meta.is_a?(Hash) ? meta["reattempt_reason"] : nil
|
|
51
|
+
reason != "flow_control"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clears the cached detection result. Call this in tests or after
|
|
56
|
+
# running migrations in-process so the next access re-checks.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def reset_metadata_column_cache!
|
|
60
|
+
remove_instance_variable(:@_metadata_column) if defined?(@_metadata_column)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Reads a single key from the metadata hash.
|
|
65
|
+
#
|
|
66
|
+
# @param key [String, Symbol] the metadata key
|
|
67
|
+
# @return [Object, nil] the value, or nil if the column is absent
|
|
68
|
+
def read_metadata(key)
|
|
69
|
+
return nil unless self.class.metadata_column?
|
|
70
|
+
parsed_metadata[key.to_s]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Merges a key/value pair into the metadata hash (in-memory only, does
|
|
74
|
+
# not persist — call save!/update! separately or include in a broader
|
|
75
|
+
# update).
|
|
76
|
+
#
|
|
77
|
+
# @param key [String, Symbol] the metadata key
|
|
78
|
+
# @param value [Object] the value to store
|
|
79
|
+
# @return [void]
|
|
80
|
+
def write_metadata(key, value)
|
|
81
|
+
return unless self.class.metadata_column?
|
|
82
|
+
merged = parsed_metadata.merge(key.to_s => value)
|
|
83
|
+
write_attribute(:metadata, merged.to_json)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the reattempt reason from metadata.
|
|
87
|
+
# Possible values: "flow_control", "exception_policy", "precondition", or nil.
|
|
88
|
+
#
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
def reattempt_reason
|
|
91
|
+
read_metadata("reattempt_reason")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the exception info hash from metadata, if recorded.
|
|
95
|
+
# Keys: "class", "message", "backtrace" (backtrace is an Array of strings).
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash, nil]
|
|
98
|
+
def exception_info
|
|
99
|
+
read_metadata("exception")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Returns metadata as a Hash regardless of whether the JSON attribute
|
|
105
|
+
# type has been applied to this instance. On SQLite/MySQL the raw value
|
|
106
|
+
# may still be a JSON string if the record was loaded before the
|
|
107
|
+
# attribute type was lazily registered.
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash]
|
|
110
|
+
def parsed_metadata
|
|
111
|
+
raw = metadata
|
|
112
|
+
case raw
|
|
113
|
+
when Hash then raw
|
|
114
|
+
when String then JSON.parse(raw)
|
|
115
|
+
when NilClass then {}
|
|
116
|
+
else {}
|
|
117
|
+
end
|
|
118
|
+
rescue JSON::ParserError
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
19
19
|
self.table_name = "geneva_drive_step_executions"
|
|
20
20
|
|
|
21
|
+
require_relative "step_execution/metadata_accessor"
|
|
22
|
+
include MetadataAccessor
|
|
23
|
+
|
|
21
24
|
# Step execution states as enum with string values
|
|
22
25
|
# Provides: scheduled?, in_progress?, etc. predicates
|
|
23
26
|
# Provides: scheduled, in_progress, etc. scopes
|
|
@@ -33,6 +36,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
33
36
|
# Outcome values for audit purposes
|
|
34
37
|
OUTCOMES = %w[
|
|
35
38
|
success
|
|
39
|
+
continued
|
|
36
40
|
reattempted
|
|
37
41
|
skipped
|
|
38
42
|
canceled
|
|
@@ -47,6 +51,18 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
47
51
|
foreign_key: :workflow_id,
|
|
48
52
|
inverse_of: :step_executions
|
|
49
53
|
|
|
54
|
+
# For chained resumable step executions
|
|
55
|
+
belongs_to :continues_from,
|
|
56
|
+
class_name: "GenevaDrive::StepExecution",
|
|
57
|
+
foreign_key: :continues_from_id,
|
|
58
|
+
optional: true,
|
|
59
|
+
inverse_of: :successor
|
|
60
|
+
|
|
61
|
+
has_one :successor,
|
|
62
|
+
class_name: "GenevaDrive::StepExecution",
|
|
63
|
+
foreign_key: :continues_from_id,
|
|
64
|
+
inverse_of: :continues_from
|
|
65
|
+
|
|
50
66
|
# Validations
|
|
51
67
|
validates :step_name, presence: true
|
|
52
68
|
validates :scheduled_for, presence: true
|
|
@@ -57,6 +73,63 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
57
73
|
scheduled.where("scheduled_for <= ?", Time.current)
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
class << self
|
|
77
|
+
# Lazily checks whether the resumable-step columns (cursor and
|
|
78
|
+
# continues_from_id) have been migrated. Never hits the database at
|
|
79
|
+
# class definition time — only on the first runtime call.
|
|
80
|
+
#
|
|
81
|
+
# Deployments usually ship the gem update before running migrations,
|
|
82
|
+
# so all cursor/chaining behavior must degrade safely when the columns
|
|
83
|
+
# are absent: reads return nil, writes are no-ops, and executing an
|
|
84
|
+
# actual resumable_step raises a clear configuration error instead.
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def resumable_columns?
|
|
88
|
+
if defined?(@_resumable_columns)
|
|
89
|
+
return @_resumable_columns
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@_resumable_columns = table_exists? &&
|
|
93
|
+
column_names.include?("cursor") &&
|
|
94
|
+
column_names.include?("continues_from_id")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Clears the cached detection result. Call this in tests or after
|
|
98
|
+
# running migrations in-process so the next access re-checks.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def reset_resumable_columns_cache!
|
|
102
|
+
remove_instance_variable(:@_resumable_columns) if defined?(@_resumable_columns)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Serializes a cursor value using ActiveJob serializers (handles Date,
|
|
106
|
+
# Time, and other types) and enforces GenevaDrive.max_cursor_size on
|
|
107
|
+
# the serialized JSON. The single serialization path for cursor writes.
|
|
108
|
+
#
|
|
109
|
+
# @param value [Object, nil] the cursor value
|
|
110
|
+
# @return [Object, nil] the serialized cursor
|
|
111
|
+
# @raise [CursorTooLargeError] if the serialized JSON exceeds the limit
|
|
112
|
+
def serialize_cursor(value)
|
|
113
|
+
return nil if value.nil?
|
|
114
|
+
|
|
115
|
+
serialized = ActiveJob::Arguments.serialize([value]).first
|
|
116
|
+
|
|
117
|
+
limit = GenevaDrive.max_cursor_size
|
|
118
|
+
if limit
|
|
119
|
+
bytesize = JSON.generate(serialized).bytesize
|
|
120
|
+
if bytesize > limit
|
|
121
|
+
raise GenevaDrive::CursorTooLargeError,
|
|
122
|
+
"Serialized cursor is #{bytesize} bytes, exceeding GenevaDrive.max_cursor_size " \
|
|
123
|
+
"(#{limit} bytes). The cursor is a position marker (an id, page number, or token), " \
|
|
124
|
+
"not a place to store the data being processed. Set GenevaDrive.max_cursor_size to " \
|
|
125
|
+
"nil to disable this check."
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
serialized
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
60
133
|
# Transitions the step execution to 'in_progress' state.
|
|
61
134
|
# Uses pessimistic locking to prevent double execution.
|
|
62
135
|
#
|
|
@@ -133,6 +206,37 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
133
206
|
end
|
|
134
207
|
end
|
|
135
208
|
|
|
209
|
+
# Returns the deserialized cursor value for resumable steps.
|
|
210
|
+
# Uses ActiveJob serializers to handle Date, Time, and other types.
|
|
211
|
+
# Returns nil when the cursor column has not been migrated yet.
|
|
212
|
+
#
|
|
213
|
+
# @return [Object, nil] the cursor value
|
|
214
|
+
def cursor_value
|
|
215
|
+
return nil unless self.class.resumable_columns?
|
|
216
|
+
return nil if cursor.blank?
|
|
217
|
+
ActiveJob::Arguments.deserialize([cursor]).first
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Sets the cursor value for resumable steps.
|
|
221
|
+
# Uses ActiveJob serializers to handle Date, Time, and other types.
|
|
222
|
+
# Silent no-op when the cursor column has not been migrated yet.
|
|
223
|
+
#
|
|
224
|
+
# @param value [Object] the cursor value to store
|
|
225
|
+
# @raise [CursorTooLargeError] if the serialized JSON exceeds GenevaDrive.max_cursor_size
|
|
226
|
+
# @return [void]
|
|
227
|
+
def cursor_value=(value)
|
|
228
|
+
return unless self.class.resumable_columns?
|
|
229
|
+
self.cursor = self.class.serialize_cursor(value)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Returns true if this execution is resuming from a prior execution.
|
|
233
|
+
# Always false when the continues_from_id column has not been migrated yet.
|
|
234
|
+
#
|
|
235
|
+
# @return [Boolean]
|
|
236
|
+
def resuming?
|
|
237
|
+
self.class.resumable_columns? && continues_from_id.present?
|
|
238
|
+
end
|
|
239
|
+
|
|
136
240
|
# Returns the step definition for this execution.
|
|
137
241
|
#
|
|
138
242
|
# @return [StepDefinition, nil] the step definition
|
|
@@ -142,9 +246,11 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
142
246
|
|
|
143
247
|
# Executes this step using the Executor.
|
|
144
248
|
#
|
|
249
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption conditions
|
|
250
|
+
# @param max_iterations [Integer, nil] per-execution override of the step's max_iterations
|
|
145
251
|
# @return [void]
|
|
146
|
-
def execute!
|
|
147
|
-
GenevaDrive::Executor.execute!(self)
|
|
252
|
+
def execute!(interruptible: true, max_iterations: nil)
|
|
253
|
+
GenevaDrive::Executor.execute!(self, interruptible: interruptible, max_iterations: max_iterations)
|
|
148
254
|
end
|
|
149
255
|
|
|
150
256
|
# Same as ActiveRecord::Base#logger but supplemented with tags for step and workflow
|
|
@@ -156,4 +262,20 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
156
262
|
workflow_tagged_logger.tagged("execution_id=#{id} step_name=#{step_name}")
|
|
157
263
|
end
|
|
158
264
|
end
|
|
265
|
+
|
|
266
|
+
# Temporarily overrides the step execution's logger for the duration of the block.
|
|
267
|
+
# The passed logger is used directly - it should already include step-specific tags.
|
|
268
|
+
# The original logger is restored after the block completes.
|
|
269
|
+
#
|
|
270
|
+
# @param logger [Logger] the logger to use (already fully tagged)
|
|
271
|
+
# @yield the block to execute with the injected logger
|
|
272
|
+
# @return [Object] the result of the block
|
|
273
|
+
# @api private
|
|
274
|
+
def with_logger(logger)
|
|
275
|
+
previous_logger = @logger
|
|
276
|
+
@logger = logger
|
|
277
|
+
yield
|
|
278
|
+
ensure
|
|
279
|
+
@logger = previous_logger
|
|
280
|
+
end
|
|
159
281
|
end
|