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.
@@ -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 uniqueness constraint
121
- workflow.step_executions.where(state: "scheduled").update_all(
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GenevaDrive
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -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
- return enqueue_scheduled_execution(scheduled_execution) if scheduled_execution
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 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
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
- successor = step_executions.create!(
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
- # 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
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