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
|
@@ -20,11 +20,11 @@ module GenevaDrive::TestHelpers
|
|
|
20
20
|
# Executes all steps of a workflow synchronously for testing.
|
|
21
21
|
#
|
|
22
22
|
# This method runs through all scheduled steps, executing each one
|
|
23
|
-
# immediately regardless of wait times.
|
|
24
|
-
#
|
|
23
|
+
# immediately regardless of wait times. Resumable steps are run to
|
|
24
|
+
# completion (interruption limits are disabled).
|
|
25
25
|
#
|
|
26
26
|
# @param workflow [GenevaDrive::Workflow] the workflow to run
|
|
27
|
-
# @param max_iterations [Integer] maximum
|
|
27
|
+
# @param max_iterations [Integer] maximum step executions (safety limit)
|
|
28
28
|
# @return [GenevaDrive::Workflow] the workflow after execution
|
|
29
29
|
# @raise [RuntimeError] if max_iterations is exceeded
|
|
30
30
|
#
|
|
@@ -46,7 +46,8 @@ module GenevaDrive::TestHelpers
|
|
|
46
46
|
step_execution = workflow.current_execution
|
|
47
47
|
break unless step_execution
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
# interruptible: false so resumable steps run to completion
|
|
50
|
+
step_execution.execute!(interruptible: false)
|
|
50
51
|
iterations += 1
|
|
51
52
|
|
|
52
53
|
if iterations >= max_iterations
|
|
@@ -170,4 +171,122 @@ module GenevaDrive::TestHelpers
|
|
|
170
171
|
assert_equal expected_state.to_s, workflow.state,
|
|
171
172
|
"Expected workflow to be #{expected_state}, but was #{workflow.state}"
|
|
172
173
|
end
|
|
174
|
+
|
|
175
|
+
# === Resumable Step Test Helpers ===
|
|
176
|
+
|
|
177
|
+
# Runs the current step to completion without advancing to the next step.
|
|
178
|
+
#
|
|
179
|
+
# For regular steps, this is equivalent to `perform_next_step`.
|
|
180
|
+
# For resumable steps, this disables interruption checks and runs until
|
|
181
|
+
# the step completes (or fails/cancels/skips).
|
|
182
|
+
#
|
|
183
|
+
# @param workflow [GenevaDrive::Workflow] the workflow containing the step
|
|
184
|
+
# @param max_executions [Integer] safety limit on job executions (default: 100)
|
|
185
|
+
# @return [GenevaDrive::StepExecution] the step execution
|
|
186
|
+
#
|
|
187
|
+
# @example Run current step to completion
|
|
188
|
+
# workflow = BulkEmailWorkflow.create!(hero: campaign)
|
|
189
|
+
# speedrun_current_step(workflow)
|
|
190
|
+
# assert_step_executed(workflow, :send_emails)
|
|
191
|
+
#
|
|
192
|
+
def speedrun_current_step(workflow, max_executions: 100)
|
|
193
|
+
workflow.reload
|
|
194
|
+
step_execution = workflow.current_execution
|
|
195
|
+
return nil unless step_execution
|
|
196
|
+
|
|
197
|
+
executions = 0
|
|
198
|
+
original_step_name = step_execution.step_name
|
|
199
|
+
|
|
200
|
+
loop do
|
|
201
|
+
step_execution.reload
|
|
202
|
+
|
|
203
|
+
# Follow the execution chain: a completed execution that spawned a
|
|
204
|
+
# successor means the step is still going (suspend! and skip_to! create
|
|
205
|
+
# successors even with interruption checks disabled).
|
|
206
|
+
if GenevaDrive::StepExecution.resumable_columns? &&
|
|
207
|
+
step_execution.completed? && (successor = step_execution.successor)
|
|
208
|
+
step_execution = successor
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
break if step_execution.completed? || step_execution.failed? || step_execution.canceled? || step_execution.skipped?
|
|
213
|
+
|
|
214
|
+
# interruptible: false so the step runs to completion
|
|
215
|
+
step_execution.execute!(interruptible: false)
|
|
216
|
+
executions += 1
|
|
217
|
+
|
|
218
|
+
if executions >= max_executions
|
|
219
|
+
raise "speedrun_current_step exceeded max_executions (#{max_executions}). " \
|
|
220
|
+
"Step '#{original_step_name}' may be in an infinite loop."
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
workflow.reload
|
|
225
|
+
step_execution.reload
|
|
226
|
+
step_execution
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Runs the current (resumable) step for a limited number of iterations,
|
|
230
|
+
# then lets it interrupt and create a successor execution.
|
|
231
|
+
#
|
|
232
|
+
# Useful for testing partial progress and cursor state of resumable steps.
|
|
233
|
+
#
|
|
234
|
+
# @param workflow [GenevaDrive::Workflow] the workflow containing the step
|
|
235
|
+
# @param count [Integer] the number of iterations to run before interrupting
|
|
236
|
+
# @return [GenevaDrive::StepExecution] the executed (now completed) step execution
|
|
237
|
+
#
|
|
238
|
+
# @example Run three iterations, then inspect the cursor
|
|
239
|
+
# run_iterations(workflow, count: 3)
|
|
240
|
+
# assert_cursor(workflow, 3)
|
|
241
|
+
#
|
|
242
|
+
def run_iterations(workflow, count:)
|
|
243
|
+
workflow.reload
|
|
244
|
+
step_execution = workflow.current_execution
|
|
245
|
+
raise "No current step execution to run" unless step_execution
|
|
246
|
+
|
|
247
|
+
step_execution.execute!(max_iterations: count)
|
|
248
|
+
|
|
249
|
+
workflow.reload
|
|
250
|
+
step_execution.reload
|
|
251
|
+
step_execution
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Asserts that a resumable step's cursor matches the expected value.
|
|
255
|
+
#
|
|
256
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
257
|
+
# @param expected_cursor [Object] the expected cursor value
|
|
258
|
+
# @return [void]
|
|
259
|
+
#
|
|
260
|
+
# @example Check cursor position
|
|
261
|
+
# assert_cursor(workflow, 42)
|
|
262
|
+
# assert_cursor(workflow, "page_token_abc")
|
|
263
|
+
#
|
|
264
|
+
def assert_cursor(workflow, expected_cursor)
|
|
265
|
+
workflow.reload
|
|
266
|
+
execution = workflow.current_execution
|
|
267
|
+
actual = execution&.cursor_value
|
|
268
|
+
|
|
269
|
+
assert_equal expected_cursor, actual,
|
|
270
|
+
"Expected cursor #{expected_cursor.inspect}, got #{actual.inspect}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Asserts that a step completed and has a scheduled successor execution.
|
|
274
|
+
# Used to verify that a resumable step was interrupted and will continue.
|
|
275
|
+
#
|
|
276
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
277
|
+
# @param step_name [String, Symbol] the step name to check
|
|
278
|
+
# @return [void]
|
|
279
|
+
#
|
|
280
|
+
# @example Check step has a scheduled successor
|
|
281
|
+
# assert_step_has_successor(workflow, :bulk_process)
|
|
282
|
+
#
|
|
283
|
+
def assert_step_has_successor(workflow, step_name)
|
|
284
|
+
workflow.reload
|
|
285
|
+
completed = workflow.step_executions.find_by(step_name: step_name.to_s, state: "completed")
|
|
286
|
+
assert completed, "Expected completed execution for step #{step_name}"
|
|
287
|
+
|
|
288
|
+
successor = completed.successor
|
|
289
|
+
assert successor, "Expected successor execution for step #{step_name}, but none found"
|
|
290
|
+
assert successor.scheduled?, "Expected successor to be scheduled, but was #{successor.state}"
|
|
291
|
+
end
|
|
173
292
|
end
|
data/lib/geneva_drive/version.rb
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Provides safe read/write access to the freeform JSON metadata column
|
|
4
|
+
# on workflows. 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 Workflow.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module GenevaDrive::Workflow::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
|
+
# Clears the cached detection result. Call this in tests or after
|
|
36
|
+
# running migrations in-process so the next access re-checks.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def reset_metadata_column_cache!
|
|
40
|
+
remove_instance_variable(:@_metadata_column) if defined?(@_metadata_column)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads a single key from the metadata hash.
|
|
45
|
+
#
|
|
46
|
+
# @param key [String, Symbol] the metadata key
|
|
47
|
+
# @return [Object, nil] the value, or nil if the column is absent
|
|
48
|
+
def read_metadata(key)
|
|
49
|
+
return nil unless self.class.metadata_column?
|
|
50
|
+
parsed_metadata[key.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Merges a key/value pair into the metadata hash (in-memory only, does
|
|
54
|
+
# not persist — call save!/update! separately or include in a broader
|
|
55
|
+
# update).
|
|
56
|
+
#
|
|
57
|
+
# @param key [String, Symbol] the metadata key
|
|
58
|
+
# @param value [Object] the value to store
|
|
59
|
+
# @return [void]
|
|
60
|
+
def write_metadata(key, value)
|
|
61
|
+
return unless self.class.metadata_column?
|
|
62
|
+
merged = parsed_metadata.merge(key.to_s => value)
|
|
63
|
+
write_attribute(:metadata, merged.to_json)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Returns metadata as a Hash regardless of whether the JSON attribute
|
|
69
|
+
# type has been applied to this instance. On SQLite/MySQL the raw value
|
|
70
|
+
# may still be a JSON string if the record was loaded before the
|
|
71
|
+
# attribute type was lazily registered.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def parsed_metadata
|
|
75
|
+
raw = metadata
|
|
76
|
+
case raw
|
|
77
|
+
when Hash then raw
|
|
78
|
+
when String then JSON.parse(raw)
|
|
79
|
+
when NilClass then {}
|
|
80
|
+
else {}
|
|
81
|
+
end
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
end
|