geneva_drive 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/MANUAL.md +260 -1
  4. data/README.md +2 -0
  5. data/Rakefile +14 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
  9. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  10. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
  11. data/lib/geneva_drive/executor.rb +131 -10
  12. data/lib/geneva_drive/flow_control.rb +43 -3
  13. data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
  14. data/lib/geneva_drive/json_column.rb +72 -0
  15. data/lib/geneva_drive/migration_helpers.rb +27 -0
  16. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  17. data/lib/geneva_drive/signal.rb +245 -0
  18. data/lib/geneva_drive/signal_matcher.rb +60 -0
  19. data/lib/geneva_drive/step_collection.rb +31 -7
  20. data/lib/geneva_drive/step_definition.rb +91 -1
  21. data/lib/geneva_drive/step_execution.rb +39 -2
  22. data/lib/geneva_drive/test_helpers.rb +89 -2
  23. data/lib/geneva_drive/version.rb +1 -1
  24. data/lib/geneva_drive/workflow.rb +497 -24
  25. data/lib/geneva_drive.rb +24 -0
  26. data/test/dsl/signal_step_definition_test.rb +132 -0
  27. data/test/dummy/config/database.yml +40 -5
  28. data/test/dummy/config/initializers/geneva_drive.rb +46 -0
  29. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  30. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  31. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  32. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  33. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  34. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  35. data/test/dummy_install/db/schema.rb +75 -0
  36. data/test/dummy_install/log/test.log +1852 -0
  37. data/test/jobs/housekeeping_signals_test.rb +198 -0
  38. data/test/migration_helpers_test.rb +1 -1
  39. data/test/test_helper.rb +53 -13
  40. data/test/workflow/removed_step_test.rb +294 -0
  41. data/test/workflow/resume_at_test.rb +170 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. metadata +26 -2
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A per-workflow event record. Signals are how the outside world tells a
4
+ # workflow that something happened: a webhook landed, a human clicked
5
+ # "approve", another system finished its part of the job.
6
+ #
7
+ # A signal is always addressed to one workflow instance and is persisted
8
+ # before any processing happens, so it can never be lost between arriving
9
+ # and being noticed - a signal that arrives before the waiting step even
10
+ # exists simply sits in the table until the step's gate picks it up.
11
+ #
12
+ # The lifecycle, in the +state+ column, is +pending+ -> +claimed+ -> +consumed+:
13
+ #
14
+ # - +pending+: persisted, nobody is working on it
15
+ # - +claimed+: attached to at least one step execution, at least one of which
16
+ # has not resolved yet
17
+ # - +consumed+: every attached chain resolved cleanly; no new execution may attach
18
+ #
19
+ # Alongside the state there are two counters, +claimed+ and +consumed+: how
20
+ # many executions have attached to this event, and how many attached chains
21
+ # have resolved cleanly.
22
+ #
23
+ # @example Delivering a signal
24
+ # workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
25
+ # workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
26
+ #
27
+ class GenevaDrive::Signal < ActiveRecord::Base
28
+ include GenevaDrive::JsonColumn
29
+
30
+ self.table_name = "geneva_drive_signals"
31
+
32
+ # Signal lifecycle states as enum with string values.
33
+ #
34
+ # Neither predicates nor scopes are generated: the +claimed+ and +consumed+
35
+ # counter columns own those names, and a `Signal.claimed` scope reading the
36
+ # state while `signal.claimed` reads the counter would be a trap. The state
37
+ # is queried explicitly (`where(state: ...)`) and read off the column; the
38
+ # public predicates are {#claimed?} and {#consumed?} over the counters.
39
+ enum :state, {
40
+ pending: "pending",
41
+ claimed: "claimed",
42
+ consumed: "consumed"
43
+ }, instance_methods: false, scopes: false
44
+
45
+ # Step execution states that mean "this chain has not come to rest yet".
46
+ ACTIVE_EXECUTION_STATES = %w[waiting scheduled in_progress].freeze
47
+
48
+ # Outcomes that resolve an attached chain cleanly, so the signal can be
49
+ # considered handled by it.
50
+ CLEAN_OUTCOMES = %w[success skipped].freeze
51
+
52
+ belongs_to :workflow,
53
+ class_name: "GenevaDrive::Workflow",
54
+ foreign_key: :workflow_id,
55
+ inverse_of: :signals
56
+
57
+ validates :name, presence: true
58
+
59
+ # Signals that may still be attached to a step execution, oldest first.
60
+ # The eligibility rule for both the gate and dispatch: anything that
61
+ # matches and has not been consumed, FIFO.
62
+ scope :attachable, -> { where.not(state: "consumed").order(created_at: :asc, id: :asc) }
63
+
64
+ class << self
65
+ # Lazily checks whether the signals table has been migrated. Never hits
66
+ # the database at class definition time - only on the first runtime call.
67
+ #
68
+ # Deployments usually ship the gem update before running migrations, so
69
+ # everything except actually sending or waiting for a signal must keep
70
+ # working when the table is absent.
71
+ #
72
+ # @return [Boolean]
73
+ def table_available?
74
+ if defined?(@_table_available)
75
+ return @_table_available
76
+ end
77
+
78
+ @_table_available = table_exists?
79
+ end
80
+
81
+ # Clears the cached table detection result. Call this in tests or after
82
+ # running migrations in-process so the next access re-checks.
83
+ #
84
+ # @return [void]
85
+ def reset_table_available_cache!
86
+ remove_instance_variable(:@_table_available) if defined?(@_table_available)
87
+ end
88
+
89
+ # Serializes a payload using ActiveJob serializers (handles Date, Time,
90
+ # and other types) and enforces GenevaDrive.max_signal_payload_size on
91
+ # the serialized JSON. The single serialization path for payload writes.
92
+ #
93
+ # @param value [Object, nil] the payload
94
+ # @return [Object, nil] the serialized payload
95
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
96
+ def serialize_payload(value)
97
+ return nil if value.nil?
98
+
99
+ serialized = ActiveJob::Arguments.serialize([value]).first
100
+
101
+ limit = GenevaDrive.max_signal_payload_size
102
+ if limit
103
+ bytesize = JSON.generate(serialized).bytesize
104
+ if bytesize > limit
105
+ raise GenevaDrive::SignalPayloadTooLargeError,
106
+ "Serialized signal payload is #{bytesize} bytes, exceeding " \
107
+ "GenevaDrive.max_signal_payload_size (#{limit} bytes). A payload describes the " \
108
+ "event, it is not a place to ship the data the step should be working on - " \
109
+ "write what matters onto the hero instead. Set " \
110
+ "GenevaDrive.max_signal_payload_size to nil to disable this check."
111
+ end
112
+ end
113
+
114
+ encode_json_column(:payload, serialized)
115
+ end
116
+ end
117
+
118
+ # Returns the deserialized payload. Hashes come back with indifferent
119
+ # access, because webhook senders produce string keys while Ruby senders
120
+ # produce symbols and matchers should not have to know which.
121
+ #
122
+ # @return [Object, nil] the payload
123
+ def payload
124
+ raw = self.class.decode_json_column(:payload, self[:payload])
125
+ return nil if raw.nil?
126
+
127
+ value = ActiveJob::Arguments.deserialize([raw]).first
128
+ value.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(value) : value
129
+ end
130
+
131
+ # Sets the payload, serializing it through ActiveJob serializers.
132
+ #
133
+ # @param value [Object, nil] the payload
134
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
135
+ # @return [void]
136
+ def payload=(value)
137
+ self[:payload] = self.class.serialize_payload(value)
138
+ end
139
+
140
+ # Whether this instance was returned from a deduplicated delivery - that
141
+ # is, {GenevaDrive::Workflow#signal!} found an existing row with the same
142
+ # idempotency key instead of inserting a new one. Not a column: it is a
143
+ # fact about this call, not about the record.
144
+ #
145
+ # @return [Boolean]
146
+ def duplicate_delivery?
147
+ !!@duplicate_delivery
148
+ end
149
+
150
+ # Flags this instance as the result of a deduplicated delivery.
151
+ #
152
+ # @return [void]
153
+ # @api private
154
+ def duplicate_delivery!
155
+ @duplicate_delivery = true
156
+ end
157
+
158
+ # Whether at least one execution has ever attached to this signal. Reads
159
+ # the counter, not the lifecycle state, so it stays true after the signal
160
+ # has been consumed - "this event was picked up" rather than "is being
161
+ # handled right now".
162
+ #
163
+ # @return [Boolean]
164
+ def claimed? = claimed > 0
165
+
166
+ # Whether at least one attached chain has resolved cleanly. Reads the
167
+ # counter, so for a one-to-many dispatch it goes true with the first
168
+ # resolved branch, while the state only flips once the last one resolves.
169
+ #
170
+ # @return [Boolean]
171
+ def consumed? = consumed > 0
172
+
173
+ # Records one execution attaching to this signal, bumping the claimed
174
+ # count. The pending -> claimed state flip and claimed_at happen on the
175
+ # first claim only, so the timestamp keeps meaning "when this event started
176
+ # being handled". A retry attaching to a still-claimed signal counts as a
177
+ # new claim; a successor continuing the same chain does not (it carries the
178
+ # pin over rather than acquiring it).
179
+ #
180
+ # @return [void]
181
+ # @api private
182
+ def claim!
183
+ attrs = {claimed: claimed + 1}
184
+ if state == "pending"
185
+ attrs[:state] = "claimed"
186
+ attrs[:claimed_at] = Time.current
187
+ end
188
+ update!(attrs)
189
+ end
190
+
191
+ # Records one attached execution chain resolving cleanly, bumping the
192
+ # consumed count. The state flips to consumed - closing the signal to new
193
+ # attachments - only once every attached chain has resolved, which for a
194
+ # single claimant is the same moment.
195
+ #
196
+ # @param resolved_step_names [Array<String, Symbol>] steps whose chains are
197
+ # resolved by decree rather than by outcome - a skip settles the chain it
198
+ # skips past even when that chain's last execution failed
199
+ # @return [void]
200
+ # @api private
201
+ def record_consumption!(resolved_step_names: [])
202
+ attrs = {consumed: consumed + 1}
203
+ if state == "claimed" && fully_resolved?(resolved_step_names: resolved_step_names)
204
+ attrs[:state] = "consumed"
205
+ attrs[:consumed_at] = Time.current
206
+ end
207
+ update!(attrs)
208
+ end
209
+
210
+ # Whether every execution chain attached to this signal has come to a
211
+ # clean stop. A chain that is still running (or parked), and one whose
212
+ # latest attached execution failed or was canceled, both keep the state at
213
+ # claimed - the failed chain's retry re-attaches to it and reads the same
214
+ # payload.
215
+ #
216
+ # Only the most recent attached execution per step counts: the earlier ones
217
+ # in a chain end with `continued` or `reattempted` precisely because they
218
+ # handed the work to the next one.
219
+ #
220
+ # @param resolved_step_names [Array<String, Symbol>] steps to treat as
221
+ # resolved whatever their executions say. Skipping a step is a decision
222
+ # that its chain is over, including when that chain ended in a failure -
223
+ # no future execution of it is coming to resolve it cleanly.
224
+ # @return [Boolean]
225
+ # @api private
226
+ def fully_resolved?(resolved_step_names: [])
227
+ settled = Array(resolved_step_names).map(&:to_s)
228
+ attached = step_executions.order(created_at: :asc, id: :asc).to_a
229
+ return true if attached.empty?
230
+
231
+ unsettled = attached.reject { |execution| settled.include?(execution.step_name) }
232
+ return false if unsettled.any? { |execution| ACTIVE_EXECUTION_STATES.include?(execution.state) }
233
+
234
+ unsettled.group_by(&:step_name).all? do |_step_name, chain|
235
+ CLEAN_OUTCOMES.include?(chain.last.outcome)
236
+ end
237
+ end
238
+
239
+ # Step executions pinned to this signal.
240
+ #
241
+ # @return [ActiveRecord::Relation<GenevaDrive::StepExecution>]
242
+ def step_executions
243
+ GenevaDrive::StepExecution.where(signal_id: id)
244
+ end
245
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decides whether a given {GenevaDrive::Signal} is the one a step is waiting
4
+ # for. A matcher is a plain object, so it can live in a constant and be shared
5
+ # between steps and workflows.
6
+ #
7
+ # Name equality is the whole predicate unless a block is given; the block
8
+ # receives the (indifferent-access) payload and is +instance_exec+'d on the
9
+ # signal's workflow, so +hero+ and the workflow's own methods are in scope.
10
+ #
11
+ # @example Name only (what `wait_for: :payment_confirmed` builds for you)
12
+ # GenevaDrive::SignalMatcher.new(:payment_confirmed)
13
+ #
14
+ # @example Narrowed by payload
15
+ # GenevaDrive::SignalMatcher.new(:document_signed) do |payload|
16
+ # payload[:document_id] == hero.contract_id
17
+ # end
18
+ #
19
+ # Anything else responding to +#matches?(signal)+ works just as well in
20
+ # +wait_for:+ - a custom matcher owns its whole predicate and can, for
21
+ # instance, accept either of two signal names.
22
+ #
23
+ # Matchers run both at the gate (the waiting step's own job) and at dispatch
24
+ # (inside +signal!+, in the sender's process). They must be cheap and free of
25
+ # side effects, the same expectation +skip_if:+ carries.
26
+ class GenevaDrive::SignalMatcher
27
+ # @return [String] the signal name this matcher accepts
28
+ attr_reader :name
29
+
30
+ # @return [Proc, nil] the payload predicate, if any
31
+ attr_reader :condition
32
+
33
+ # @param name [String, Symbol] signal name for equality matching
34
+ # @yield [payload] optional payload predicate, instance_exec'd on the workflow
35
+ # @raise [ArgumentError] if the name is blank
36
+ def initialize(name, &condition)
37
+ raise ArgumentError, "GenevaDrive::SignalMatcher needs a signal name" if name.blank?
38
+
39
+ @name = name.to_s
40
+ @condition = condition
41
+ end
42
+
43
+ # Whether the signal is the one being waited for.
44
+ #
45
+ # @param signal [GenevaDrive::Signal] the candidate signal
46
+ # @return [Boolean]
47
+ def matches?(signal)
48
+ return false unless signal.name == @name
49
+ return true unless @condition
50
+
51
+ !!signal.workflow.instance_exec(signal.payload, &@condition)
52
+ end
53
+
54
+ # Human-readable description, used in log lines and test helper messages.
55
+ #
56
+ # @return [String]
57
+ def to_s
58
+ @condition ? "#{@name} (narrowed by payload)" : @name
59
+ end
60
+ end
@@ -65,30 +65,54 @@ class GenevaDrive::StepCollection
65
65
  ordered_steps[position]
66
66
  end
67
67
 
68
- # Returns the next step after the given step name.
68
+ # Returns the next runnable step after the given step name.
69
+ #
70
+ # Steps declared with `removed_step` are stepped over: they hold a position
71
+ # in the sequence so that executions scheduled before their removal still
72
+ # resolve, but no execution is ever scheduled for one.
73
+ #
74
+ # A nil current_name means "the beginning", so the first runnable step is
75
+ # returned - which is how the first step of a workflow is chosen.
69
76
  #
70
77
  # @param current_name [String, Symbol, nil] the current step name
71
- # @return [StepDefinition, nil] the next step or nil if at end
78
+ # @return [StepDefinition, nil] the next runnable step or nil if at end
72
79
  def next_after(current_name)
73
- return first if current_name.nil?
80
+ return ordered_steps.find { |s| !s.removed? } if current_name.nil?
74
81
 
75
82
  current_index = ordered_steps.index { |s| s.name == current_name.to_s }
76
83
  return nil unless current_index
77
84
 
78
- self[current_index + 1]
85
+ ordered_steps[(current_index + 1)..].find { |s| !s.removed? }
79
86
  end
80
87
 
81
- # Returns the step before the given step name in the ordered sequence.
88
+ # Returns the previous runnable step before the given step name in the
89
+ # ordered sequence. Removed steps are stepped over, for the same reason
90
+ # {#next_after} steps over them.
82
91
  #
83
92
  # @param current_name [String, Symbol, nil] the current step name
84
- # @return [StepDefinition, nil] the previous step or nil if at beginning
93
+ # @return [StepDefinition, nil] the previous runnable step or nil if at beginning
85
94
  def previous_before(current_name)
86
95
  return nil if current_name.nil?
87
96
 
88
97
  current_index = find_index { |s| s.name == current_name.to_s }
89
98
  return nil unless current_index && current_index > 0
90
99
 
91
- self[current_index - 1]
100
+ ordered_steps[0...current_index].reverse_each.find { |s| !s.removed? }
101
+ end
102
+
103
+ # Returns the steps that can actually run, in order, with `removed_step`
104
+ # gravestones filtered out.
105
+ #
106
+ # @return [Array<StepDefinition>] the runnable step definitions
107
+ def runnable
108
+ ordered_steps.reject(&:removed?)
109
+ end
110
+
111
+ # Returns the `removed_step` gravestones, in order.
112
+ #
113
+ # @return [Array<StepDefinition>] the removed step definitions
114
+ def removed
115
+ ordered_steps.select(&:removed?)
92
116
  end
93
117
 
94
118
  # Checks if a step exists with the given name.
@@ -15,6 +15,10 @@ class GenevaDrive::StepDefinition
15
15
  # Valid types for skip conditions
16
16
  VALID_SKIP_CONDITION_TYPES = [Symbol, Proc, TrueClass, FalseClass, NilClass].freeze
17
17
 
18
+ # Types that mean "a delay" and therefore belong to wait:, never wait_for:.
19
+ # ActiveSupport::Duration is a Numeric-alike but not a Numeric, hence both.
20
+ DURATION_SHAPED_TYPES = [ActiveSupport::Duration, Numeric, Time, Date].freeze
21
+
18
22
  # @return [String] the step name
19
23
  attr_reader :name
20
24
 
@@ -45,6 +49,12 @@ class GenevaDrive::StepDefinition
45
49
  # @return [Array<String, Integer>, nil] source location of the step block [path, lineno]
46
50
  attr_reader :block_location
47
51
 
52
+ # @return [Symbol, String, Object, nil] the raw wait_for: option
53
+ attr_reader :wait_for
54
+
55
+ # @return [GenevaDrive::SignalMatcher, nil] the normalized signal matcher, if this step waits
56
+ attr_reader :signal_matcher
57
+
48
58
  # Creates a new step definition.
49
59
  #
50
60
  # @param name [String, Symbol] the step name
@@ -69,6 +79,7 @@ class GenevaDrive::StepDefinition
69
79
  @call_location = call_location
70
80
  @block_location = block_location
71
81
  @wait = options[:wait]
82
+ @wait_for = options[:wait_for]
72
83
  @job_options = options.fetch(:job_options, {})
73
84
  @skip_if_option = options[:skip_if]
74
85
  @if_option = options[:if]
@@ -83,6 +94,7 @@ class GenevaDrive::StepDefinition
83
94
  validate!
84
95
  @job_options = GenevaDrive::JobOptions.validate!(@job_options, context: "Step '#{@name}'").freeze
85
96
  @exception_policy = build_exception_policy
97
+ @signal_matcher = build_signal_matcher
86
98
  end
87
99
 
88
100
  # Returns the action symbol from the exception policy.
@@ -119,6 +131,22 @@ class GenevaDrive::StepDefinition
119
131
  false
120
132
  end
121
133
 
134
+ # Returns whether this step is a gravestone left by `removed_step`.
135
+ # Removed steps keep their name resolvable for executions scheduled before
136
+ # the step was taken out, but are never scheduled and never run.
137
+ #
138
+ # @return [Boolean] false for standard steps
139
+ def removed?
140
+ false
141
+ end
142
+
143
+ # Whether this step parks until a matching signal arrives.
144
+ #
145
+ # @return [Boolean]
146
+ def waits_for_signal?
147
+ !@signal_matcher.nil?
148
+ end
149
+
122
150
  # Executes the step callable in the context of the workflow.
123
151
  #
124
152
  # @param workflow [GenevaDrive::Workflow] the workflow instance
@@ -144,6 +172,58 @@ class GenevaDrive::StepDefinition
144
172
  validate_terminal_action_raw!
145
173
  validate_positioning!
146
174
  validate_skip_condition!
175
+ validate_wait_for!
176
+ end
177
+
178
+ # Normalizes wait_for: into a matcher. A bare name becomes a
179
+ # {GenevaDrive::SignalMatcher}; anything else already is one (or quacks
180
+ # like one).
181
+ #
182
+ # @return [Object, nil] an object responding to #matches?(signal)
183
+ def build_signal_matcher
184
+ return nil if @wait_for.nil?
185
+ return GenevaDrive::SignalMatcher.new(@wait_for) if signal_shaped_name?(@wait_for)
186
+
187
+ @wait_for
188
+ end
189
+
190
+ # Whether the value is a plain signal name.
191
+ #
192
+ # @param value [Object]
193
+ # @return [Boolean]
194
+ def signal_shaped_name?(value)
195
+ value.is_a?(Symbol) || value.is_a?(String)
196
+ end
197
+
198
+ # Whether the value means "a delay" and therefore belongs to wait:.
199
+ #
200
+ # @param value [Object]
201
+ # @return [Boolean]
202
+ def duration_shaped?(value)
203
+ DURATION_SHAPED_TYPES.any? { |type| value.is_a?(type) }
204
+ end
205
+
206
+ # Validates wait_for:, including the swap guard that catches a duration
207
+ # passed to it. wait: and wait_for: read alike and take disjoint types, so
208
+ # each direction of the mistake gets an error naming the kwarg the author
209
+ # meant (the other direction lives in validate_wait!).
210
+ #
211
+ # @raise [StepConfigurationError] if the option is invalid or swapped
212
+ def validate_wait_for!
213
+ return if @wait_for.nil?
214
+
215
+ if duration_shaped?(@wait_for)
216
+ raise GenevaDrive::StepConfigurationError,
217
+ "Step '#{@name}' has wait_for: #{@wait_for.inspect} — wait_for: takes a signal name or " \
218
+ "matcher; to delay the step, use wait:"
219
+ end
220
+
221
+ return if signal_shaped_name?(@wait_for)
222
+ return if @wait_for.respond_to?(:matches?)
223
+
224
+ raise GenevaDrive::StepConfigurationError,
225
+ "Step '#{@name}' has invalid wait_for: must be a Symbol, String, GenevaDrive::SignalMatcher, " \
226
+ "or an object responding to #matches?(signal), but was #{@wait_for.class}"
147
227
  end
148
228
 
149
229
  # Builds the ExceptionPolicy from validated raw inputs.
@@ -196,9 +276,19 @@ class GenevaDrive::StepDefinition
196
276
 
197
277
  # Validates the wait duration.
198
278
  #
199
- # @raise [StepConfigurationError] if wait is negative
279
+ # @raise [StepConfigurationError] if wait is negative or signal-shaped
200
280
  def validate_wait!
201
281
  return if @wait.nil?
282
+
283
+ # The other half of the wait:/wait_for: swap guard. Durations are numbers
284
+ # or Durations, never Strings - and a String sails through the to_i check
285
+ # below as a zero-second wait, so this arm tightens wait: for everyone.
286
+ if signal_shaped_name?(@wait) || @wait.respond_to?(:matches?)
287
+ raise GenevaDrive::StepConfigurationError,
288
+ "Step '#{@name}' has wait: #{@wait.inspect} — wait: takes a duration; to wait for a " \
289
+ "signal, use wait_for:"
290
+ end
291
+
202
292
  return if @wait.respond_to?(:to_i) && @wait.to_i >= 0
203
293
 
204
294
  raise GenevaDrive::StepConfigurationError,
@@ -20,12 +20,14 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
20
20
 
21
21
  require_relative "step_execution/metadata_accessor"
22
22
  include MetadataAccessor
23
+ include GenevaDrive::JsonColumn
23
24
 
24
25
  # Step execution states as enum with string values
25
26
  # Provides: scheduled?, in_progress?, etc. predicates
26
27
  # Provides: scheduled, in_progress, etc. scopes
27
28
  enum :state, {
28
29
  scheduled: "scheduled",
30
+ waiting: "waiting",
29
31
  in_progress: "in_progress",
30
32
  completed: "completed",
31
33
  failed: "failed",
@@ -63,6 +65,14 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
63
65
  foreign_key: :continues_from_id,
64
66
  inverse_of: :continues_from
65
67
 
68
+ # The signal this execution is processing (the "attachment pin"). Set once,
69
+ # at the gate or by dispatch, and copied to successor executions so the
70
+ # payload stays stable across a whole resumable chain.
71
+ belongs_to :signal,
72
+ class_name: "GenevaDrive::Signal",
73
+ foreign_key: :signal_id,
74
+ optional: true
75
+
66
76
  # Validations
67
77
  validates :step_name, presence: true
68
78
  validates :scheduled_for, presence: true
@@ -102,6 +112,33 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
102
112
  remove_instance_variable(:@_resumable_columns) if defined?(@_resumable_columns)
103
113
  end
104
114
 
115
+ # Lazily checks whether the signal columns (signal_id and
116
+ # waiting_since) have been migrated, mirroring resumable_columns?.
117
+ # Never hits the database at class definition time.
118
+ #
119
+ # Everything unrelated to signals keeps working without them; a step
120
+ # declaring wait_for: fails loudly with a "run the install generator"
121
+ # message instead of silently running without its event.
122
+ #
123
+ # @return [Boolean]
124
+ def signal_columns?
125
+ if defined?(@_signal_columns)
126
+ return @_signal_columns
127
+ end
128
+
129
+ @_signal_columns = table_exists? &&
130
+ column_names.include?("signal_id") &&
131
+ column_names.include?("waiting_since")
132
+ end
133
+
134
+ # Clears the cached signal column detection result. Call this in tests or
135
+ # after running migrations in-process so the next access re-checks.
136
+ #
137
+ # @return [void]
138
+ def reset_signal_columns_cache!
139
+ remove_instance_variable(:@_signal_columns) if defined?(@_signal_columns)
140
+ end
141
+
105
142
  # Serializes a cursor value using ActiveJob serializers (handles Date,
106
143
  # Time, and other types) and enforces GenevaDrive.max_cursor_size on
107
144
  # the serialized JSON. The single serialization path for cursor writes.
@@ -126,7 +163,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
126
163
  end
127
164
  end
128
165
 
129
- serialized
166
+ encode_json_column(:cursor, serialized)
130
167
  end
131
168
  end
132
169
 
@@ -214,7 +251,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
214
251
  def cursor_value
215
252
  return nil unless self.class.resumable_columns?
216
253
  return nil if cursor.blank?
217
- ActiveJob::Arguments.deserialize([cursor]).first
254
+ ActiveJob::Arguments.deserialize([self.class.decode_json_column(:cursor, cursor)]).first
218
255
  end
219
256
 
220
257
  # Sets the cursor value for resumable steps.