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
@@ -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 [Symbol] exception handler (:pause!, :cancel!, :reattempt!, :skip!)
28
- attr_reader :on_exception
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 how to handle exceptions
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
- @on_exception = options[:on_exception] || :pause!
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
- validate_exception_handler!
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
- # Returns the default max_reattempts value based on on_exception setting.
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 [Integer, nil] 100 if on_exception is :reattempt!, nil otherwise
114
- def default_max_reattempts
115
- (@on_exception == :reattempt!) ? 100 : nil
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 exception handler.
208
+ # Validates the raw on_exception value before unfolding.
141
209
  #
142
210
  # @raise [StepConfigurationError] if on_exception is invalid
143
- def validate_exception_handler!
144
- return if EXCEPTION_HANDLERS.include?(@on_exception)
211
+ def validate_on_exception_raw!
212
+ return if @on_exception_raw == NOT_SET
145
213
 
146
- raise GenevaDrive::StepConfigurationError,
147
- "Step '#{@name}' has invalid on_exception: must be one of #{EXCEPTION_HANDLERS.join(", ")}"
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