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.
@@ -33,6 +33,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
33
33
  results = {
34
34
  workflows_cleaned_up: 0,
35
35
  step_executions_cleaned_up: 0,
36
+ signals_cleaned_up: 0,
36
37
  stuck_in_progress_recovered: 0,
37
38
  stuck_scheduled_recovered: 0
38
39
  }
@@ -40,6 +41,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
40
41
  cleanup_completed_workflows!(results)
41
42
  recover_stuck_step_executions!(results)
42
43
  report_workflow_gauges!
44
+ report_waiting_gauges!
43
45
 
44
46
  logger.info("Completed: #{results}")
45
47
  results
@@ -89,6 +91,42 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
89
91
  end
90
92
  end
91
93
 
94
+ # Reports gauges for step executions parked waiting for a signal.
95
+ #
96
+ # Waiting indefinitely is a legitimate state, not a stuck one, so the stuck
97
+ # sweeps ignore parked rows. These gauges are what keeps a stalled
98
+ # rendezvous from being silent:
99
+ # - `geneva_drive.waiting_step_executions` - parked rows, total and per class
100
+ # - `geneva_drive.waiting_overdue` - parked longer than
101
+ # GenevaDrive.waiting_visibility_threshold, total and per class
102
+ #
103
+ # @return [void]
104
+ def report_waiting_gauges!
105
+ return unless GenevaDrive::StepExecution.signal_columns?
106
+
107
+ report_waiting_gauge!("geneva_drive.waiting_step_executions", GenevaDrive::StepExecution.waiting)
108
+
109
+ threshold = GenevaDrive.waiting_visibility_threshold
110
+ return if threshold.blank?
111
+
112
+ overdue = GenevaDrive::StepExecution.waiting.where(waiting_since: ..threshold.ago)
113
+ report_waiting_gauge!("geneva_drive.waiting_overdue", overdue)
114
+ end
115
+
116
+ # Sets one gauge per workflow class plus an untagged total for the scope.
117
+ #
118
+ # @param gauge_name [String] the Measurometer gauge name
119
+ # @param scope [ActiveRecord::Relation] a step execution scope
120
+ # @return [void]
121
+ def report_waiting_gauge!(gauge_name, scope)
122
+ counts = scope.joins(:workflow).group("#{GenevaDrive::Workflow.table_name}.type").count
123
+
124
+ Measurometer.set_gauge(gauge_name, counts.values.sum)
125
+ counts.each do |type, count|
126
+ Measurometer.set_gauge(gauge_name, count, workflow: type)
127
+ end
128
+ end
129
+
92
130
  # Cleans up completed/canceled workflows older than the configured threshold.
93
131
  # Uses efficient batched SQL DELETEs - step executions are deleted first via
94
132
  # INNER JOIN, then workflows. Loops until all eligible records are deleted.
@@ -114,7 +152,17 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
114
152
  break if deleted_count < batch_size
115
153
  end
116
154
 
117
- # Second pass: delete the workflows themselves
155
+ # Second pass: delete the signals delivered to old workflows
156
+ if GenevaDrive::Signal.table_available?
157
+ loop do
158
+ deleted_count = delete_signals_batch(cutoff_time, batch_size)
159
+ logger.info("Deleted #{deleted_count} signals")
160
+ results[:signals_cleaned_up] += deleted_count
161
+ break if deleted_count < batch_size
162
+ end
163
+ end
164
+
165
+ # Third pass: delete the workflows themselves
118
166
  loop do
119
167
  deleted_count = delete_workflows_batch(cutoff_time, batch_size)
120
168
  logger.info("Deleted #{deleted_count} workflows")
@@ -123,11 +171,43 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
123
171
  end
124
172
 
125
173
  logger.info(
126
- "Cleaned up #{results[:workflows_cleaned_up]} workflows " \
127
- "and #{results[:step_executions_cleaned_up]} step executions older than #{cutoff_time}"
174
+ "Cleaned up #{results[:workflows_cleaned_up]} workflows, " \
175
+ "#{results[:step_executions_cleaned_up]} step executions and " \
176
+ "#{results[:signals_cleaned_up]} signals older than #{cutoff_time}"
128
177
  )
129
178
  end
130
179
 
180
+ # Deletes a batch of signals belonging to old workflows.
181
+ #
182
+ # @param cutoff_time [Time] workflows transitioned before this time are eligible
183
+ # @param batch_size [Integer] maximum records to delete in this batch
184
+ # @return [Integer] number of records deleted
185
+ def delete_signals_batch(cutoff_time, batch_size)
186
+ GenevaDrive::Signal.connection_pool.with_connection do |conn|
187
+ signals_table = conn.quote_table_name(GenevaDrive::Signal.table_name)
188
+ workflows_table = conn.quote_table_name(GenevaDrive::Workflow.table_name)
189
+ limit = batch_size.to_i
190
+
191
+ # MySQL doesn't support LIMIT in subqueries with IN, so we wrap it in another SELECT
192
+ # Also, MySQL doesn't handle bind parameters for LIMIT properly, so we interpolate directly
193
+ sql = <<~SQL.squish
194
+ DELETE FROM #{signals_table}
195
+ WHERE id IN (
196
+ SELECT id FROM (
197
+ SELECT s.id
198
+ FROM #{signals_table} s
199
+ INNER JOIN #{workflows_table} w ON w.id = s.workflow_id
200
+ WHERE w.state IN ('finished', 'canceled')
201
+ AND w.transitioned_at < ?
202
+ LIMIT #{limit}
203
+ ) AS batch_to_delete
204
+ )
205
+ SQL
206
+
207
+ conn.delete(GenevaDrive::Signal.sanitize_sql([sql, cutoff_time]))
208
+ end
209
+ end
210
+
131
211
  # Deletes a batch of step executions belonging to old workflows.
132
212
  #
133
213
  # @param cutoff_time [Time] workflows transitioned before this time are eligible
@@ -0,0 +1,243 @@
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
+ self.table_name = "geneva_drive_signals"
29
+
30
+ # Signal lifecycle states as enum with string values.
31
+ #
32
+ # Neither predicates nor scopes are generated: the +claimed+ and +consumed+
33
+ # counter columns own those names, and a `Signal.claimed` scope reading the
34
+ # state while `signal.claimed` reads the counter would be a trap. The state
35
+ # is queried explicitly (`where(state: ...)`) and read off the column; the
36
+ # public predicates are {#claimed?} and {#consumed?} over the counters.
37
+ enum :state, {
38
+ pending: "pending",
39
+ claimed: "claimed",
40
+ consumed: "consumed"
41
+ }, instance_methods: false, scopes: false
42
+
43
+ # Step execution states that mean "this chain has not come to rest yet".
44
+ ACTIVE_EXECUTION_STATES = %w[waiting scheduled in_progress].freeze
45
+
46
+ # Outcomes that resolve an attached chain cleanly, so the signal can be
47
+ # considered handled by it.
48
+ CLEAN_OUTCOMES = %w[success skipped].freeze
49
+
50
+ belongs_to :workflow,
51
+ class_name: "GenevaDrive::Workflow",
52
+ foreign_key: :workflow_id,
53
+ inverse_of: :signals
54
+
55
+ validates :name, presence: true
56
+
57
+ # Signals that may still be attached to a step execution, oldest first.
58
+ # The eligibility rule for both the gate and dispatch: anything that
59
+ # matches and has not been consumed, FIFO.
60
+ scope :attachable, -> { where.not(state: "consumed").order(created_at: :asc, id: :asc) }
61
+
62
+ class << self
63
+ # Lazily checks whether the signals table has been migrated. Never hits
64
+ # the database at class definition time - only on the first runtime call.
65
+ #
66
+ # Deployments usually ship the gem update before running migrations, so
67
+ # everything except actually sending or waiting for a signal must keep
68
+ # working when the table is absent.
69
+ #
70
+ # @return [Boolean]
71
+ def table_available?
72
+ if defined?(@_table_available)
73
+ return @_table_available
74
+ end
75
+
76
+ @_table_available = table_exists?
77
+ end
78
+
79
+ # Clears the cached table detection result. Call this in tests or after
80
+ # running migrations in-process so the next access re-checks.
81
+ #
82
+ # @return [void]
83
+ def reset_table_available_cache!
84
+ remove_instance_variable(:@_table_available) if defined?(@_table_available)
85
+ end
86
+
87
+ # Serializes a payload using ActiveJob serializers (handles Date, Time,
88
+ # and other types) and enforces GenevaDrive.max_signal_payload_size on
89
+ # the serialized JSON. The single serialization path for payload writes.
90
+ #
91
+ # @param value [Object, nil] the payload
92
+ # @return [Object, nil] the serialized payload
93
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
94
+ def serialize_payload(value)
95
+ return nil if value.nil?
96
+
97
+ serialized = ActiveJob::Arguments.serialize([value]).first
98
+
99
+ limit = GenevaDrive.max_signal_payload_size
100
+ if limit
101
+ bytesize = JSON.generate(serialized).bytesize
102
+ if bytesize > limit
103
+ raise GenevaDrive::SignalPayloadTooLargeError,
104
+ "Serialized signal payload is #{bytesize} bytes, exceeding " \
105
+ "GenevaDrive.max_signal_payload_size (#{limit} bytes). A payload describes the " \
106
+ "event, it is not a place to ship the data the step should be working on - " \
107
+ "write what matters onto the hero instead. Set " \
108
+ "GenevaDrive.max_signal_payload_size to nil to disable this check."
109
+ end
110
+ end
111
+
112
+ serialized
113
+ end
114
+ end
115
+
116
+ # Returns the deserialized payload. Hashes come back with indifferent
117
+ # access, because webhook senders produce string keys while Ruby senders
118
+ # produce symbols and matchers should not have to know which.
119
+ #
120
+ # @return [Object, nil] the payload
121
+ def payload
122
+ raw = self[:payload]
123
+ return nil if raw.nil?
124
+
125
+ value = ActiveJob::Arguments.deserialize([raw]).first
126
+ value.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(value) : value
127
+ end
128
+
129
+ # Sets the payload, serializing it through ActiveJob serializers.
130
+ #
131
+ # @param value [Object, nil] the payload
132
+ # @raise [SignalPayloadTooLargeError] if the serialized JSON exceeds the limit
133
+ # @return [void]
134
+ def payload=(value)
135
+ self[:payload] = self.class.serialize_payload(value)
136
+ end
137
+
138
+ # Whether this instance was returned from a deduplicated delivery - that
139
+ # is, {GenevaDrive::Workflow#signal!} found an existing row with the same
140
+ # idempotency key instead of inserting a new one. Not a column: it is a
141
+ # fact about this call, not about the record.
142
+ #
143
+ # @return [Boolean]
144
+ def duplicate_delivery?
145
+ !!@duplicate_delivery
146
+ end
147
+
148
+ # Flags this instance as the result of a deduplicated delivery.
149
+ #
150
+ # @return [void]
151
+ # @api private
152
+ def duplicate_delivery!
153
+ @duplicate_delivery = true
154
+ end
155
+
156
+ # Whether at least one execution has ever attached to this signal. Reads
157
+ # the counter, not the lifecycle state, so it stays true after the signal
158
+ # has been consumed - "this event was picked up" rather than "is being
159
+ # handled right now".
160
+ #
161
+ # @return [Boolean]
162
+ def claimed? = claimed > 0
163
+
164
+ # Whether at least one attached chain has resolved cleanly. Reads the
165
+ # counter, so for a one-to-many dispatch it goes true with the first
166
+ # resolved branch, while the state only flips once the last one resolves.
167
+ #
168
+ # @return [Boolean]
169
+ def consumed? = consumed > 0
170
+
171
+ # Records one execution attaching to this signal, bumping the claimed
172
+ # count. The pending -> claimed state flip and claimed_at happen on the
173
+ # first claim only, so the timestamp keeps meaning "when this event started
174
+ # being handled". A retry attaching to a still-claimed signal counts as a
175
+ # new claim; a successor continuing the same chain does not (it carries the
176
+ # pin over rather than acquiring it).
177
+ #
178
+ # @return [void]
179
+ # @api private
180
+ def claim!
181
+ attrs = {claimed: claimed + 1}
182
+ if state == "pending"
183
+ attrs[:state] = "claimed"
184
+ attrs[:claimed_at] = Time.current
185
+ end
186
+ update!(attrs)
187
+ end
188
+
189
+ # Records one attached execution chain resolving cleanly, bumping the
190
+ # consumed count. The state flips to consumed - closing the signal to new
191
+ # attachments - only once every attached chain has resolved, which for a
192
+ # single claimant is the same moment.
193
+ #
194
+ # @param resolved_step_names [Array<String, Symbol>] steps whose chains are
195
+ # resolved by decree rather than by outcome - a skip settles the chain it
196
+ # skips past even when that chain's last execution failed
197
+ # @return [void]
198
+ # @api private
199
+ def record_consumption!(resolved_step_names: [])
200
+ attrs = {consumed: consumed + 1}
201
+ if state == "claimed" && fully_resolved?(resolved_step_names: resolved_step_names)
202
+ attrs[:state] = "consumed"
203
+ attrs[:consumed_at] = Time.current
204
+ end
205
+ update!(attrs)
206
+ end
207
+
208
+ # Whether every execution chain attached to this signal has come to a
209
+ # clean stop. A chain that is still running (or parked), and one whose
210
+ # latest attached execution failed or was canceled, both keep the state at
211
+ # claimed - the failed chain's retry re-attaches to it and reads the same
212
+ # payload.
213
+ #
214
+ # Only the most recent attached execution per step counts: the earlier ones
215
+ # in a chain end with `continued` or `reattempted` precisely because they
216
+ # handed the work to the next one.
217
+ #
218
+ # @param resolved_step_names [Array<String, Symbol>] steps to treat as
219
+ # resolved whatever their executions say. Skipping a step is a decision
220
+ # that its chain is over, including when that chain ended in a failure -
221
+ # no future execution of it is coming to resolve it cleanly.
222
+ # @return [Boolean]
223
+ # @api private
224
+ def fully_resolved?(resolved_step_names: [])
225
+ settled = Array(resolved_step_names).map(&:to_s)
226
+ attached = step_executions.order(created_at: :asc, id: :asc).to_a
227
+ return true if attached.empty?
228
+
229
+ unsettled = attached.reject { |execution| settled.include?(execution.step_name) }
230
+ return false if unsettled.any? { |execution| ACTIVE_EXECUTION_STATES.include?(execution.state) }
231
+
232
+ unsettled.group_by(&:step_name).all? do |_step_name, chain|
233
+ CLEAN_OUTCOMES.include?(chain.last.outcome)
234
+ end
235
+ end
236
+
237
+ # Step executions pinned to this signal.
238
+ #
239
+ # @return [ActiveRecord::Relation<GenevaDrive::StepExecution>]
240
+ def step_executions
241
+ GenevaDrive::StepExecution.where(signal_id: id)
242
+ end
243
+ 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
@@ -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,13 @@ class GenevaDrive::StepDefinition
119
131
  false
120
132
  end
121
133
 
134
+ # Whether this step parks until a matching signal arrives.
135
+ #
136
+ # @return [Boolean]
137
+ def waits_for_signal?
138
+ !@signal_matcher.nil?
139
+ end
140
+
122
141
  # Executes the step callable in the context of the workflow.
123
142
  #
124
143
  # @param workflow [GenevaDrive::Workflow] the workflow instance
@@ -144,6 +163,58 @@ class GenevaDrive::StepDefinition
144
163
  validate_terminal_action_raw!
145
164
  validate_positioning!
146
165
  validate_skip_condition!
166
+ validate_wait_for!
167
+ end
168
+
169
+ # Normalizes wait_for: into a matcher. A bare name becomes a
170
+ # {GenevaDrive::SignalMatcher}; anything else already is one (or quacks
171
+ # like one).
172
+ #
173
+ # @return [Object, nil] an object responding to #matches?(signal)
174
+ def build_signal_matcher
175
+ return nil if @wait_for.nil?
176
+ return GenevaDrive::SignalMatcher.new(@wait_for) if signal_shaped_name?(@wait_for)
177
+
178
+ @wait_for
179
+ end
180
+
181
+ # Whether the value is a plain signal name.
182
+ #
183
+ # @param value [Object]
184
+ # @return [Boolean]
185
+ def signal_shaped_name?(value)
186
+ value.is_a?(Symbol) || value.is_a?(String)
187
+ end
188
+
189
+ # Whether the value means "a delay" and therefore belongs to wait:.
190
+ #
191
+ # @param value [Object]
192
+ # @return [Boolean]
193
+ def duration_shaped?(value)
194
+ DURATION_SHAPED_TYPES.any? { |type| value.is_a?(type) }
195
+ end
196
+
197
+ # Validates wait_for:, including the swap guard that catches a duration
198
+ # passed to it. wait: and wait_for: read alike and take disjoint types, so
199
+ # each direction of the mistake gets an error naming the kwarg the author
200
+ # meant (the other direction lives in validate_wait!).
201
+ #
202
+ # @raise [StepConfigurationError] if the option is invalid or swapped
203
+ def validate_wait_for!
204
+ return if @wait_for.nil?
205
+
206
+ if duration_shaped?(@wait_for)
207
+ raise GenevaDrive::StepConfigurationError,
208
+ "Step '#{@name}' has wait_for: #{@wait_for.inspect} — wait_for: takes a signal name or " \
209
+ "matcher; to delay the step, use wait:"
210
+ end
211
+
212
+ return if signal_shaped_name?(@wait_for)
213
+ return if @wait_for.respond_to?(:matches?)
214
+
215
+ raise GenevaDrive::StepConfigurationError,
216
+ "Step '#{@name}' has invalid wait_for: must be a Symbol, String, GenevaDrive::SignalMatcher, " \
217
+ "or an object responding to #matches?(signal), but was #{@wait_for.class}"
147
218
  end
148
219
 
149
220
  # Builds the ExceptionPolicy from validated raw inputs.
@@ -196,9 +267,19 @@ class GenevaDrive::StepDefinition
196
267
 
197
268
  # Validates the wait duration.
198
269
  #
199
- # @raise [StepConfigurationError] if wait is negative
270
+ # @raise [StepConfigurationError] if wait is negative or signal-shaped
200
271
  def validate_wait!
201
272
  return if @wait.nil?
273
+
274
+ # The other half of the wait:/wait_for: swap guard. Durations are numbers
275
+ # or Durations, never Strings - and a String sails through the to_i check
276
+ # below as a zero-second wait, so this arm tightens wait: for everyone.
277
+ if signal_shaped_name?(@wait) || @wait.respond_to?(:matches?)
278
+ raise GenevaDrive::StepConfigurationError,
279
+ "Step '#{@name}' has wait: #{@wait.inspect} — wait: takes a duration; to wait for a " \
280
+ "signal, use wait_for:"
281
+ end
282
+
202
283
  return if @wait.respond_to?(:to_i) && @wait.to_i >= 0
203
284
 
204
285
  raise GenevaDrive::StepConfigurationError,
@@ -26,6 +26,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
26
26
  # Provides: scheduled, in_progress, etc. scopes
27
27
  enum :state, {
28
28
  scheduled: "scheduled",
29
+ waiting: "waiting",
29
30
  in_progress: "in_progress",
30
31
  completed: "completed",
31
32
  failed: "failed",
@@ -63,6 +64,14 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
63
64
  foreign_key: :continues_from_id,
64
65
  inverse_of: :continues_from
65
66
 
67
+ # The signal this execution is processing (the "attachment pin"). Set once,
68
+ # at the gate or by dispatch, and copied to successor executions so the
69
+ # payload stays stable across a whole resumable chain.
70
+ belongs_to :signal,
71
+ class_name: "GenevaDrive::Signal",
72
+ foreign_key: :signal_id,
73
+ optional: true
74
+
66
75
  # Validations
67
76
  validates :step_name, presence: true
68
77
  validates :scheduled_for, presence: true
@@ -102,6 +111,33 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
102
111
  remove_instance_variable(:@_resumable_columns) if defined?(@_resumable_columns)
103
112
  end
104
113
 
114
+ # Lazily checks whether the signal columns (signal_id and
115
+ # waiting_since) have been migrated, mirroring resumable_columns?.
116
+ # Never hits the database at class definition time.
117
+ #
118
+ # Everything unrelated to signals keeps working without them; a step
119
+ # declaring wait_for: fails loudly with a "run the install generator"
120
+ # message instead of silently running without its event.
121
+ #
122
+ # @return [Boolean]
123
+ def signal_columns?
124
+ if defined?(@_signal_columns)
125
+ return @_signal_columns
126
+ end
127
+
128
+ @_signal_columns = table_exists? &&
129
+ column_names.include?("signal_id") &&
130
+ column_names.include?("waiting_since")
131
+ end
132
+
133
+ # Clears the cached signal column detection result. Call this in tests or
134
+ # after running migrations in-process so the next access re-checks.
135
+ #
136
+ # @return [void]
137
+ def reset_signal_columns_cache!
138
+ remove_instance_variable(:@_signal_columns) if defined?(@_signal_columns)
139
+ end
140
+
105
141
  # Serializes a cursor value using ActiveJob serializers (handles Date,
106
142
  # Time, and other types) and enforces GenevaDrive.max_cursor_size on
107
143
  # the serialized JSON. The single serialization path for cursor writes.