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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
#
|
|
6
6
|
# @api private
|
|
7
7
|
class GenevaDrive::FlowControlSignal
|
|
8
|
-
# @return [Symbol] the action to take (:cancel, :pause, :reattempt, :skip, :finished)
|
|
8
|
+
# @return [Symbol] the action to take (:cancel, :pause, :reattempt, :skip, :finished, :suspend)
|
|
9
9
|
attr_reader :action
|
|
10
10
|
|
|
11
11
|
# @return [Hash] additional options for the flow control action
|
|
@@ -33,6 +33,11 @@ class GenevaDrive::InvalidStateError < StandardError; end
|
|
|
33
33
|
# raise StepConfigurationError, "Step requires either a block or method name"
|
|
34
34
|
class GenevaDrive::StepConfigurationError < StandardError; end
|
|
35
35
|
|
|
36
|
+
# Raised when a resumable step cursor exceeds GenevaDrive.max_cursor_size
|
|
37
|
+
# once serialized to JSON. The cursor is a position marker, not a payload -
|
|
38
|
+
# it is rewritten on every checkpoint and copied to every successor execution.
|
|
39
|
+
class GenevaDrive::CursorTooLargeError < StandardError; end
|
|
40
|
+
|
|
36
41
|
# Base class for errors that occur during step execution.
|
|
37
42
|
# These errors are raised after recovery actions have been performed,
|
|
38
43
|
# so the workflow/step states are already updated when the exception propagates.
|
|
@@ -94,13 +99,19 @@ class GenevaDrive::PreconditionError < GenevaDrive::StepExecutionError; end
|
|
|
94
99
|
# workflow.pause! # Pauses workflow from outside a step
|
|
95
100
|
module GenevaDrive::FlowControl
|
|
96
101
|
# Cancels the workflow immediately.
|
|
97
|
-
#
|
|
102
|
+
#
|
|
103
|
+
# When called inside a step (workflow state is 'performing'): interrupts execution via throw/catch.
|
|
104
|
+
# When called outside a step (workflow state is 'ready' or 'paused'): directly cancels the workflow.
|
|
98
105
|
#
|
|
99
106
|
# @return [void]
|
|
100
|
-
# @raise [
|
|
107
|
+
# @raise [InvalidStateError] if called on a non-ready/non-paused/non-performing workflow
|
|
101
108
|
def cancel!
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
if state == "performing"
|
|
110
|
+
logger.info("Flow control: cancel! called from step")
|
|
111
|
+
throw :flow_control, GenevaDrive::FlowControlSignal.new(:cancel)
|
|
112
|
+
else
|
|
113
|
+
external_cancel!
|
|
114
|
+
end
|
|
104
115
|
end
|
|
105
116
|
|
|
106
117
|
# Pauses the workflow for manual intervention.
|
|
@@ -124,16 +135,24 @@ module GenevaDrive::FlowControl
|
|
|
124
135
|
# Reschedules the current step for another attempt.
|
|
125
136
|
# Useful for handling temporary failures or rate limiting.
|
|
126
137
|
#
|
|
138
|
+
# For resumable steps, by default continues from the current cursor position.
|
|
139
|
+
# Use `rewind: true` to restart the step from the beginning.
|
|
140
|
+
#
|
|
127
141
|
# @param wait [ActiveSupport::Duration, nil] optional delay before retry
|
|
142
|
+
# @param rewind [Boolean] if true, resets cursor to beginning (resumable steps only)
|
|
128
143
|
# @return [void]
|
|
129
144
|
# @raise [UncaughtThrowError] if called outside of step execution context
|
|
130
145
|
#
|
|
131
146
|
# @example Retry after rate limit
|
|
132
147
|
# reattempt!(wait: 5.minutes)
|
|
133
|
-
|
|
148
|
+
#
|
|
149
|
+
# @example Retry resumable step from the beginning
|
|
150
|
+
# reattempt!(rewind: true)
|
|
151
|
+
def reattempt!(wait: nil, rewind: false)
|
|
134
152
|
wait_msg = wait ? " with wait #{wait.inspect}" : ""
|
|
135
|
-
|
|
136
|
-
|
|
153
|
+
rewind_msg = rewind ? " (rewinding cursor)" : ""
|
|
154
|
+
logger.info("Flow control: reattempt! called from step#{wait_msg}#{rewind_msg}")
|
|
155
|
+
throw :flow_control, GenevaDrive::FlowControlSignal.new(:reattempt, wait: wait, rewind: rewind)
|
|
137
156
|
end
|
|
138
157
|
|
|
139
158
|
# Skips the current step and proceeds to the next one.
|
|
@@ -162,27 +181,79 @@ module GenevaDrive::FlowControl
|
|
|
162
181
|
throw :flow_control, GenevaDrive::FlowControlSignal.new(:finished)
|
|
163
182
|
end
|
|
164
183
|
|
|
184
|
+
# Suspends the current resumable step and re-enqueues for later continuation.
|
|
185
|
+
# The step will resume from its current cursor position.
|
|
186
|
+
#
|
|
187
|
+
# This is primarily for use within resumable steps when you need to explicitly
|
|
188
|
+
# yield control (e.g., to respect rate limits or system load).
|
|
189
|
+
#
|
|
190
|
+
# @param wait [ActiveSupport::Duration, nil] optional delay before re-enqueue
|
|
191
|
+
# @return [void]
|
|
192
|
+
# @raise [UncaughtThrowError] if called outside of step execution context
|
|
193
|
+
#
|
|
194
|
+
# @example Suspend for rate limiting
|
|
195
|
+
# suspend!(wait: 5.minutes)
|
|
196
|
+
#
|
|
197
|
+
# @example Suspend to yield control
|
|
198
|
+
# suspend!
|
|
199
|
+
def suspend!(wait: nil)
|
|
200
|
+
executing_step = current_step_name && steps.named(current_step_name)
|
|
201
|
+
unless executing_step&.resumable?
|
|
202
|
+
raise GenevaDrive::InvalidStateError,
|
|
203
|
+
"suspend! can only be called from inside a resumable_step (it continues from the persisted cursor)"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
wait_msg = wait ? " with wait #{wait.inspect}" : ""
|
|
207
|
+
logger.info("Flow control: suspend! called from step#{wait_msg}")
|
|
208
|
+
throw :flow_control, GenevaDrive::FlowControlSignal.new(:suspend, wait: wait)
|
|
209
|
+
end
|
|
210
|
+
|
|
165
211
|
private
|
|
166
212
|
|
|
167
213
|
# Pauses the workflow from outside a step execution.
|
|
168
|
-
#
|
|
214
|
+
# Leaves any scheduled step execution intact (does NOT cancel it).
|
|
215
|
+
#
|
|
216
|
+
# This allows seeing that a step was scheduled, became overdue during pause,
|
|
217
|
+
# and when it actually ran - providing better timeline visibility.
|
|
169
218
|
#
|
|
170
219
|
# @raise [InvalidStateError] if workflow is not in 'ready' state
|
|
171
220
|
# @return [void]
|
|
172
221
|
def external_pause!
|
|
173
222
|
raise GenevaDrive::InvalidStateError, "Cannot pause a #{state} workflow" unless state == "ready"
|
|
174
223
|
|
|
175
|
-
logger.info("
|
|
224
|
+
logger.info("Pausing workflow (preserving scheduled execution for step #{next_step_name.inspect})")
|
|
176
225
|
with_lock do
|
|
177
226
|
# with_lock reloads automatically; re-check state in case it changed
|
|
178
227
|
raise GenevaDrive::InvalidStateError, "Cannot pause a #{state} workflow" unless state == "ready"
|
|
179
228
|
|
|
180
|
-
current_execution&.mark_canceled!(outcome: "workflow_paused")
|
|
181
229
|
update!(state: "paused", transitioned_at: Time.current)
|
|
182
230
|
end
|
|
183
231
|
logger.info("Workflow paused")
|
|
184
232
|
end
|
|
185
233
|
|
|
234
|
+
# Cancels the workflow from outside a step execution.
|
|
235
|
+
# Cancels any scheduled step execution and transitions the workflow to 'canceled'.
|
|
236
|
+
#
|
|
237
|
+
# @raise [InvalidStateError] if workflow is not in 'ready' or 'paused' state
|
|
238
|
+
# @return [void]
|
|
239
|
+
def external_cancel!
|
|
240
|
+
unless %w[ready paused].include?(state)
|
|
241
|
+
raise GenevaDrive::InvalidStateError, "Cannot cancel a #{state} workflow"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
logger.info("Canceling workflow externally")
|
|
245
|
+
with_lock do
|
|
246
|
+
# with_lock reloads automatically; re-check state in case it changed
|
|
247
|
+
unless %w[ready paused].include?(state)
|
|
248
|
+
raise GenevaDrive::InvalidStateError, "Cannot cancel a #{state} workflow"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
current_execution&.mark_canceled!(outcome: "canceled")
|
|
252
|
+
update!(state: "canceled", transitioned_at: Time.current)
|
|
253
|
+
end
|
|
254
|
+
logger.info("Workflow canceled")
|
|
255
|
+
end
|
|
256
|
+
|
|
186
257
|
# Skips the current step from outside a step execution.
|
|
187
258
|
# Marks step as skipped and schedules next step (or finishes if last).
|
|
188
259
|
#
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Object passed to resumable step blocks for cursor-based iteration.
|
|
4
|
+
#
|
|
5
|
+
# Provides an API compatible with Rails 8.1's ActiveJob::Continuation::Step,
|
|
6
|
+
# plus GenevaDrive-specific extensions for iterating over records and batches.
|
|
7
|
+
#
|
|
8
|
+
# @example Manual cursor control
|
|
9
|
+
# resumable_step :process_records do |iter|
|
|
10
|
+
# hero.records.where("id > ?", iter.cursor || 0).find_each do |record|
|
|
11
|
+
# process(record)
|
|
12
|
+
# iter.set!(record.id)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Using iterate_over_records helper
|
|
17
|
+
# resumable_step :process_records do |iter|
|
|
18
|
+
# iter.iterate_over_records(hero.records) do |record|
|
|
19
|
+
# process(record)
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class GenevaDrive::IterableStep
|
|
24
|
+
attr_reader :name, :cursor
|
|
25
|
+
|
|
26
|
+
# Creates a new IterableStep for a resumable step execution.
|
|
27
|
+
#
|
|
28
|
+
# @param name [String, Symbol] the step name
|
|
29
|
+
# @param cursor [Object, nil] the current cursor value
|
|
30
|
+
# @param execution [GenevaDrive::StepExecution] the step execution record
|
|
31
|
+
# @param resumed [Boolean] whether this is a resumed execution
|
|
32
|
+
# @param interrupter [#should_interrupt?] object to check for interruption
|
|
33
|
+
def initialize(name, cursor, execution:, resumed:, interrupter:)
|
|
34
|
+
@name = name.to_sym
|
|
35
|
+
@cursor = cursor
|
|
36
|
+
@execution = execution
|
|
37
|
+
@resumed = resumed
|
|
38
|
+
@interrupter = interrupter
|
|
39
|
+
@advanced = false
|
|
40
|
+
@iterations = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the number of iterations completed during this execution
|
|
44
|
+
# (checkpoints via set!, advance! or checkpoint!).
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
attr_reader :iterations
|
|
48
|
+
|
|
49
|
+
# === Rails 8.1 ActiveJob::Continuation::Step compatible API ===
|
|
50
|
+
|
|
51
|
+
# Returns true if this execution is resuming from a prior suspension.
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def resumed?
|
|
55
|
+
@resumed
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns true if the cursor was changed during this execution.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def advanced?
|
|
62
|
+
@advanced
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Sets the cursor to a new value and checkpoints.
|
|
66
|
+
# The cursor can be any value that ActiveJob can serialize.
|
|
67
|
+
#
|
|
68
|
+
# @param value [Object] the new cursor value
|
|
69
|
+
# @return [void]
|
|
70
|
+
def set!(value)
|
|
71
|
+
@cursor = value
|
|
72
|
+
@advanced = true
|
|
73
|
+
checkpoint!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Increments an integer cursor by 1 and checkpoints.
|
|
77
|
+
# Only works with integer cursors.
|
|
78
|
+
#
|
|
79
|
+
# @raise [ArgumentError] if cursor is not an Integer
|
|
80
|
+
# @return [void]
|
|
81
|
+
def advance!
|
|
82
|
+
raise ArgumentError, "advance! requires integer cursor, got #{@cursor.inspect}" unless @cursor.is_a?(Integer)
|
|
83
|
+
set!(@cursor + 1)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Persists the current cursor, counts the iteration, and checks for
|
|
87
|
+
# interruption (max_iterations, max_runtime, shutdown, external pause/cancel).
|
|
88
|
+
# Called automatically by set! and advance!.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def checkpoint!
|
|
92
|
+
@iterations += 1
|
|
93
|
+
persist_cursor!
|
|
94
|
+
check_interruption!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns array representation [name, cursor] for compatibility.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array]
|
|
100
|
+
def to_a
|
|
101
|
+
[name.to_s, cursor]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns a human-readable description of current position.
|
|
105
|
+
#
|
|
106
|
+
# @return [String]
|
|
107
|
+
def description
|
|
108
|
+
"at '#{name}', cursor '#{cursor.inspect}'"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# === GenevaDrive Extensions ===
|
|
112
|
+
|
|
113
|
+
# Skips to a new cursor position and suspends execution.
|
|
114
|
+
# The job will be re-enqueued to continue from the new cursor.
|
|
115
|
+
#
|
|
116
|
+
# @param value [Object] the new cursor value
|
|
117
|
+
# @param wait [ActiveSupport::Duration, Numeric, nil] optional delay before re-enqueue
|
|
118
|
+
# @return [void] (never returns - throws :interrupt)
|
|
119
|
+
def skip_to!(value, wait: nil)
|
|
120
|
+
@cursor = value
|
|
121
|
+
@advanced = true
|
|
122
|
+
persist_cursor!
|
|
123
|
+
throw :interrupt, wait
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Iterates over an enumerable using index as cursor.
|
|
127
|
+
# Resumes from the stored cursor position on subsequent runs.
|
|
128
|
+
#
|
|
129
|
+
# @param enumerable [Enumerable] the collection to iterate over
|
|
130
|
+
# @yield [Object] each element in the collection
|
|
131
|
+
# @return [void]
|
|
132
|
+
def iterate_over(enumerable)
|
|
133
|
+
raise ArgumentError, "Must respond to #each" unless enumerable.respond_to?(:each)
|
|
134
|
+
|
|
135
|
+
start_index = @cursor || 0
|
|
136
|
+
enumerable.each_with_index do |element, index|
|
|
137
|
+
next if index < start_index
|
|
138
|
+
yield element
|
|
139
|
+
set!(index + 1)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Iterates over an ActiveRecord relation using a column as cursor.
|
|
144
|
+
# Uses find_each for memory-efficient batching.
|
|
145
|
+
#
|
|
146
|
+
# @param relation [ActiveRecord::Relation] the relation to iterate over
|
|
147
|
+
# @param cursor [Symbol] the column to use as cursor (default: :id)
|
|
148
|
+
# @yield [ActiveRecord::Base] each record in the relation
|
|
149
|
+
# @return [void]
|
|
150
|
+
def iterate_over_records(relation, cursor: :id)
|
|
151
|
+
filtered = if @cursor
|
|
152
|
+
relation.where(relation.arel_table[cursor].gt(@cursor)).order(cursor => :asc)
|
|
153
|
+
else
|
|
154
|
+
relation.order(cursor => :asc)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
filtered.find_each do |record|
|
|
158
|
+
yield record
|
|
159
|
+
set!(record.public_send(cursor))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Iterates over an ActiveRecord relation in subrelations (batches).
|
|
164
|
+
# Each subrelation is yielded as an ActiveRecord relation with offset applied,
|
|
165
|
+
# useful for bulk operations like updates or exports.
|
|
166
|
+
#
|
|
167
|
+
# @param relation [ActiveRecord::Relation] the relation to iterate over
|
|
168
|
+
# @param batch_size [Integer] the number of records per subrelation
|
|
169
|
+
# @param cursor [Symbol] the column to use as cursor (default: :id)
|
|
170
|
+
# @yield [ActiveRecord::Relation] each subrelation with offset applied
|
|
171
|
+
# @return [void]
|
|
172
|
+
def iterate_over_subrelations(relation, batch_size:, cursor: :id)
|
|
173
|
+
filtered = if @cursor
|
|
174
|
+
relation.where(relation.arel_table[cursor].gt(@cursor)).order(cursor => :asc)
|
|
175
|
+
else
|
|
176
|
+
relation.order(cursor => :asc)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
filtered.in_batches(of: batch_size) do |relation_with_offset|
|
|
180
|
+
yield relation_with_offset
|
|
181
|
+
last_cursor = relation_with_offset.maximum(cursor)
|
|
182
|
+
set!(last_cursor) if last_cursor
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def persist_cursor!
|
|
189
|
+
serialized = GenevaDrive::StepExecution.serialize_cursor(@cursor)
|
|
190
|
+
|
|
191
|
+
GenevaDrive::StepExecution
|
|
192
|
+
.where(id: @execution.id)
|
|
193
|
+
.update_all(cursor: serialized)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def check_interruption!
|
|
197
|
+
throw :interrupt if @interrupter&.should_interrupt?(iterations: @iterations)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validation helper for Active Job option hashes passed through the workflow DSL
|
|
4
|
+
# (`set_step_job_options`, `step(job_options: ...)`, and `workflow.step_job_options=`).
|
|
5
|
+
#
|
|
6
|
+
# Active Job itself silently ignores unknown keys in `set` — a typo like
|
|
7
|
+
# `piority:` would never raise, and the workflow would just run at the default
|
|
8
|
+
# priority. This helper enforces the known key set so misconfigurations surface
|
|
9
|
+
# at definition time, or at assignment time for per-instance overrides.
|
|
10
|
+
#
|
|
11
|
+
# The allowed keys mirror what `ActiveJob::Core#set` actually consumes.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module GenevaDrive::JobOptions
|
|
15
|
+
# Keys that Active Job's `set` recognizes. See `ActiveJob::Core#set` in
|
|
16
|
+
# activejob/lib/active_job/core.rb.
|
|
17
|
+
ALLOWED_KEYS = %i[wait wait_until queue priority].freeze
|
|
18
|
+
|
|
19
|
+
# Validates a job options hash and returns a symbolized copy.
|
|
20
|
+
#
|
|
21
|
+
# @param options [#to_h, Hash, nil] the value to validate — anything that
|
|
22
|
+
# coerces to a Hash via `#to_h` is accepted (Hash, keyword-args splat,
|
|
23
|
+
# ActiveSupport::HashWithIndifferentAccess, structs, etc.)
|
|
24
|
+
# @param context [String] human-readable identifier used in error messages
|
|
25
|
+
# (e.g. "Step 'foo'" or "MyWorkflow.step_job_options")
|
|
26
|
+
# @return [Hash{Symbol=>Object}] symbolized copy safe to pass to `.set`
|
|
27
|
+
# @raise [GenevaDrive::StepConfigurationError] if options can't be coerced to
|
|
28
|
+
# a Hash, contains unknown keys, or has values of the wrong type
|
|
29
|
+
def self.validate!(options, context:)
|
|
30
|
+
hash = begin
|
|
31
|
+
options.to_h
|
|
32
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
33
|
+
raise GenevaDrive::StepConfigurationError,
|
|
34
|
+
"#{context} has invalid job_options: cannot coerce #{options.class} to a Hash via #to_h"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
symbolized = hash.symbolize_keys
|
|
38
|
+
|
|
39
|
+
unknown = symbolized.keys - ALLOWED_KEYS
|
|
40
|
+
unless unknown.empty?
|
|
41
|
+
raise GenevaDrive::StepConfigurationError,
|
|
42
|
+
"#{context} has unknown job_options key(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
43
|
+
"Active Job only recognizes: #{ALLOWED_KEYS.map(&:inspect).join(", ")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
symbolized.each { |k, v| validate_value!(k, v, context) }
|
|
47
|
+
symbolized
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private
|
|
51
|
+
def self.validate_value!(key, value, context)
|
|
52
|
+
return if value.nil?
|
|
53
|
+
|
|
54
|
+
valid = case key
|
|
55
|
+
when :wait
|
|
56
|
+
value.respond_to?(:to_i) && value.to_i >= 0
|
|
57
|
+
when :wait_until
|
|
58
|
+
value.respond_to?(:to_time) || value.respond_to?(:to_f)
|
|
59
|
+
when :queue
|
|
60
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
61
|
+
when :priority
|
|
62
|
+
value.is_a?(Integer)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return if valid
|
|
66
|
+
|
|
67
|
+
raise GenevaDrive::StepConfigurationError,
|
|
68
|
+
"#{context} has invalid job_options[#{key.inspect}]: #{value.inspect} (#{value.class})"
|
|
69
|
+
end
|
|
70
|
+
end
|