geneva_drive 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -1
- data/MANUAL.md +176 -0
- data/README.md +2 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/geneva_drive/executor.rb +113 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +82 -1
- data/lib/geneva_drive/step_execution.rb +36 -0
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +327 -22
- data/lib/geneva_drive.rb +22 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +2 -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 +11 -1
|
@@ -46,6 +46,8 @@ module GenevaDrive::TestHelpers
|
|
|
46
46
|
step_execution = workflow.current_execution
|
|
47
47
|
break unless step_execution
|
|
48
48
|
|
|
49
|
+
raise_if_parked!(step_execution, "speedrun_workflow")
|
|
50
|
+
|
|
49
51
|
# interruptible: false so resumable steps run to completion
|
|
50
52
|
step_execution.execute!(interruptible: false)
|
|
51
53
|
iterations += 1
|
|
@@ -79,6 +81,8 @@ module GenevaDrive::TestHelpers
|
|
|
79
81
|
step_execution = workflow.current_execution
|
|
80
82
|
return nil unless step_execution
|
|
81
83
|
|
|
84
|
+
raise_if_parked!(step_execution, "perform_next_step")
|
|
85
|
+
|
|
82
86
|
step_execution.execute!
|
|
83
87
|
workflow.reload
|
|
84
88
|
step_execution
|
|
@@ -117,8 +121,9 @@ module GenevaDrive::TestHelpers
|
|
|
117
121
|
"Available steps: #{available}"
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
# Cancel any existing scheduled step executions to satisfy
|
|
121
|
-
|
|
124
|
+
# Cancel any existing scheduled or parked step executions to satisfy the
|
|
125
|
+
# uniqueness constraint
|
|
126
|
+
workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
122
127
|
state: "canceled",
|
|
123
128
|
outcome: "canceled",
|
|
124
129
|
canceled_at: Time.current
|
|
@@ -194,11 +199,14 @@ module GenevaDrive::TestHelpers
|
|
|
194
199
|
step_execution = workflow.current_execution
|
|
195
200
|
return nil unless step_execution
|
|
196
201
|
|
|
202
|
+
raise_if_parked!(step_execution, "speedrun_current_step")
|
|
203
|
+
|
|
197
204
|
executions = 0
|
|
198
205
|
original_step_name = step_execution.step_name
|
|
199
206
|
|
|
200
207
|
loop do
|
|
201
208
|
step_execution.reload
|
|
209
|
+
raise_if_parked!(step_execution, "speedrun_current_step")
|
|
202
210
|
|
|
203
211
|
# Follow the execution chain: a completed execution that spawned a
|
|
204
212
|
# successor means the step is still going (suspend! and skip_to! create
|
|
@@ -289,4 +297,83 @@ module GenevaDrive::TestHelpers
|
|
|
289
297
|
assert successor, "Expected successor execution for step #{step_name}, but none found"
|
|
290
298
|
assert successor.scheduled?, "Expected successor to be scheduled, but was #{successor.state}"
|
|
291
299
|
end
|
|
300
|
+
|
|
301
|
+
# === Signal Test Helpers ===
|
|
302
|
+
|
|
303
|
+
# Asserts that the workflow is parked waiting for a signal.
|
|
304
|
+
#
|
|
305
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
306
|
+
# @param signal_name [String, Symbol, nil] the signal name the step waits for
|
|
307
|
+
# @return [void]
|
|
308
|
+
#
|
|
309
|
+
# @example Check that the workflow is waiting
|
|
310
|
+
# assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
311
|
+
#
|
|
312
|
+
def assert_waiting_for_signal(workflow, signal_name = nil)
|
|
313
|
+
workflow.reload
|
|
314
|
+
execution = workflow.step_executions.where(state: "waiting").first
|
|
315
|
+
|
|
316
|
+
assert execution,
|
|
317
|
+
"Expected #{workflow.class.name} to be waiting for a signal, but no parked step execution was found " \
|
|
318
|
+
"(current execution: #{workflow.current_execution&.state.inspect})"
|
|
319
|
+
|
|
320
|
+
return unless signal_name
|
|
321
|
+
|
|
322
|
+
matcher = execution.step_definition&.signal_matcher
|
|
323
|
+
assert_equal signal_name.to_s, matcher_name(matcher),
|
|
324
|
+
"Expected step '#{execution.step_name}' to be waiting for #{signal_name.inspect}, " \
|
|
325
|
+
"but it waits for #{matcher}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Asserts that a signal with the given name exists in the given state.
|
|
329
|
+
#
|
|
330
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
331
|
+
# @param signal_name [String, Symbol] the signal name
|
|
332
|
+
# @param state [String, Symbol] the expected signal state
|
|
333
|
+
# @return [void]
|
|
334
|
+
#
|
|
335
|
+
# @example Check that the signal was consumed
|
|
336
|
+
# assert_signal_state(workflow, :payment_confirmed, :consumed)
|
|
337
|
+
#
|
|
338
|
+
def assert_signal_state(workflow, signal_name, state)
|
|
339
|
+
signal = workflow.signals.where(name: signal_name.to_s).order(:created_at, :id).last
|
|
340
|
+
|
|
341
|
+
assert signal, "Expected a #{signal_name.inspect} signal on #{workflow.class.name}, but none was found"
|
|
342
|
+
assert_equal state.to_s, signal.state,
|
|
343
|
+
"Expected signal #{signal_name.inspect} to be #{state}, but was #{signal.state}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private
|
|
347
|
+
|
|
348
|
+
# The signal name a matcher accepts, when it has one to give. Custom
|
|
349
|
+
# matchers own their whole predicate and need not expose a name.
|
|
350
|
+
#
|
|
351
|
+
# @param matcher [Object, nil] anything responding to #matches?(signal)
|
|
352
|
+
# @return [String, nil]
|
|
353
|
+
def matcher_name(matcher)
|
|
354
|
+
matcher.name if matcher.respond_to?(:name)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Raises a descriptive error when a step-driving helper runs into a parked
|
|
358
|
+
# execution. Without this a parked workflow would look like an infinite
|
|
359
|
+
# loop (or a silently passing assertion) instead of "you forgot to signal".
|
|
360
|
+
#
|
|
361
|
+
# @param step_execution [GenevaDrive::StepExecution] the execution about to be driven
|
|
362
|
+
# @param helper_name [String] the helper that was called
|
|
363
|
+
# @return [void]
|
|
364
|
+
def raise_if_parked!(step_execution, helper_name)
|
|
365
|
+
return unless step_execution.waiting?
|
|
366
|
+
|
|
367
|
+
matcher = begin
|
|
368
|
+
step_execution.step_definition&.signal_matcher
|
|
369
|
+
rescue
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
waiting_for = matcher ? " waiting for #{matcher}" : ""
|
|
373
|
+
example_name = matcher_name(matcher)&.to_sym
|
|
374
|
+
|
|
375
|
+
raise "#{helper_name} cannot drive step '#{step_execution.step_name}': its execution is parked" \
|
|
376
|
+
"#{waiting_for}. Deliver the signal first, e.g. " \
|
|
377
|
+
"workflow.signal!(#{example_name.inspect}), then call #{helper_name} again."
|
|
378
|
+
end
|
|
292
379
|
end
|
data/lib/geneva_drive/version.rb
CHANGED
|
@@ -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: []
|
|
@@ -523,7 +528,14 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
523
528
|
|
|
524
529
|
# A scheduled execution preserved by pause! takes precedence - re-enqueue it
|
|
525
530
|
scheduled_execution = current_execution
|
|
526
|
-
|
|
531
|
+
if scheduled_execution
|
|
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
|
|
527
539
|
|
|
528
540
|
# Resumable-step continuations need the cursor/continues_from_id columns
|
|
529
541
|
if GenevaDrive::StepExecution.resumable_columns?
|
|
@@ -557,12 +569,206 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
557
569
|
create_step_execution(step_def, wait: nil)
|
|
558
570
|
end
|
|
559
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
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
signal.duplicate_delivery! if duplicate
|
|
666
|
+
signal
|
|
667
|
+
end
|
|
668
|
+
|
|
560
669
|
# Returns the current active step execution, if any.
|
|
561
|
-
# Includes scheduled and in_progress states.
|
|
670
|
+
# Includes scheduled, waiting and in_progress states.
|
|
562
671
|
#
|
|
563
672
|
# @return [StepExecution, nil] the current execution
|
|
564
673
|
def current_execution
|
|
565
|
-
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)
|
|
566
772
|
end
|
|
567
773
|
|
|
568
774
|
# Returns all step executions in chronological order.
|
|
@@ -732,6 +938,64 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
732
938
|
create_step_execution(first_step, wait: first_step.wait)
|
|
733
939
|
end
|
|
734
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
|
+
|
|
735
999
|
# Enqueues a job for an existing scheduled execution.
|
|
736
1000
|
#
|
|
737
1001
|
# If the execution is overdue (scheduled_for is in the past), runs immediately.
|
|
@@ -785,22 +1049,24 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
785
1049
|
scheduled_for = wait ? wait.from_now : Time.current
|
|
786
1050
|
|
|
787
1051
|
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
|
|
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!
|
|
796
1055
|
|
|
797
|
-
|
|
1056
|
+
successor_attributes = {
|
|
798
1057
|
step_name: predecessor.step_name,
|
|
799
1058
|
state: "scheduled",
|
|
800
1059
|
scheduled_for: scheduled_for,
|
|
801
1060
|
continues_from_id: predecessor.id,
|
|
802
1061
|
cursor: predecessor.cursor
|
|
803
|
-
|
|
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)
|
|
804
1070
|
|
|
805
1071
|
# next_step_name points to the step that's scheduled to run next
|
|
806
1072
|
update!(next_step_name: predecessor.step_name)
|
|
@@ -840,15 +1106,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
840
1106
|
scheduled_for = wait ? wait.from_now : Time.current
|
|
841
1107
|
|
|
842
1108
|
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
|
|
1109
|
+
cancel_stray_executions!
|
|
852
1110
|
|
|
853
1111
|
step_execution = step_executions.create!(
|
|
854
1112
|
step_name: step_definition.name,
|
|
@@ -892,6 +1150,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
892
1150
|
end
|
|
893
1151
|
end
|
|
894
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
|
+
|
|
895
1173
|
# Finishes the workflow.
|
|
896
1174
|
#
|
|
897
1175
|
# @return [nil]
|
|
@@ -948,6 +1226,33 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
948
1226
|
@tagged_logger = previous_tagged_logger
|
|
949
1227
|
end
|
|
950
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
|
+
|
|
951
1256
|
# Returns the Logger properly tagged to this Workflow
|
|
952
1257
|
#
|
|
953
1258
|
# @return [Logger]
|
data/lib/geneva_drive.rb
CHANGED
|
@@ -32,6 +32,8 @@ 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"
|
|
@@ -40,6 +42,8 @@ module GenevaDrive
|
|
|
40
42
|
autoload :StepCollection, "geneva_drive/step_collection"
|
|
41
43
|
autoload :Workflow, "geneva_drive/workflow"
|
|
42
44
|
autoload :StepExecution, "geneva_drive/step_execution"
|
|
45
|
+
autoload :Signal, "geneva_drive/signal"
|
|
46
|
+
autoload :SignalMatcher, "geneva_drive/signal_matcher"
|
|
43
47
|
autoload :IterableStep, "geneva_drive/iterable_step"
|
|
44
48
|
autoload :Executor, "geneva_drive/executor"
|
|
45
49
|
autoload :ExceptionPolicy, "geneva_drive/exception_policy"
|
|
@@ -88,6 +92,22 @@ module GenevaDrive
|
|
|
88
92
|
# @return [Integer, nil]
|
|
89
93
|
attr_accessor :max_cursor_size
|
|
90
94
|
|
|
95
|
+
# Maximum size in bytes of a signal payload once serialized to JSON.
|
|
96
|
+
# A payload describes an event - an order id, an amount, a decision -
|
|
97
|
+
# not the data the step is going to work on. Exceeding the limit raises
|
|
98
|
+
# SignalPayloadTooLargeError from Workflow#signal!, before anything is
|
|
99
|
+
# persisted. Set to nil to disable the check.
|
|
100
|
+
# @return [Integer, nil]
|
|
101
|
+
attr_accessor :max_signal_payload_size
|
|
102
|
+
|
|
103
|
+
# How long a step execution may sit in the "waiting" state before
|
|
104
|
+
# housekeeping counts it in the `geneva_drive.waiting_overdue` gauge.
|
|
105
|
+
# Waiting indefinitely is legitimate, so this is not a timeout - it is
|
|
106
|
+
# the floor that keeps a stalled rendezvous from being silent.
|
|
107
|
+
# Set to nil to disable the gauge.
|
|
108
|
+
# @return [ActiveSupport::Duration, nil]
|
|
109
|
+
attr_accessor :waiting_visibility_threshold
|
|
110
|
+
|
|
91
111
|
# Whether to defer job enqueueing to after the database transaction commits.
|
|
92
112
|
# When true (the default in non-test environments), jobs are enqueued inside
|
|
93
113
|
# an `after_all_transactions_commit` callback to ensure the step execution
|
|
@@ -175,5 +195,7 @@ module GenevaDrive
|
|
|
175
195
|
self.housekeeping_batch_size = 1000
|
|
176
196
|
self.stuck_recovery_action = :reattempt
|
|
177
197
|
self.max_cursor_size = 128 * 1024
|
|
198
|
+
self.max_signal_payload_size = 128 * 1024
|
|
199
|
+
self.waiting_visibility_threshold = 7.days
|
|
178
200
|
self.enqueue_after_commit = !Rails.env.test?
|
|
179
201
|
end
|