geneva_drive 0.6.0 → 0.8.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/MANUAL.md +260 -1
  4. data/README.md +2 -0
  5. data/Rakefile +14 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
  9. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  10. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
  11. data/lib/geneva_drive/executor.rb +131 -10
  12. data/lib/geneva_drive/flow_control.rb +43 -3
  13. data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
  14. data/lib/geneva_drive/json_column.rb +72 -0
  15. data/lib/geneva_drive/migration_helpers.rb +27 -0
  16. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  17. data/lib/geneva_drive/signal.rb +245 -0
  18. data/lib/geneva_drive/signal_matcher.rb +60 -0
  19. data/lib/geneva_drive/step_collection.rb +31 -7
  20. data/lib/geneva_drive/step_definition.rb +91 -1
  21. data/lib/geneva_drive/step_execution.rb +39 -2
  22. data/lib/geneva_drive/test_helpers.rb +89 -2
  23. data/lib/geneva_drive/version.rb +1 -1
  24. data/lib/geneva_drive/workflow.rb +497 -24
  25. data/lib/geneva_drive.rb +24 -0
  26. data/test/dsl/signal_step_definition_test.rb +132 -0
  27. data/test/dummy/config/database.yml +40 -5
  28. data/test/dummy/config/initializers/geneva_drive.rb +46 -0
  29. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  30. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  31. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  32. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  33. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  34. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  35. data/test/dummy_install/db/schema.rb +75 -0
  36. data/test/dummy_install/log/test.log +1852 -0
  37. data/test/jobs/housekeeping_signals_test.rb +198 -0
  38. data/test/migration_helpers_test.rb +1 -1
  39. data/test/test_helper.rb +53 -13
  40. data/test/workflow/removed_step_test.rb +294 -0
  41. data/test/workflow/resume_at_test.rb +170 -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. metadata +26 -2
@@ -43,6 +43,11 @@ class GenevaDrive::Workflow < ActiveRecord::Base
43
43
  foreign_key: :workflow_id,
44
44
  inverse_of: :workflow,
45
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
46
51
 
47
52
  # Class-inheritable attributes for DSL
48
53
  class_attribute :_step_definitions, instance_writer: false, default: []
@@ -201,6 +206,92 @@ class GenevaDrive::Workflow < ActiveRecord::Base
201
206
  step_def
202
207
  end
203
208
 
209
+ # Marks a step as removed, leaving a gravestone in the position the step
210
+ # used to occupy.
211
+ #
212
+ # Deleting a `step` outright is unsafe while workflows are in flight.
213
+ # Executions scheduled under the old name find no definition when their
214
+ # job runs, and the workflow pauses with {StepNotDefinedError} - stuck
215
+ # until an operator intervenes. The gem cannot recover from this on its
216
+ # own: it knows the step is gone, but not whether the work it did still
217
+ # needs doing, and not which of the remaining steps would have run had it
218
+ # executed. Only the author of the removal knows that.
219
+ #
220
+ # `removed_step` is how that knowledge gets written down. Declaring it
221
+ # where the step used to be says "this step no longer needs to happen,
222
+ # carry on past it", and the gem can then act without guessing:
223
+ #
224
+ # - Executions already scheduled for the name resolve, are skipped, and
225
+ # the workflow proceeds to the next runnable step.
226
+ # - Scheduling never spools a new execution for the name again.
227
+ # - `before_step:` / `after_step:` references to it keep working.
228
+ #
229
+ # Ship the `removed_step`, let in-flight workflows drain past it, then
230
+ # delete the line once {.removed_steps_in_flight} comes back empty.
231
+ #
232
+ # A removed step takes a name and nothing else. There is nothing to
233
+ # configure about a step that does not run.
234
+ #
235
+ # @param name [String, Symbol] the name of the step that was removed
236
+ # @return [RemovedStepDefinition] the gravestone
237
+ #
238
+ # @example Taking a step out of a live workflow
239
+ # class PaymentWorkflow < GenevaDrive::Workflow
240
+ # step :authorize do
241
+ # # ...
242
+ # end
243
+ #
244
+ # removed_step :capture_payment
245
+ #
246
+ # step :send_receipt do
247
+ # # ...
248
+ # end
249
+ # end
250
+ def removed_step(name)
251
+ raise ArgumentError, "removed_step requires a step name" if name.nil?
252
+
253
+ caller_loc = caller_locations(1, 1).first
254
+ call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
255
+
256
+ step_name = prepare_step_registration!(name, {})
257
+
258
+ step_def = GenevaDrive::RemovedStepDefinition.new(
259
+ name: step_name,
260
+ call_location: call_location
261
+ )
262
+
263
+ _step_definitions << step_def
264
+
265
+ step_def
266
+ end
267
+
268
+ # Returns the names of this workflow's removed steps that step execution
269
+ # rows still reference, so a `removed_step` gravestone is only deleted
270
+ # once nothing can land on it any more.
271
+ #
272
+ # Each name maps to the number of rows still referencing it. An empty
273
+ # hash means every in-flight workflow has drained past the removal and
274
+ # the `removed_step` lines can go.
275
+ #
276
+ # Rows belonging to finished and canceled workflows are ignored - they
277
+ # will never execute again.
278
+ #
279
+ # @return [Hash{String => Integer}] removed step names to referencing row counts
280
+ #
281
+ # @example
282
+ # PaymentWorkflow.removed_steps_in_flight
283
+ # # => {"capture_payment" => 3}
284
+ def removed_steps_in_flight
285
+ removed_names = steps.removed.map(&:name)
286
+ return {} if removed_names.empty?
287
+
288
+ GenevaDrive::StepExecution
289
+ .where(step_name: removed_names)
290
+ .where(workflow_id: where.not(state: %w[finished canceled]).select(:id))
291
+ .group(:step_name)
292
+ .count
293
+ end
294
+
204
295
  # Defines a blanket cancellation condition for the workflow.
205
296
  # Checked before every step execution.
206
297
  #
@@ -523,7 +614,14 @@ class GenevaDrive::Workflow < ActiveRecord::Base
523
614
 
524
615
  # A scheduled execution preserved by pause! takes precedence - re-enqueue it
525
616
  scheduled_execution = current_execution
526
- return enqueue_scheduled_execution(scheduled_execution) if scheduled_execution
617
+ if scheduled_execution
618
+ # A parked execution is not re-enqueued: it is waiting for an event, not
619
+ # for a clock. Re-run the rendezvous instead, which delivers any signal
620
+ # that arrived while the workflow was paused.
621
+ return rendezvous_waiting_execution!(scheduled_execution) if scheduled_execution.waiting?
622
+
623
+ return enqueue_scheduled_execution(scheduled_execution)
624
+ end
527
625
 
528
626
  # Resumable-step continuations need the cursor/continues_from_id columns
529
627
  if GenevaDrive::StepExecution.resumable_columns?
@@ -554,15 +652,276 @@ class GenevaDrive::Workflow < ActiveRecord::Base
554
652
 
555
653
  # No scheduled execution exists - create one for the next step
556
654
  step_def = steps.named(next_step_name)
655
+
656
+ # The step this workflow is pointed at can be gone from the code - that is
657
+ # what paused it in the first place if it paused with StepNotDefinedError.
658
+ # Say so plainly instead of dying on nil deeper in create_step_execution,
659
+ # and name the way out.
660
+ unless step_def
661
+ raise GenevaDrive::StepNotDefinedError.new(
662
+ "Cannot resume #{self.class.name} ##{id}: step '#{next_step_name}' is no longer defined in " \
663
+ "#{self.class.name}. Pick the step to continue from with resume_at!(:step_name) - the steps " \
664
+ "still defined are #{steps.runnable.map(&:name).join(", ")} - or cancel! the workflow. To stop " \
665
+ "future removals from stranding workflows this way, leave a removed_step :#{next_step_name} " \
666
+ "gravestone in place of the deleted step.",
667
+ step_execution: nil,
668
+ workflow: self
669
+ )
670
+ end
671
+
557
672
  create_step_execution(step_def, wait: nil)
558
673
  end
559
674
 
675
+ # Resumes a paused workflow at a specific step, instead of at whatever step
676
+ # it was pointed at when it paused.
677
+ #
678
+ # This is the operator's escape hatch for a workflow stranded on a step that
679
+ # no longer exists - the step was deleted without leaving a
680
+ # {GenevaDrive::Workflow.removed_step} gravestone, so the workflow paused
681
+ # with {StepNotDefinedError} and plain {#resume!} has nowhere to go.
682
+ #
683
+ # Where to land is deliberately a human decision. A deleted step's position
684
+ # in the sequence says nothing about whether the steps that follow it were
685
+ # ever meant to run: a `capture_payment` that normally ends the workflow by
686
+ # calling `finished!` sits directly before `refund`, and nothing in the
687
+ # database distinguishes "carry on" from "stop here". Only the person who
688
+ # removed the step knows which, so this method asks them.
689
+ #
690
+ # Any stray scheduled execution is canceled first, so the workflow continues
691
+ # from the named step and nowhere else.
692
+ #
693
+ # @param step_name [String, Symbol] the step to continue from
694
+ # @return [StepExecution] the created step execution
695
+ # @raise [InvalidStateError] if the workflow is not paused
696
+ # @raise [StepNotDefinedError] if the named step is not defined, or is a removed_step
697
+ #
698
+ # @example Continue a stranded workflow past a deleted step
699
+ # workflow = GenevaDrive::Workflow.find(id)
700
+ # workflow.resume_at!(:send_receipt)
701
+ def resume_at!(step_name)
702
+ raise GenevaDrive::InvalidStateError, "Cannot resume a #{state} workflow" unless state == "paused"
703
+
704
+ step_def = steps.named(step_name.to_s)
705
+
706
+ if step_def.nil? || step_def.removed?
707
+ reason = step_def ? "a removed_step" : "not defined"
708
+ raise GenevaDrive::StepNotDefinedError.new(
709
+ "Cannot resume #{self.class.name} ##{id} at '#{step_name}': that step is #{reason} in " \
710
+ "#{self.class.name}. The steps it can be resumed at are #{steps.runnable.map(&:name).join(", ")}.",
711
+ step_execution: nil,
712
+ workflow: self
713
+ )
714
+ end
715
+
716
+ logger.info("Resuming paused workflow at step #{step_def.name.inspect} (was pointed at #{next_step_name.inspect})")
717
+
718
+ with_lock do
719
+ update!(state: "ready", current_step_name: nil, transitioned_at: nil)
720
+ end
721
+
722
+ create_step_execution(step_def, wait: nil)
723
+ end
724
+
725
+ # Delivers an external event to this workflow.
726
+ #
727
+ # The signal row is persisted first and dispatched second, which is what
728
+ # makes arrival order irrelevant: a signal that lands before the waiting
729
+ # step's execution even exists simply sits in the table until that step's
730
+ # gate picks it up, and a signal that lands while a step is already parked
731
+ # wakes it immediately.
732
+ #
733
+ # Pass an +idempotency_key+ (a webhook's event id, typically) to make
734
+ # redelivery a no-op: the second call returns the row the first one wrote,
735
+ # flagged {GenevaDrive::Signal#duplicate_delivery?}, without dispatching.
736
+ #
737
+ # On a paused workflow the row is persisted but not dispatched - waking a
738
+ # step while the workflow is paused would only get its execution canceled.
739
+ # {#resume!} performs the rendezvous instead.
740
+ #
741
+ # @param signal_name [Symbol, String] the event name
742
+ # @param payload [Object] data describing the event, serialized through ActiveJob
743
+ # @param idempotency_key [String, nil] deduplication key, scoped to (workflow, name)
744
+ # @return [GenevaDrive::Signal] the persisted signal
745
+ # @raise [ArgumentError] if the signal name is blank
746
+ # @raise [WorkflowNotOngoing] if the workflow is finished or canceled and this is a new event
747
+ # @raise [SignalPayloadTooLargeError] if the payload exceeds GenevaDrive.max_signal_payload_size
748
+ #
749
+ # @example Deliver a webhook event
750
+ # workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
751
+ # workflow.signal!(:payment_confirmed,
752
+ # payload: {amount_cents: 12_500},
753
+ # idempotency_key: event["id"])
754
+ def signal!(signal_name, payload: {}, idempotency_key: nil, **unknown_options)
755
+ if unknown_options.any?
756
+ raise ArgumentError,
757
+ "Unknown options passed to signal!: #{unknown_options.keys.join(", ")}"
758
+ end
759
+
760
+ if signal_name.blank?
761
+ raise ArgumentError, "signal! requires a signal name"
762
+ end
763
+
764
+ name = signal_name.to_s
765
+ dedup_key = idempotency_key&.to_s
766
+
767
+ # Serialize (and bound) before anything is persisted.
768
+ serialized_payload = GenevaDrive::Signal.serialize_payload(payload)
769
+
770
+ signal = nil
771
+ duplicate = false
772
+
773
+ # One transaction covers the lot: the INSERT, the waiting-execution scan,
774
+ # and every attach, flip and counter bump. Taking the lock before writing
775
+ # anything is what makes delivery all-or-nothing - insert first and
776
+ # dispatch second, and a crash in between would leave a pending signal
777
+ # sitting next to a waiting execution with nothing left to introduce them.
778
+ with_lock do
779
+ # with_lock reloads; the terminal check belongs inside it
780
+ unless ongoing?
781
+ existing = dedup_key && signals.find_by(name: name, idempotency_key: dedup_key)
782
+ unless existing
783
+ raise GenevaDrive::WorkflowNotOngoing,
784
+ "Cannot deliver signal #{name.inspect} to a #{state} workflow"
785
+ end
786
+
787
+ logger.info("Signal #{name.inspect} redelivered to #{state} workflow, returning existing row")
788
+ signal = existing
789
+ duplicate = true
790
+ next
791
+ end
792
+
793
+ begin
794
+ # A savepoint so that hitting the dedup index does not poison the
795
+ # transaction - the caller's, if they had one open, or ours.
796
+ transaction(requires_new: true) do
797
+ record = signals.new(name: name, idempotency_key: dedup_key, state: "pending")
798
+ record[:payload] = serialized_payload
799
+ record.save!
800
+ signal = record
801
+ end
802
+ rescue ActiveRecord::RecordNotUnique
803
+ signal = signals.find_by!(name: name, idempotency_key: dedup_key)
804
+ logger.info("Signal #{name.inspect} is a duplicate delivery of signal #{signal.id}, not dispatching")
805
+ duplicate = true
806
+ next
807
+ end
808
+
809
+ logger.info("Received signal #{name.inspect} as signal #{signal.id}")
810
+
811
+ if paused?
812
+ logger.info("Workflow is paused, buffering signal #{signal.id} until resume!")
813
+ else
814
+ wake_executions_matching!(signal)
815
+ end
816
+ end
817
+
818
+ signal.duplicate_delivery! if duplicate
819
+ signal
820
+ end
821
+
560
822
  # Returns the current active step execution, if any.
561
- # Includes scheduled and in_progress states.
823
+ # Includes scheduled, waiting and in_progress states.
562
824
  #
563
825
  # @return [StepExecution, nil] the current execution
564
826
  def current_execution
565
- step_executions.where(state: %w[scheduled in_progress]).first
827
+ step_executions.where(state: %w[scheduled waiting in_progress]).first
828
+ end
829
+
830
+ # Signals that may still be attached to an execution, oldest first, each
831
+ # pointing back at this workflow instance.
832
+ #
833
+ # Matcher blocks are instance_exec'd on `signal.workflow`, and `inverse_of:`
834
+ # only primes records loaded straight off the association - a scoped
835
+ # relation would hand each signal a freshly loaded workflow, which would
836
+ # then see none of the caller's unsaved state.
837
+ #
838
+ # @return [Array<GenevaDrive::Signal>]
839
+ # @api private
840
+ public def attachable_signals
841
+ signals.attachable.map { |signal| adopt_signal(signal) }
842
+ end
843
+
844
+ # Wakes every parked step execution whose matcher accepts the given signal.
845
+ #
846
+ # Runs under the workflow row lock - the same lock create_step_execution
847
+ # and the Executor take - so whichever of dispatch and the gate commits
848
+ # second sees the other's write and no signal can slip through the gap.
849
+ #
850
+ # @param signal [GenevaDrive::Signal] the signal to deliver
851
+ # @return [Array<StepExecution>] the executions that were woken
852
+ # @api private
853
+ public def dispatch_signal!(signal)
854
+ return [] unless GenevaDrive::StepExecution.signal_columns?
855
+
856
+ with_lock { wake_executions_matching!(signal) }
857
+ end
858
+
859
+ # Settles the signal attached to an execution we are deliberately moving
860
+ # past, wherever the skip came from: `skip_if` firing at wake, flow control
861
+ # inside the step, an exception policy, or an operator calling `skip!`.
862
+ #
863
+ # Consumption follows attachment. A skip in any flavor says "we are done
864
+ # with this step", so an event that was delivered to it is spent by that
865
+ # decision - leaving it claimed would hand a stale event to the next waiter
866
+ # for the same name, which is exactly what consume-once-per-event forbids.
867
+ # An execution that never attached has nothing to settle, so buffered
868
+ # signals a step never reached are left alone.
869
+ #
870
+ # Must be called with the workflow lock held, so the settle rides the same
871
+ # transaction as the skip itself.
872
+ #
873
+ # @param step_execution [StepExecution, nil] the execution being skipped past
874
+ # @return [GenevaDrive::Signal, nil] the settled signal, if there was one
875
+ # @api private
876
+ public def settle_signal_for_skipped!(step_execution)
877
+ return unless GenevaDrive::StepExecution.signal_columns?
878
+ return if step_execution.nil? || step_execution.signal_id.blank?
879
+
880
+ signal = step_execution.signal
881
+ return unless signal && signal.state == "claimed"
882
+
883
+ logger.info("Skipping past step #{step_execution.step_name}, settling attached signal #{signal.id} (#{signal.name})")
884
+ signal.record_consumption!(resolved_step_names: [step_execution.step_name])
885
+ signal
886
+ end
887
+
888
+ # The body of dispatch, for callers that already hold the workflow lock
889
+ # (`signal!` holds it across the whole delivery so the rows land atomically).
890
+ #
891
+ # @param signal [GenevaDrive::Signal] the signal to deliver
892
+ # @return [Array<StepExecution>] the executions that were woken
893
+ # @api private
894
+ public def wake_executions_matching!(signal)
895
+ return [] unless GenevaDrive::StepExecution.signal_columns?
896
+ return [] if paused? || !ongoing?
897
+
898
+ # Matcher blocks run on signal.workflow, so point the signal at this very
899
+ # instance instead of letting it load a second copy from the database.
900
+ adopt_signal(signal)
901
+ woken = []
902
+
903
+ step_executions.where(state: "waiting").order(created_at: :asc, id: :asc).each do |execution|
904
+ step_def = execution.step_definition
905
+ # A step whose definition is gone (class removed, step renamed) can
906
+ # never match - the matcher lives on the definition.
907
+ next unless step_def&.waits_for_signal?
908
+ next unless step_def.signal_matcher.matches?(signal)
909
+
910
+ signal.claim!
911
+ execution.update!(
912
+ signal_id: signal.id,
913
+ state: "scheduled",
914
+ scheduled_for: Time.current,
915
+ waiting_since: nil
916
+ )
917
+ woken << [execution, step_def]
918
+ end
919
+
920
+ woken.each do |execution, step_def|
921
+ enqueue_woken_execution(execution, step_def)
922
+ end
923
+
924
+ woken.map(&:first)
566
925
  end
567
926
 
568
927
  # Returns all step executions in chronological order.
@@ -714,7 +1073,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
714
1073
  #
715
1074
  # @return [void]
716
1075
  def log_workflow_created
717
- step_count = self.class.step_definitions.size
1076
+ step_count = steps.runnable.size
718
1077
  logger.info("Created workflow with #{step_count} step(s) defined")
719
1078
  end
720
1079
 
@@ -722,7 +1081,10 @@ class GenevaDrive::Workflow < ActiveRecord::Base
722
1081
  #
723
1082
  # @return [StepExecution, nil] the created step execution
724
1083
  def schedule_first_step!
725
- first_step = self.class.step_definitions.first
1084
+ # next_after(nil) means "from the beginning" and honours both the
1085
+ # before_step:/after_step: ordering and removed_step gravestones, neither
1086
+ # of which the raw _step_definitions array knows about.
1087
+ first_step = steps.next_after(nil)
726
1088
  unless first_step
727
1089
  logger.info("No steps defined, finishing workflow immediately")
728
1090
  return finish_workflow!
@@ -732,6 +1094,76 @@ class GenevaDrive::Workflow < ActiveRecord::Base
732
1094
  create_step_execution(first_step, wait: first_step.wait)
733
1095
  end
734
1096
 
1097
+ # Points a signal's workflow association at this instance, so matcher
1098
+ # blocks - which are instance_exec'd on it - see the live object.
1099
+ #
1100
+ # @param signal [GenevaDrive::Signal]
1101
+ # @return [GenevaDrive::Signal] the same signal
1102
+ def adopt_signal(signal)
1103
+ signal.association(:workflow).target = self
1104
+ signal
1105
+ end
1106
+
1107
+ # Enqueues the PerformStepJob for an execution that dispatch just flipped
1108
+ # from waiting to scheduled. Uses the same enqueue discipline (merged job
1109
+ # options, after-commit deferral, job_id writeback) as create_step_execution.
1110
+ #
1111
+ # @param step_execution [StepExecution] the woken execution
1112
+ # @param step_definition [StepDefinition] its step definition
1113
+ # @return [void]
1114
+ def enqueue_woken_execution(step_execution, step_definition)
1115
+ job_options = merged_step_job_options(step_definition)
1116
+ execution_id = step_execution.id
1117
+ workflow_logger = logger
1118
+
1119
+ run_after_commit do
1120
+ job = GenevaDrive::PerformStepJob
1121
+ .set(**job_options)
1122
+ .perform_later(execution_id)
1123
+
1124
+ workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for woken step execution #{execution_id}")
1125
+
1126
+ GenevaDrive::StepExecution
1127
+ .where(id: execution_id)
1128
+ .update_all(job_id: job.job_id)
1129
+ end
1130
+ end
1131
+
1132
+ # Re-runs the rendezvous for a step execution that is parked waiting for a
1133
+ # signal. Signals that arrived while the workflow was paused are delivered
1134
+ # here, on resume.
1135
+ #
1136
+ # @param step_execution [StepExecution] the parked execution
1137
+ # @return [StepExecution] the same execution (rescheduled if a signal matched)
1138
+ def rendezvous_waiting_execution!(step_execution)
1139
+ step_def = step_execution.step_definition
1140
+
1141
+ # The step was removed while this execution sat parked. It waits for an
1142
+ # event that nothing will ever match now, so release it here rather than
1143
+ # leaving it parked for good.
1144
+ if step_def&.removed?
1145
+ logger.info("Step #{step_execution.step_name} was parked but is now a removed_step — releasing it and continuing")
1146
+
1147
+ step_execution.mark_skipped!
1148
+ settle_signal_for_skipped!(step_execution)
1149
+ return schedule_next_step!
1150
+ end
1151
+
1152
+ matcher = step_def&.signal_matcher
1153
+
1154
+ if matcher
1155
+ candidate = attachable_signals.detect { |signal| matcher.matches?(signal) }
1156
+ if candidate
1157
+ logger.info("Resuming into a matching signal #{candidate.id} (#{candidate.name}) for step #{step_execution.step_name}")
1158
+ dispatch_signal!(candidate)
1159
+ return step_execution.reload
1160
+ end
1161
+ end
1162
+
1163
+ logger.info("Step #{step_execution.step_name} stays parked - no matching signal has arrived yet")
1164
+ step_execution
1165
+ end
1166
+
735
1167
  # Enqueues a job for an existing scheduled execution.
736
1168
  #
737
1169
  # If the execution is overdue (scheduled_for is in the past), runs immediately.
@@ -785,22 +1217,24 @@ class GenevaDrive::Workflow < ActiveRecord::Base
785
1217
  scheduled_for = wait ? wait.from_now : Time.current
786
1218
 
787
1219
  with_lock do
788
- # Cancel any stray scheduled executions - the successor is the one
789
- # execution that should run next (same as create_step_execution).
790
- canceled_count = step_executions.scheduled.update_all(
791
- state: "canceled",
792
- outcome: "canceled",
793
- canceled_at: Time.current
794
- )
795
- logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
1220
+ # Cancel any stray scheduled or parked executions - the successor is the
1221
+ # one execution that should run next (same as create_step_execution).
1222
+ cancel_stray_executions!
796
1223
 
797
- successor = step_executions.create!(
1224
+ successor_attributes = {
798
1225
  step_name: predecessor.step_name,
799
1226
  state: "scheduled",
800
1227
  scheduled_for: scheduled_for,
801
1228
  continues_from_id: predecessor.id,
802
1229
  cursor: predecessor.cursor
803
- )
1230
+ }
1231
+ # Carry the attachment pin across the chain so successors never re-park
1232
+ # and received_signal stays stable for the whole iteration.
1233
+ if GenevaDrive::StepExecution.signal_columns?
1234
+ successor_attributes[:signal_id] = predecessor.signal_id
1235
+ end
1236
+
1237
+ successor = step_executions.create!(**successor_attributes)
804
1238
 
805
1239
  # next_step_name points to the step that's scheduled to run next
806
1240
  update!(next_step_name: predecessor.step_name)
@@ -840,15 +1274,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
840
1274
  scheduled_for = wait ? wait.from_now : Time.current
841
1275
 
842
1276
  with_lock do
843
- # Cancel any scheduled step executions (not in_progress - those are being executed).
844
- # Safe to use update_all since we hold the workflow lock, blocking any executor
845
- # that would try to start these steps.
846
- canceled_count = step_executions.scheduled.update_all(
847
- state: "canceled",
848
- outcome: "canceled",
849
- canceled_at: Time.current
850
- )
851
- logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
1277
+ cancel_stray_executions!
852
1278
 
853
1279
  step_execution = step_executions.create!(
854
1280
  step_name: step_definition.name,
@@ -892,6 +1318,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
892
1318
  end
893
1319
  end
894
1320
 
1321
+ # Cancels any scheduled or parked step executions so that the execution
1322
+ # about to be created is the only one that will run. In-progress executions
1323
+ # are left alone - they are being executed.
1324
+ #
1325
+ # Safe to use update_all because the caller holds the workflow lock, which
1326
+ # blocks any executor that would try to start these steps. Parked executions
1327
+ # are swept for the same reason: no legitimate path creates a new execution
1328
+ # past one that is waiting.
1329
+ #
1330
+ # @return [Integer] the number of executions canceled
1331
+ def cancel_stray_executions!
1332
+ canceled_count = step_executions.where(state: %w[scheduled waiting]).update_all(
1333
+ state: "canceled",
1334
+ outcome: "canceled",
1335
+ canceled_at: Time.current
1336
+ )
1337
+ logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
1338
+ canceled_count
1339
+ end
1340
+
895
1341
  # Finishes the workflow.
896
1342
  #
897
1343
  # @return [nil]
@@ -948,6 +1394,33 @@ class GenevaDrive::Workflow < ActiveRecord::Base
948
1394
  @tagged_logger = previous_tagged_logger
949
1395
  end
950
1396
 
1397
+ # The signal that woke the step currently executing, or nil when the step
1398
+ # does not declare +wait_for:+. Available inside step bodies only.
1399
+ #
1400
+ # @return [GenevaDrive::Signal, nil]
1401
+ #
1402
+ # @example Read the payload of the event that woke the step
1403
+ # step :capture, wait_for: :payment_confirmed do
1404
+ # hero.capture!(received_signal.payload[:amount_cents])
1405
+ # end
1406
+ public attr_reader :received_signal
1407
+
1408
+ # Temporarily makes a signal available to step code as +received_signal+
1409
+ # for the duration of the block. Injected by the Executor the same way the
1410
+ # tagged logger is.
1411
+ #
1412
+ # @param signal [GenevaDrive::Signal, nil] the attached signal
1413
+ # @yield the block to execute with the signal in scope
1414
+ # @return [Object] the result of the block
1415
+ # @api private
1416
+ public def with_received_signal(signal)
1417
+ previous_received_signal = @received_signal
1418
+ @received_signal = signal
1419
+ yield
1420
+ ensure
1421
+ @received_signal = previous_received_signal
1422
+ end
1423
+
951
1424
  # Returns the Logger properly tagged to this Workflow
952
1425
  #
953
1426
  # @return [Logger]
data/lib/geneva_drive.rb CHANGED
@@ -32,19 +32,25 @@ module GenevaDrive
32
32
  autoload :StepExecutionError, "geneva_drive/flow_control"
33
33
  autoload :StepNotDefinedError, "geneva_drive/flow_control"
34
34
  autoload :CursorTooLargeError, "geneva_drive/flow_control"
35
+ autoload :SignalPayloadTooLargeError, "geneva_drive/flow_control"
36
+ autoload :WorkflowNotOngoing, "geneva_drive/flow_control"
35
37
  autoload :StepFailedError, "geneva_drive/flow_control"
36
38
  autoload :PreconditionError, "geneva_drive/flow_control"
37
39
  autoload :FlowControl, "geneva_drive/flow_control"
38
40
  autoload :StepDefinition, "geneva_drive/step_definition"
39
41
  autoload :ResumableStepDefinition, "geneva_drive/resumable_step_definition"
42
+ autoload :RemovedStepDefinition, "geneva_drive/removed_step_definition"
40
43
  autoload :StepCollection, "geneva_drive/step_collection"
41
44
  autoload :Workflow, "geneva_drive/workflow"
42
45
  autoload :StepExecution, "geneva_drive/step_execution"
46
+ autoload :Signal, "geneva_drive/signal"
47
+ autoload :SignalMatcher, "geneva_drive/signal_matcher"
43
48
  autoload :IterableStep, "geneva_drive/iterable_step"
44
49
  autoload :Executor, "geneva_drive/executor"
45
50
  autoload :ExceptionPolicy, "geneva_drive/exception_policy"
46
51
  autoload :JobOptions, "geneva_drive/job_options"
47
52
  autoload :CombinedExceptionPolicy, "geneva_drive/combined_exception_policy"
53
+ autoload :JsonColumn, "geneva_drive/json_column"
48
54
  autoload :MigrationHelpers, "geneva_drive/migration_helpers"
49
55
  autoload :TestHelpers, "geneva_drive/test_helpers"
50
56
 
@@ -88,6 +94,22 @@ module GenevaDrive
88
94
  # @return [Integer, nil]
89
95
  attr_accessor :max_cursor_size
90
96
 
97
+ # Maximum size in bytes of a signal payload once serialized to JSON.
98
+ # A payload describes an event - an order id, an amount, a decision -
99
+ # not the data the step is going to work on. Exceeding the limit raises
100
+ # SignalPayloadTooLargeError from Workflow#signal!, before anything is
101
+ # persisted. Set to nil to disable the check.
102
+ # @return [Integer, nil]
103
+ attr_accessor :max_signal_payload_size
104
+
105
+ # How long a step execution may sit in the "waiting" state before
106
+ # housekeeping counts it in the `geneva_drive.waiting_overdue` gauge.
107
+ # Waiting indefinitely is legitimate, so this is not a timeout - it is
108
+ # the floor that keeps a stalled rendezvous from being silent.
109
+ # Set to nil to disable the gauge.
110
+ # @return [ActiveSupport::Duration, nil]
111
+ attr_accessor :waiting_visibility_threshold
112
+
91
113
  # Whether to defer job enqueueing to after the database transaction commits.
92
114
  # When true (the default in non-test environments), jobs are enqueued inside
93
115
  # an `after_all_transactions_commit` callback to ensure the step execution
@@ -175,5 +197,7 @@ module GenevaDrive
175
197
  self.housekeeping_batch_size = 1000
176
198
  self.stuck_recovery_action = :reattempt
177
199
  self.max_cursor_size = 128 * 1024
200
+ self.max_signal_payload_size = 128 * 1024
201
+ self.waiting_visibility_threshold = 7.days
178
202
  self.enqueue_after_commit = !Rails.env.test?
179
203
  end