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
|
@@ -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
|
|
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
|
-
"
|
|
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.
|