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
@@ -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
- # The current step is marked as canceled and the workflow transitions to 'canceled' state.
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 [UncaughtThrowError] if called outside of step execution context
107
+ # @raise [InvalidStateError] if called on a non-ready/non-paused/non-performing workflow
101
108
  def cancel!
102
- logger.info("Flow control: cancel! called from step")
103
- throw :flow_control, GenevaDrive::FlowControlSignal.new(:cancel)
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
- def reattempt!(wait: nil)
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
- logger.info("Flow control: reattempt! called from step#{wait_msg}")
136
- throw :flow_control, GenevaDrive::FlowControlSignal.new(:reattempt, wait: wait)
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
- # Cancels any pending step execution and transitions workflow to paused.
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("Flow control: pause! called externally on step #{current_step_name.inspect}")
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