geneva_drive 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -22,6 +22,9 @@
22
22
  class GenevaDrive::Workflow < ActiveRecord::Base
23
23
  self.table_name = "geneva_drive_workflows"
24
24
 
25
+ require_relative "workflow/metadata_accessor"
26
+ include MetadataAccessor
27
+
25
28
  # Workflow states as enum with string values
26
29
  # Provides: ready?, performing?, etc. predicates
27
30
  # Provides: ready, performing, etc. scopes
@@ -40,6 +43,11 @@ class GenevaDrive::Workflow < ActiveRecord::Base
40
43
  foreign_key: :workflow_id,
41
44
  inverse_of: :workflow,
42
45
  dependent: :delete_all
46
+ has_many :signals,
47
+ class_name: "GenevaDrive::Signal",
48
+ foreign_key: :workflow_id,
49
+ inverse_of: :workflow,
50
+ dependent: :delete_all
43
51
 
44
52
  # Class-inheritable attributes for DSL
45
53
  class_attribute :_step_definitions, instance_writer: false, default: []
@@ -68,6 +76,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
68
76
  # @param name [String, Symbol, nil] the step name (auto-generated if nil)
69
77
  # @param options [Hash] step options
70
78
  # @option options [ActiveSupport::Duration, nil] :wait delay before execution
79
+ # @option options [Hash] :job_options options passed to Active Job's set method
71
80
  # @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
72
81
  # @option options [Symbol, GenevaDrive::ExceptionPolicy, Proc, Array<GenevaDrive::ExceptionPolicy>] :on_exception
73
82
  # exception handling policy. Accepts:
@@ -119,24 +128,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
119
128
  call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
120
129
  block_location = block&.source_location
121
130
 
122
- # Duplicate parent's array only if we haven't already (avoid mutating inherited definitions)
123
- if _step_definitions.equal?(superclass._step_definitions)
124
- self._step_definitions = _step_definitions.dup
125
- end
126
- # Invalidate cached step collection since we're adding a step
127
- @steps = nil
128
-
129
- step_name = (name || generate_step_name).to_s
130
-
131
- # Check for duplicate step names
132
- if _step_definitions.any? { |s| s.name == step_name }
133
- raise GenevaDrive::StepConfigurationError,
134
- "Step '#{step_name}' is already defined in #{self.name}"
135
- end
136
-
137
- # Validate positioning references exist
138
- validate_step_positioning_reference!(step_name, options[:before_step], :before_step)
139
- validate_step_positioning_reference!(step_name, options[:after_step], :after_step)
131
+ step_name = prepare_step_registration!(name, options)
140
132
 
141
133
  step_def = GenevaDrive::StepDefinition.new(
142
134
  name: step_name,
@@ -151,6 +143,69 @@ class GenevaDrive::Workflow < ActiveRecord::Base
151
143
  step_def
152
144
  end
153
145
 
146
+ # Defines a resumable step that can iterate over large collections.
147
+ # The block receives an IterableStep object for cursor-based iteration
148
+ # that survives job restarts.
149
+ #
150
+ # @param name [String, Symbol, nil] the step name (auto-generated if nil)
151
+ # @param options [Hash] step options
152
+ # @option options [Integer, nil] :max_iterations interrupt after N iterations
153
+ # @option options [ActiveSupport::Duration, nil] :max_runtime interrupt after duration
154
+ # @option options [ActiveSupport::Duration, nil] :wait delay before execution
155
+ # @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
156
+ # @option options [Symbol] :on_exception exception handler (:pause!, :cancel!, :reattempt!, :skip!)
157
+ # @option options [String, Symbol, nil] :before_step position before this step
158
+ # @option options [String, Symbol, nil] :after_step position after this step
159
+ # @yield [iter] the step implementation receiving an IterableStep object
160
+ # @yieldparam iter [GenevaDrive::IterableStep] cursor management object
161
+ # @return [void]
162
+ #
163
+ # @example Iterate over records with automatic checkpointing
164
+ # resumable_step :process_users do |iter|
165
+ # iter.iterate_over_records(hero.users) do |user|
166
+ # process(user)
167
+ # end
168
+ # end
169
+ #
170
+ # @example Manual cursor control
171
+ # resumable_step :sync_pages do |iter|
172
+ # page = iter.cursor || 1
173
+ # loop do
174
+ # response = Api.fetch(page: page)
175
+ # break if response.empty?
176
+ # response.each { |item| process(item) }
177
+ # page += 1
178
+ # iter.set!(page)
179
+ # end
180
+ # end
181
+ #
182
+ # @example With iteration limits
183
+ # resumable_step :bulk_import, max_iterations: 10_000 do |iter|
184
+ # iter.iterate_over_records(records) { |r| import(r) }
185
+ # end
186
+ def resumable_step(name = nil, **options, &block)
187
+ raise ArgumentError, "resumable_step requires a block" unless block_given?
188
+
189
+ # Capture source locations before any other operations
190
+ caller_loc = caller_locations(1, 1).first
191
+ call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
192
+ block_location = block.source_location
193
+
194
+ step_name = prepare_step_registration!(name, options)
195
+
196
+ step_def = GenevaDrive::ResumableStepDefinition.new(
197
+ name: step_name,
198
+ call_location: call_location,
199
+ block_location: block_location,
200
+ **options,
201
+ &block
202
+ )
203
+
204
+ _step_definitions << step_def
205
+
206
+ step_def
207
+ end
208
+
154
209
  # Defines a blanket cancellation condition for the workflow.
155
210
  # Checked before every step execution.
156
211
  #
@@ -180,8 +235,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
180
235
  # @example Set queue for workflow steps
181
236
  # set_step_job_options queue: :workflows, priority: 10
182
237
  def set_step_job_options(**options)
238
+ validated = GenevaDrive::JobOptions.validate!(options, context: "#{name || "Workflow"}.set_step_job_options")
183
239
  # Merge with parent's options
184
- self._step_job_options = _step_job_options.merge(options)
240
+ self._step_job_options = _step_job_options.merge(validated)
185
241
  end
186
242
 
187
243
  # Allows the workflow to continue even if the hero is deleted.
@@ -205,21 +261,25 @@ class GenevaDrive::Workflow < ActiveRecord::Base
205
261
  # Policies are checked when a step raises an exception and the step itself
206
262
  # does not have an explicit `on_exception:` override.
207
263
  #
208
- # @overload on_exception(action, *exception_matchers, wait: nil, max_reattempts: nil)
264
+ # @overload on_exception(action, *exception_matchers, wait: nil, max_reattempts: nil, report: :always)
209
265
  # Declarative mode — specify an action symbol.
210
266
  # @param action [Symbol] :pause!, :cancel!, :reattempt!, or :skip!
211
267
  # @param exception_matchers [Array<Class>] optional exception classes to match
212
268
  # @param wait [ActiveSupport::Duration, nil] wait before reattempt
213
269
  # @param max_reattempts [Integer, nil] max consecutive reattempts
270
+ # @param report [Symbol] when to report the exception to +Rails.error.report+
271
+ # (+:always+, +:never+, or +:terminal_only+). See {ExceptionPolicy} for details.
214
272
  #
215
- # @overload on_exception(*exception_matchers, action:, wait: nil, max_reattempts: nil)
273
+ # @overload on_exception(*exception_matchers, action:, wait: nil, max_reattempts: nil, report: :always)
216
274
  # Declarative mode with exception classes as leading args and action as keyword.
217
275
  # @param exception_matchers [Array<Class>] exception classes to match
218
276
  # @param action [Symbol] :pause!, :cancel!, :reattempt!, or :skip!
277
+ # @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
219
278
  #
220
- # @overload on_exception(*exception_matchers, &block)
279
+ # @overload on_exception(*exception_matchers, report: :always, &block)
221
280
  # Imperative mode — block receives exception, runs in workflow context.
222
281
  # @param exception_matchers [Array<Class>] optional exception classes to match
282
+ # @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
223
283
  # @yield [error] the exception that was raised
224
284
  #
225
285
  # @example Blanket default for all exceptions
@@ -233,7 +293,15 @@ class GenevaDrive::Workflow < ActiveRecord::Base
233
293
  # on_exception RateLimitError do |error|
234
294
  # reattempt! wait: error.retry_after.seconds
235
295
  # end
236
- def on_exception(*args, action: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, &block)
296
+ #
297
+ # @example Suppress reporting for expected rate limits
298
+ # on_exception RateLimitError, report: :never do |error|
299
+ # reattempt! wait: error.retry_after.seconds
300
+ # end
301
+ #
302
+ # @example Report only when reattempts are exhausted
303
+ # on_exception Timeout::Error, action: :reattempt!, max_reattempts: 5, report: :terminal_only
304
+ def on_exception(*args, action: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always, &block)
237
305
  # Separate exception classes from a leading action symbol
238
306
  if args.first.is_a?(Symbol)
239
307
  raise ArgumentError, "Cannot pass both a positional action and action: keyword" if action
@@ -258,10 +326,10 @@ class GenevaDrive::Workflow < ActiveRecord::Base
258
326
  end
259
327
 
260
328
  policy = if block
261
- GenevaDrive::ExceptionPolicy.new(&block)
329
+ GenevaDrive::ExceptionPolicy.new(report: report, &block)
262
330
  else
263
331
  raise ArgumentError, "Either an action or a block is required" unless action
264
- GenevaDrive::ExceptionPolicy.new(action, wait: wait, max_reattempts: max_reattempts, terminal_action: terminal_action)
332
+ GenevaDrive::ExceptionPolicy.new(action, wait: wait, max_reattempts: max_reattempts, terminal_action: terminal_action, report: report)
265
333
  end
266
334
 
267
335
  policy.exception_matchers.concat(exception_matchers)
@@ -314,6 +382,36 @@ class GenevaDrive::Workflow < ActiveRecord::Base
314
382
 
315
383
  private
316
384
 
385
+ # Shared bookkeeping for registering a step definition: copy-on-write of
386
+ # the inherited definitions array, cache invalidation, name generation,
387
+ # duplicate-name check, and positioning validation.
388
+ #
389
+ # @param name [String, Symbol, nil] the requested step name
390
+ # @param options [Hash] the step options (read for before_step/after_step)
391
+ # @return [String] the resolved step name
392
+ def prepare_step_registration!(name, options)
393
+ # Duplicate parent's array only if we haven't already (avoid mutating inherited definitions)
394
+ if _step_definitions.equal?(superclass._step_definitions)
395
+ self._step_definitions = _step_definitions.dup
396
+ end
397
+ # Invalidate cached step collection since we're adding a step
398
+ @steps = nil
399
+
400
+ step_name = (name || generate_step_name).to_s
401
+
402
+ # Check for duplicate step names
403
+ if _step_definitions.any? { |s| s.name == step_name }
404
+ raise GenevaDrive::StepConfigurationError,
405
+ "Step '#{step_name}' is already defined in #{self.name}"
406
+ end
407
+
408
+ # Validate positioning references exist
409
+ validate_step_positioning_reference!(step_name, options[:before_step], :before_step)
410
+ validate_step_positioning_reference!(step_name, options[:after_step], :after_step)
411
+
412
+ step_name
413
+ end
414
+
317
415
  # Validates that a positioning reference (before_step/after_step) exists.
318
416
  #
319
417
  # @param step_name [String] the step being defined
@@ -428,22 +526,249 @@ class GenevaDrive::Workflow < ActiveRecord::Base
428
526
  update!(state: "ready", transitioned_at: nil)
429
527
  end
430
528
 
431
- # Look for a scheduled execution to resume
529
+ # A scheduled execution preserved by pause! takes precedence - re-enqueue it
432
530
  scheduled_execution = current_execution
433
531
  if scheduled_execution
434
- enqueue_scheduled_execution(scheduled_execution)
435
- else
436
- # No scheduled execution exists - create one for the next step
437
- step_def = steps.named(next_step_name)
438
- create_step_execution(step_def, wait: nil)
532
+ # A parked execution is not re-enqueued: it is waiting for an event, not
533
+ # for a clock. Re-run the rendezvous instead, which delivers any signal
534
+ # that arrived while the workflow was paused.
535
+ return rendezvous_waiting_execution!(scheduled_execution) if scheduled_execution.waiting?
536
+
537
+ return enqueue_scheduled_execution(scheduled_execution)
538
+ end
539
+
540
+ # Resumable-step continuations need the cursor/continues_from_id columns
541
+ if GenevaDrive::StepExecution.resumable_columns?
542
+ # Check for a resumable step that was paused mid-iteration. Only the most
543
+ # recent marker row counts, and only while it has not been consumed yet
544
+ # (creating a successor consumes it - the successor's presence marks that).
545
+ paused_resumable_execution = step_executions
546
+ .where(outcome: "workflow_paused", state: "completed")
547
+ .order(created_at: :desc, id: :desc)
548
+ .first
549
+
550
+ if paused_resumable_execution && paused_resumable_execution.successor.nil?
551
+ logger.info("Resuming from resumable execution #{paused_resumable_execution.id}, cursor: #{paused_resumable_execution.cursor_value.inspect}")
552
+ return create_successor_execution!(paused_resumable_execution)
553
+ end
554
+
555
+ # A resumable step that failed (pausing the workflow) resumes from its
556
+ # persisted cursor instead of redoing the whole iteration.
557
+ failed_execution = step_executions
558
+ .where(step_name: next_step_name, state: "failed")
559
+ .order(created_at: :desc, id: :desc)
560
+ .first
561
+ if failed_execution && failed_execution.cursor.present? && failed_execution.successor.nil?
562
+ logger.info("Retrying failed resumable execution #{failed_execution.id} from cursor: #{failed_execution.cursor_value.inspect}")
563
+ return create_successor_execution!(failed_execution)
564
+ end
565
+ end
566
+
567
+ # No scheduled execution exists - create one for the next step
568
+ step_def = steps.named(next_step_name)
569
+ create_step_execution(step_def, wait: nil)
570
+ end
571
+
572
+ # Delivers an external event to this workflow.
573
+ #
574
+ # The signal row is persisted first and dispatched second, which is what
575
+ # makes arrival order irrelevant: a signal that lands before the waiting
576
+ # step's execution even exists simply sits in the table until that step's
577
+ # gate picks it up, and a signal that lands while a step is already parked
578
+ # wakes it immediately.
579
+ #
580
+ # Pass an +idempotency_key+ (a webhook's event id, typically) to make
581
+ # redelivery a no-op: the second call returns the row the first one wrote,
582
+ # flagged {GenevaDrive::Signal#duplicate_delivery?}, without dispatching.
583
+ #
584
+ # On a paused workflow the row is persisted but not dispatched - waking a
585
+ # step while the workflow is paused would only get its execution canceled.
586
+ # {#resume!} performs the rendezvous instead.
587
+ #
588
+ # @param signal_name [Symbol, String] the event name
589
+ # @param payload [Object] data describing the event, serialized through ActiveJob
590
+ # @param idempotency_key [String, nil] deduplication key, scoped to (workflow, name)
591
+ # @return [GenevaDrive::Signal] the persisted signal
592
+ # @raise [ArgumentError] if the signal name is blank
593
+ # @raise [WorkflowNotOngoing] if the workflow is finished or canceled and this is a new event
594
+ # @raise [SignalPayloadTooLargeError] if the payload exceeds GenevaDrive.max_signal_payload_size
595
+ #
596
+ # @example Deliver a webhook event
597
+ # workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
598
+ # workflow.signal!(:payment_confirmed,
599
+ # payload: {amount_cents: 12_500},
600
+ # idempotency_key: event["id"])
601
+ def signal!(signal_name, payload: {}, idempotency_key: nil, **unknown_options)
602
+ if unknown_options.any?
603
+ raise ArgumentError,
604
+ "Unknown options passed to signal!: #{unknown_options.keys.join(", ")}"
605
+ end
606
+
607
+ if signal_name.blank?
608
+ raise ArgumentError, "signal! requires a signal name"
609
+ end
610
+
611
+ name = signal_name.to_s
612
+ dedup_key = idempotency_key&.to_s
613
+
614
+ # Serialize (and bound) before anything is persisted.
615
+ serialized_payload = GenevaDrive::Signal.serialize_payload(payload)
616
+
617
+ signal = nil
618
+ duplicate = false
619
+
620
+ # One transaction covers the lot: the INSERT, the waiting-execution scan,
621
+ # and every attach, flip and counter bump. Taking the lock before writing
622
+ # anything is what makes delivery all-or-nothing - insert first and
623
+ # dispatch second, and a crash in between would leave a pending signal
624
+ # sitting next to a waiting execution with nothing left to introduce them.
625
+ with_lock do
626
+ # with_lock reloads; the terminal check belongs inside it
627
+ unless ongoing?
628
+ existing = dedup_key && signals.find_by(name: name, idempotency_key: dedup_key)
629
+ unless existing
630
+ raise GenevaDrive::WorkflowNotOngoing,
631
+ "Cannot deliver signal #{name.inspect} to a #{state} workflow"
632
+ end
633
+
634
+ logger.info("Signal #{name.inspect} redelivered to #{state} workflow, returning existing row")
635
+ signal = existing
636
+ duplicate = true
637
+ next
638
+ end
639
+
640
+ begin
641
+ # A savepoint so that hitting the dedup index does not poison the
642
+ # transaction - the caller's, if they had one open, or ours.
643
+ transaction(requires_new: true) do
644
+ record = signals.new(name: name, idempotency_key: dedup_key, state: "pending")
645
+ record[:payload] = serialized_payload
646
+ record.save!
647
+ signal = record
648
+ end
649
+ rescue ActiveRecord::RecordNotUnique
650
+ signal = signals.find_by!(name: name, idempotency_key: dedup_key)
651
+ logger.info("Signal #{name.inspect} is a duplicate delivery of signal #{signal.id}, not dispatching")
652
+ duplicate = true
653
+ next
654
+ end
655
+
656
+ logger.info("Received signal #{name.inspect} as signal #{signal.id}")
657
+
658
+ if paused?
659
+ logger.info("Workflow is paused, buffering signal #{signal.id} until resume!")
660
+ else
661
+ wake_executions_matching!(signal)
662
+ end
439
663
  end
664
+
665
+ signal.duplicate_delivery! if duplicate
666
+ signal
440
667
  end
441
668
 
442
669
  # Returns the current active step execution, if any.
670
+ # Includes scheduled, waiting and in_progress states.
443
671
  #
444
672
  # @return [StepExecution, nil] the current execution
445
673
  def current_execution
446
- step_executions.where(state: %w[scheduled in_progress]).first
674
+ step_executions.where(state: %w[scheduled waiting in_progress]).first
675
+ end
676
+
677
+ # Signals that may still be attached to an execution, oldest first, each
678
+ # pointing back at this workflow instance.
679
+ #
680
+ # Matcher blocks are instance_exec'd on `signal.workflow`, and `inverse_of:`
681
+ # only primes records loaded straight off the association - a scoped
682
+ # relation would hand each signal a freshly loaded workflow, which would
683
+ # then see none of the caller's unsaved state.
684
+ #
685
+ # @return [Array<GenevaDrive::Signal>]
686
+ # @api private
687
+ public def attachable_signals
688
+ signals.attachable.map { |signal| adopt_signal(signal) }
689
+ end
690
+
691
+ # Wakes every parked step execution whose matcher accepts the given signal.
692
+ #
693
+ # Runs under the workflow row lock - the same lock create_step_execution
694
+ # and the Executor take - so whichever of dispatch and the gate commits
695
+ # second sees the other's write and no signal can slip through the gap.
696
+ #
697
+ # @param signal [GenevaDrive::Signal] the signal to deliver
698
+ # @return [Array<StepExecution>] the executions that were woken
699
+ # @api private
700
+ public def dispatch_signal!(signal)
701
+ return [] unless GenevaDrive::StepExecution.signal_columns?
702
+
703
+ with_lock { wake_executions_matching!(signal) }
704
+ end
705
+
706
+ # Settles the signal attached to an execution we are deliberately moving
707
+ # past, wherever the skip came from: `skip_if` firing at wake, flow control
708
+ # inside the step, an exception policy, or an operator calling `skip!`.
709
+ #
710
+ # Consumption follows attachment. A skip in any flavor says "we are done
711
+ # with this step", so an event that was delivered to it is spent by that
712
+ # decision - leaving it claimed would hand a stale event to the next waiter
713
+ # for the same name, which is exactly what consume-once-per-event forbids.
714
+ # An execution that never attached has nothing to settle, so buffered
715
+ # signals a step never reached are left alone.
716
+ #
717
+ # Must be called with the workflow lock held, so the settle rides the same
718
+ # transaction as the skip itself.
719
+ #
720
+ # @param step_execution [StepExecution, nil] the execution being skipped past
721
+ # @return [GenevaDrive::Signal, nil] the settled signal, if there was one
722
+ # @api private
723
+ public def settle_signal_for_skipped!(step_execution)
724
+ return unless GenevaDrive::StepExecution.signal_columns?
725
+ return if step_execution.nil? || step_execution.signal_id.blank?
726
+
727
+ signal = step_execution.signal
728
+ return unless signal && signal.state == "claimed"
729
+
730
+ logger.info("Skipping past step #{step_execution.step_name}, settling attached signal #{signal.id} (#{signal.name})")
731
+ signal.record_consumption!(resolved_step_names: [step_execution.step_name])
732
+ signal
733
+ end
734
+
735
+ # The body of dispatch, for callers that already hold the workflow lock
736
+ # (`signal!` holds it across the whole delivery so the rows land atomically).
737
+ #
738
+ # @param signal [GenevaDrive::Signal] the signal to deliver
739
+ # @return [Array<StepExecution>] the executions that were woken
740
+ # @api private
741
+ public def wake_executions_matching!(signal)
742
+ return [] unless GenevaDrive::StepExecution.signal_columns?
743
+ return [] if paused? || !ongoing?
744
+
745
+ # Matcher blocks run on signal.workflow, so point the signal at this very
746
+ # instance instead of letting it load a second copy from the database.
747
+ adopt_signal(signal)
748
+ woken = []
749
+
750
+ step_executions.where(state: "waiting").order(created_at: :asc, id: :asc).each do |execution|
751
+ step_def = execution.step_definition
752
+ # A step whose definition is gone (class removed, step renamed) can
753
+ # never match - the matcher lives on the definition.
754
+ next unless step_def&.waits_for_signal?
755
+ next unless step_def.signal_matcher.matches?(signal)
756
+
757
+ signal.claim!
758
+ execution.update!(
759
+ signal_id: signal.id,
760
+ state: "scheduled",
761
+ scheduled_for: Time.current,
762
+ waiting_since: nil
763
+ )
764
+ woken << [execution, step_def]
765
+ end
766
+
767
+ woken.each do |execution, step_def|
768
+ enqueue_woken_execution(execution, step_def)
769
+ end
770
+
771
+ woken.map(&:first)
447
772
  end
448
773
 
449
774
  # Returns all step executions in chronological order.
@@ -525,6 +850,30 @@ class GenevaDrive::Workflow < ActiveRecord::Base
525
850
  yield
526
851
  end
527
852
 
853
+ # Returns per-instance step job options stored in metadata.
854
+ # These are merged with the class-level options when enqueuing step jobs,
855
+ # with instance options taking precedence.
856
+ #
857
+ # @return [Hash] symbolized job options (e.g. `{ queue: :high, priority: 5 }`)
858
+ def step_job_options
859
+ (read_metadata("step_job_options") || {}).symbolize_keys
860
+ end
861
+
862
+ # Sets per-instance step job options in metadata.
863
+ # Stored as part of the workflow's metadata column so they persist and
864
+ # survive across step boundaries.
865
+ #
866
+ # @param options [Hash, nil] job options to store
867
+ # @return [void]
868
+ def step_job_options=(options)
869
+ if options.present?
870
+ validated = GenevaDrive::JobOptions.validate!(options, context: "#{self.class.name}#step_job_options=")
871
+ write_metadata("step_job_options", validated.stringify_keys)
872
+ else
873
+ write_metadata("step_job_options", nil)
874
+ end
875
+ end
876
+
528
877
  # Transitions the workflow to a new state.
529
878
  #
530
879
  # @param new_state [String] the target state
@@ -542,6 +891,17 @@ class GenevaDrive::Workflow < ActiveRecord::Base
542
891
 
543
892
  private
544
893
 
894
+ # Returns class-level step job options merged with per-instance and step overrides.
895
+ # Instance options take precedence over class options; step options take final precedence.
896
+ #
897
+ # @param step_definition [StepDefinition, nil] step whose job options should override defaults
898
+ # @return [Hash] merged job options
899
+ def merged_step_job_options(step_definition)
900
+ self.class._step_job_options
901
+ .merge(step_job_options)
902
+ .merge(step_definition&.job_options || {})
903
+ end
904
+
545
905
  # Validates that no other ongoing workflow exists for the same (type, hero).
546
906
  # Mirrors the database unique index on (type, hero_type, hero_id) for ongoing workflows.
547
907
  #
@@ -578,6 +938,64 @@ class GenevaDrive::Workflow < ActiveRecord::Base
578
938
  create_step_execution(first_step, wait: first_step.wait)
579
939
  end
580
940
 
941
+ # Points a signal's workflow association at this instance, so matcher
942
+ # blocks - which are instance_exec'd on it - see the live object.
943
+ #
944
+ # @param signal [GenevaDrive::Signal]
945
+ # @return [GenevaDrive::Signal] the same signal
946
+ def adopt_signal(signal)
947
+ signal.association(:workflow).target = self
948
+ signal
949
+ end
950
+
951
+ # Enqueues the PerformStepJob for an execution that dispatch just flipped
952
+ # from waiting to scheduled. Uses the same enqueue discipline (merged job
953
+ # options, after-commit deferral, job_id writeback) as create_step_execution.
954
+ #
955
+ # @param step_execution [StepExecution] the woken execution
956
+ # @param step_definition [StepDefinition] its step definition
957
+ # @return [void]
958
+ def enqueue_woken_execution(step_execution, step_definition)
959
+ job_options = merged_step_job_options(step_definition)
960
+ execution_id = step_execution.id
961
+ workflow_logger = logger
962
+
963
+ run_after_commit do
964
+ job = GenevaDrive::PerformStepJob
965
+ .set(**job_options)
966
+ .perform_later(execution_id)
967
+
968
+ workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for woken step execution #{execution_id}")
969
+
970
+ GenevaDrive::StepExecution
971
+ .where(id: execution_id)
972
+ .update_all(job_id: job.job_id)
973
+ end
974
+ end
975
+
976
+ # Re-runs the rendezvous for a step execution that is parked waiting for a
977
+ # signal. Signals that arrived while the workflow was paused are delivered
978
+ # here, on resume.
979
+ #
980
+ # @param step_execution [StepExecution] the parked execution
981
+ # @return [StepExecution] the same execution (rescheduled if a signal matched)
982
+ def rendezvous_waiting_execution!(step_execution)
983
+ step_def = step_execution.step_definition
984
+ matcher = step_def&.signal_matcher
985
+
986
+ if matcher
987
+ candidate = attachable_signals.detect { |signal| matcher.matches?(signal) }
988
+ if candidate
989
+ logger.info("Resuming into a matching signal #{candidate.id} (#{candidate.name}) for step #{step_execution.step_name}")
990
+ dispatch_signal!(candidate)
991
+ return step_execution.reload
992
+ end
993
+ end
994
+
995
+ logger.info("Step #{step_execution.step_name} stays parked - no matching signal has arrived yet")
996
+ step_execution
997
+ end
998
+
581
999
  # Enqueues a job for an existing scheduled execution.
582
1000
  #
583
1001
  # If the execution is overdue (scheduled_for is in the past), runs immediately.
@@ -593,7 +1011,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
593
1011
  logger.info("Enqueuing job for step #{step_execution.step_name} to run #{wait_msg}")
594
1012
 
595
1013
  # Capture values for the callback
596
- job_options = self.class._step_job_options.dup
1014
+ job_options = merged_step_job_options(step_execution.step_definition)
597
1015
  job_options[:wait_until] = wait_until if wait_until
598
1016
  execution_id = step_execution.id
599
1017
  workflow_logger = logger
@@ -615,6 +1033,65 @@ class GenevaDrive::Workflow < ActiveRecord::Base
615
1033
  step_execution
616
1034
  end
617
1035
 
1036
+ # Creates a successor step execution that continues a resumable step from
1037
+ # the cursor persisted on the given execution. Used by the Executor when a
1038
+ # resumable step interrupts itself, by resume! for workflows paused
1039
+ # mid-iteration, and by housekeeping recovery of stuck resumable steps.
1040
+ #
1041
+ # The successor is enqueued with the same merged job options (class,
1042
+ # per-instance, per-step) as any other execution of the step.
1043
+ #
1044
+ # @param predecessor [StepExecution] the execution to continue from
1045
+ # @param wait [ActiveSupport::Duration, Numeric, nil] optional delay
1046
+ # @return [StepExecution] the new successor execution
1047
+ # @api private
1048
+ public def create_successor_execution!(predecessor, wait: nil)
1049
+ scheduled_for = wait ? wait.from_now : Time.current
1050
+
1051
+ with_lock do
1052
+ # Cancel any stray scheduled or parked executions - the successor is the
1053
+ # one execution that should run next (same as create_step_execution).
1054
+ cancel_stray_executions!
1055
+
1056
+ successor_attributes = {
1057
+ step_name: predecessor.step_name,
1058
+ state: "scheduled",
1059
+ scheduled_for: scheduled_for,
1060
+ continues_from_id: predecessor.id,
1061
+ cursor: predecessor.cursor
1062
+ }
1063
+ # Carry the attachment pin across the chain so successors never re-park
1064
+ # and received_signal stays stable for the whole iteration.
1065
+ if GenevaDrive::StepExecution.signal_columns?
1066
+ successor_attributes[:signal_id] = predecessor.signal_id
1067
+ end
1068
+
1069
+ successor = step_executions.create!(**successor_attributes)
1070
+
1071
+ # next_step_name points to the step that's scheduled to run next
1072
+ update!(next_step_name: predecessor.step_name)
1073
+
1074
+ job_options = merged_step_job_options(predecessor.step_definition)
1075
+ job_options[:wait_until] = scheduled_for if wait
1076
+ successor_id = successor.id
1077
+ workflow_logger = logger
1078
+
1079
+ run_after_commit do
1080
+ job = GenevaDrive::PerformStepJob
1081
+ .set(**job_options)
1082
+ .perform_later(successor_id)
1083
+
1084
+ workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for successor execution ##{successor_id}")
1085
+
1086
+ GenevaDrive::StepExecution
1087
+ .where(id: successor_id)
1088
+ .update_all(job_id: job.job_id)
1089
+ end
1090
+
1091
+ successor
1092
+ end
1093
+ end
1094
+
618
1095
  # Creates a step execution and enqueues the job after transaction commits.
619
1096
  # Any existing scheduled step executions are canceled first.
620
1097
  # In-progress step executions are left alone - they're being executed.
@@ -629,15 +1106,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
629
1106
  scheduled_for = wait ? wait.from_now : Time.current
630
1107
 
631
1108
  with_lock do
632
- # Cancel any scheduled step executions (not in_progress - those are being executed).
633
- # Safe to use update_all since we hold the workflow lock, blocking any executor
634
- # that would try to start these steps.
635
- canceled_count = step_executions.scheduled.update_all(
636
- state: "canceled",
637
- outcome: "canceled",
638
- canceled_at: Time.current
639
- )
640
- logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
1109
+ cancel_stray_executions!
641
1110
 
642
1111
  step_execution = step_executions.create!(
643
1112
  step_name: step_definition.name,
@@ -649,7 +1118,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
649
1118
  update!(next_step_name: step_definition.name)
650
1119
 
651
1120
  # Capture values for the after_commit callback
652
- job_options = self.class._step_job_options.dup
1121
+ job_options = merged_step_job_options(step_definition)
653
1122
  job_options[:wait_until] = scheduled_for if wait
654
1123
  execution_id = step_execution.id
655
1124
  workflow_logger = logger
@@ -681,6 +1150,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
681
1150
  end
682
1151
  end
683
1152
 
1153
+ # Cancels any scheduled or parked step executions so that the execution
1154
+ # about to be created is the only one that will run. In-progress executions
1155
+ # are left alone - they are being executed.
1156
+ #
1157
+ # Safe to use update_all because the caller holds the workflow lock, which
1158
+ # blocks any executor that would try to start these steps. Parked executions
1159
+ # are swept for the same reason: no legitimate path creates a new execution
1160
+ # past one that is waiting.
1161
+ #
1162
+ # @return [Integer] the number of executions canceled
1163
+ def cancel_stray_executions!
1164
+ canceled_count = step_executions.where(state: %w[scheduled waiting]).update_all(
1165
+ state: "canceled",
1166
+ outcome: "canceled",
1167
+ canceled_at: Time.current
1168
+ )
1169
+ logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
1170
+ canceled_count
1171
+ end
1172
+
684
1173
  # Finishes the workflow.
685
1174
  #
686
1175
  # @return [nil]
@@ -737,6 +1226,33 @@ class GenevaDrive::Workflow < ActiveRecord::Base
737
1226
  @tagged_logger = previous_tagged_logger
738
1227
  end
739
1228
 
1229
+ # The signal that woke the step currently executing, or nil when the step
1230
+ # does not declare +wait_for:+. Available inside step bodies only.
1231
+ #
1232
+ # @return [GenevaDrive::Signal, nil]
1233
+ #
1234
+ # @example Read the payload of the event that woke the step
1235
+ # step :capture, wait_for: :payment_confirmed do
1236
+ # hero.capture!(received_signal.payload[:amount_cents])
1237
+ # end
1238
+ public attr_reader :received_signal
1239
+
1240
+ # Temporarily makes a signal available to step code as +received_signal+
1241
+ # for the duration of the block. Injected by the Executor the same way the
1242
+ # tagged logger is.
1243
+ #
1244
+ # @param signal [GenevaDrive::Signal, nil] the attached signal
1245
+ # @yield the block to execute with the signal in scope
1246
+ # @return [Object] the result of the block
1247
+ # @api private
1248
+ public def with_received_signal(signal)
1249
+ previous_received_signal = @received_signal
1250
+ @received_signal = signal
1251
+ yield
1252
+ ensure
1253
+ @received_signal = previous_received_signal
1254
+ end
1255
+
740
1256
  # Returns the Logger properly tagged to this Workflow
741
1257
  #
742
1258
  # @return [Logger]