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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/MANUAL.md +1037 -355
  4. data/README.md +3 -1
  5. data/Rakefile +13 -2
  6. data/bin/md2html +96 -19
  7. data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
  8. data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
  9. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  10. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  11. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  12. data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
  13. data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
  14. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
  15. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
  16. data/lib/geneva_drive/combined_exception_policy.rb +106 -0
  17. data/lib/geneva_drive/exception_policy.rb +354 -0
  18. data/lib/geneva_drive/executor.rb +430 -148
  19. data/lib/geneva_drive/flow_control.rb +82 -11
  20. data/lib/geneva_drive/iterable_step.rb +199 -0
  21. data/lib/geneva_drive/job_options.rb +70 -0
  22. data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
  23. data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
  24. data/lib/geneva_drive/migration_helpers.rb +55 -20
  25. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  26. data/lib/geneva_drive/step_definition.rb +160 -40
  27. data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
  28. data/lib/geneva_drive/step_execution.rb +124 -2
  29. data/lib/geneva_drive/test_helpers.rb +123 -4
  30. data/lib/geneva_drive/version.rb +1 -1
  31. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  32. data/lib/geneva_drive/workflow.rb +485 -73
  33. data/lib/geneva_drive.rb +100 -2
  34. data/test/dsl/class_level_on_exception_test.rb +200 -0
  35. data/test/dsl/exception_policy_test.rb +331 -0
  36. data/test/dsl/step_definition_test.rb +126 -2
  37. data/test/dsl/step_level_exception_policy_test.rb +143 -0
  38. data/test/dummy/config/storage.yml +7 -0
  39. data/test/dummy_install/config/storage.yml +7 -0
  40. data/test/jobs/housekeeping_job_test.rb +451 -5
  41. data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
  42. data/test/jobs/perform_step_job_test.rb +25 -2
  43. data/test/migration_helpers_test.rb +137 -0
  44. data/test/step_execution/metadata_accessor_test.rb +147 -0
  45. data/test/test_helper.rb +37 -0
  46. data/test/test_helper_test.rb +281 -0
  47. data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
  48. data/test/workflow/composable_exception_policy_test.rb +231 -0
  49. data/test/workflow/cursor_size_limit_test.rb +75 -0
  50. data/test/workflow/executor_test.rb +100 -4
  51. data/test/workflow/external_cancel_test.rb +245 -0
  52. data/test/workflow/flow_control_test.rb +4 -3
  53. data/test/workflow/instrumentation_test.rb +28 -0
  54. data/test/workflow/max_reattempts_test.rb +51 -0
  55. data/test/workflow/pause_resume_test.rb +574 -0
  56. data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
  57. data/test/workflow/resumable_step_integration_test.rb +341 -0
  58. data/test/workflow/resumable_step_test.rb +615 -0
  59. data/test/workflow/resumable_without_migration_test.rb +103 -0
  60. data/test/workflow/resume_and_skip_test.rb +57 -41
  61. data/test/workflow/with_inline_enqueue_test.rb +137 -0
  62. data/test/workflow/workflow_test.rb +117 -2
  63. metadata +47 -61
  64. data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
  65. data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
  66. data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  67. data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  68. data/test/dummy/db/schema.rb +0 -72
  69. data/test/dummy/log/development.log +0 -112
  70. data/test/dummy/log/test.log +0 -4
  71. data/test/dummy/tmp/local_secret.txt +0 -1
  72. data/test/dummy_install/Rakefile +0 -6
  73. data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
  74. data/test/dummy_install/app/controllers/application_controller.rb +0 -4
  75. data/test/dummy_install/app/helpers/application_helper.rb +0 -2
  76. data/test/dummy_install/app/jobs/application_job.rb +0 -7
  77. data/test/dummy_install/app/models/application_record.rb +0 -3
  78. data/test/dummy_install/app/models/user.rb +0 -9
  79. data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
  80. data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
  81. data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
  82. data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
  83. data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
  84. data/test/dummy_install/bin/dev +0 -2
  85. data/test/dummy_install/bin/rails +0 -4
  86. data/test/dummy_install/bin/rake +0 -4
  87. data/test/dummy_install/bin/setup +0 -34
  88. data/test/dummy_install/config/application.rb +0 -29
  89. data/test/dummy_install/config/boot.rb +0 -5
  90. data/test/dummy_install/config/cable.yml +0 -10
  91. data/test/dummy_install/config/database.yml +0 -13
  92. data/test/dummy_install/config/environment.rb +0 -5
  93. data/test/dummy_install/config/environments/development.rb +0 -51
  94. data/test/dummy_install/config/environments/production.rb +0 -70
  95. data/test/dummy_install/config/environments/test.rb +0 -39
  96. data/test/dummy_install/config/initializers/assets.rb +0 -7
  97. data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
  98. data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
  99. data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
  100. data/test/dummy_install/config/initializers/inflections.rb +0 -16
  101. data/test/dummy_install/config/locales/en.yml +0 -31
  102. data/test/dummy_install/config/puma.rb +0 -38
  103. data/test/dummy_install/config/routes.rb +0 -3
  104. data/test/dummy_install/config.ru +0 -6
  105. data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
  106. data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
  107. data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
  108. data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  109. data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  110. data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
  111. data/test/dummy_install/db/schema.rb +0 -76
  112. data/test/dummy_install/log/test.log +0 -4031
  113. data/test/dummy_install/public/400.html +0 -114
  114. data/test/dummy_install/public/404.html +0 -114
  115. data/test/dummy_install/public/406-unsupported-browser.html +0 -114
  116. data/test/dummy_install/public/422.html +0 -114
  117. data/test/dummy_install/public/500.html +0 -114
  118. data/test/dummy_install/public/icon.png +0 -0
  119. data/test/dummy_install/public/icon.svg +0 -3
  120. data/test/dummy_install/tmp/local_secret.txt +0 -1
  121. 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. Useful for testing complete
24
- # workflow behavior.
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 steps to execute (safety limit)
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
- step_execution.execute!
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GenevaDrive
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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