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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/MANUAL.md +260 -1
- data/README.md +2 -0
- data/Rakefile +14 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
- data/lib/geneva_drive/executor.rb +131 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +27 -0
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +245 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +91 -1
- data/lib/geneva_drive/step_execution.rb +39 -2
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +497 -24
- data/lib/geneva_drive.rb +24 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +46 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +53 -13
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
789
|
-
# execution that should run next (same as create_step_execution).
|
|
790
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|