geneva_drive 0.5.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 +14 -1
- data/MANUAL.md +449 -11
- data/README.md +5 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +352 -26
- data/lib/geneva_drive/flow_control.rb +87 -7
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +96 -2
- data/lib/geneva_drive/step_execution.rb +141 -2
- data/lib/geneva_drive/test_helpers.rb +212 -6
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +559 -43
- data/lib/geneva_drive.rb +37 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +4 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -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
- data/test/workflow/workflow_test.rb +72 -1
- metadata +25 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Metadata about a resumable step definition in a workflow.
|
|
4
|
+
# Extends StepDefinition with cursor-based iteration support.
|
|
5
|
+
#
|
|
6
|
+
# Resumable steps can iterate over large collections with checkpointing,
|
|
7
|
+
# allowing them to be suspended and resumed across job executions.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class GenevaDrive::ResumableStepDefinition < GenevaDrive::StepDefinition
|
|
11
|
+
# @return [Proc] the block to execute (receives IterableStep)
|
|
12
|
+
attr_reader :block
|
|
13
|
+
|
|
14
|
+
# @return [Integer, nil] maximum number of iterations before auto-suspend
|
|
15
|
+
attr_reader :max_iterations
|
|
16
|
+
|
|
17
|
+
# @return [ActiveSupport::Duration, nil] maximum runtime before auto-suspend
|
|
18
|
+
attr_reader :max_runtime
|
|
19
|
+
|
|
20
|
+
# Creates a new resumable step definition.
|
|
21
|
+
#
|
|
22
|
+
# @param name [String, Symbol] the step name
|
|
23
|
+
# @param max_iterations [Integer, nil] maximum iterations before auto-suspend
|
|
24
|
+
# @param max_runtime [ActiveSupport::Duration, nil] maximum runtime before auto-suspend
|
|
25
|
+
# @param options [Hash] additional options (same as StepDefinition)
|
|
26
|
+
# @param block [Proc] the block to execute (required, receives IterableStep)
|
|
27
|
+
# @raise [StepConfigurationError] if configuration is invalid
|
|
28
|
+
def initialize(name:, max_iterations: nil, max_runtime: nil, **options, &block)
|
|
29
|
+
@block = block
|
|
30
|
+
@max_iterations = max_iterations
|
|
31
|
+
@max_runtime = max_runtime
|
|
32
|
+
|
|
33
|
+
# Pass a dummy callable to parent since we use block instead
|
|
34
|
+
super(name: name, callable: block, **options)
|
|
35
|
+
|
|
36
|
+
validate_resumable!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns true to indicate this step supports resumable iteration.
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def resumable?
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Executes the step block with an IterableStep in the workflow context.
|
|
47
|
+
#
|
|
48
|
+
# @param workflow [GenevaDrive::Workflow] the workflow instance
|
|
49
|
+
# @param iterable_step [GenevaDrive::IterableStep] the iteration context
|
|
50
|
+
# @return [Object] the result of the step execution
|
|
51
|
+
def execute_in_context(workflow, iterable_step)
|
|
52
|
+
workflow.instance_exec(iterable_step, &@block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Validates resumable-specific configuration.
|
|
58
|
+
#
|
|
59
|
+
# @raise [StepConfigurationError] if configuration is invalid
|
|
60
|
+
def validate_resumable!
|
|
61
|
+
validate_block!
|
|
62
|
+
validate_max_iterations!
|
|
63
|
+
validate_max_runtime!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validates that the block is present.
|
|
67
|
+
#
|
|
68
|
+
# @raise [StepConfigurationError] if block is missing
|
|
69
|
+
def validate_block!
|
|
70
|
+
return if @block.is_a?(Proc)
|
|
71
|
+
|
|
72
|
+
raise GenevaDrive::StepConfigurationError,
|
|
73
|
+
"Resumable step '#{@name}' requires a block"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Validates the max_iterations value.
|
|
77
|
+
#
|
|
78
|
+
# @raise [StepConfigurationError] if max_iterations is invalid
|
|
79
|
+
def validate_max_iterations!
|
|
80
|
+
return if @max_iterations.nil?
|
|
81
|
+
return if @max_iterations.is_a?(Integer) && @max_iterations.positive?
|
|
82
|
+
|
|
83
|
+
raise GenevaDrive::StepConfigurationError,
|
|
84
|
+
"Resumable step '#{@name}' has invalid max_iterations: must be a positive integer"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Validates the max_runtime value.
|
|
88
|
+
#
|
|
89
|
+
# @raise [StepConfigurationError] if max_runtime is invalid
|
|
90
|
+
def validate_max_runtime!
|
|
91
|
+
return if @max_runtime.nil?
|
|
92
|
+
return if @max_runtime.respond_to?(:to_i) && @max_runtime.to_i.positive?
|
|
93
|
+
|
|
94
|
+
raise GenevaDrive::StepConfigurationError,
|
|
95
|
+
"Resumable step '#{@name}' has invalid max_runtime: must be a positive duration"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Metadata about a step definition in a workflow.
|
|
4
4
|
# Holds the step name, callable (block or method name), wait time,
|
|
5
|
-
# skip conditions, and exception handling configuration.
|
|
5
|
+
# job options, skip conditions, and exception handling configuration.
|
|
6
6
|
#
|
|
7
7
|
# @api private
|
|
8
8
|
class GenevaDrive::StepDefinition
|
|
@@ -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
|
|
|
@@ -24,6 +28,9 @@ class GenevaDrive::StepDefinition
|
|
|
24
28
|
# @return [ActiveSupport::Duration, nil] wait time before executing this step
|
|
25
29
|
attr_reader :wait
|
|
26
30
|
|
|
31
|
+
# @return [Hash] frozen, symbol-keyed Active Job options for this step
|
|
32
|
+
attr_reader :job_options
|
|
33
|
+
|
|
27
34
|
# @return [Proc, Symbol, Boolean, nil] condition for skipping this step
|
|
28
35
|
attr_reader :skip_condition
|
|
29
36
|
|
|
@@ -42,12 +49,19 @@ class GenevaDrive::StepDefinition
|
|
|
42
49
|
# @return [Array<String, Integer>, nil] source location of the step block [path, lineno]
|
|
43
50
|
attr_reader :block_location
|
|
44
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
|
+
|
|
45
58
|
# Creates a new step definition.
|
|
46
59
|
#
|
|
47
60
|
# @param name [String, Symbol] the step name
|
|
48
61
|
# @param callable [Proc, Symbol, nil] the code to execute (block or method name)
|
|
49
62
|
# @param options [Hash] additional options
|
|
50
63
|
# @option options [ActiveSupport::Duration, nil] :wait delay before execution
|
|
64
|
+
# @option options [Hash] :job_options options passed to Active Job's set method
|
|
51
65
|
# @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
|
|
52
66
|
# @option options [Proc, Symbol, Boolean, nil] :if condition for running (inverse of skip_if)
|
|
53
67
|
# @option options [Symbol, GenevaDrive::ExceptionPolicy, Proc, Array<GenevaDrive::ExceptionPolicy>] :on_exception
|
|
@@ -65,6 +79,8 @@ class GenevaDrive::StepDefinition
|
|
|
65
79
|
@call_location = call_location
|
|
66
80
|
@block_location = block_location
|
|
67
81
|
@wait = options[:wait]
|
|
82
|
+
@wait_for = options[:wait_for]
|
|
83
|
+
@job_options = options.fetch(:job_options, {})
|
|
68
84
|
@skip_if_option = options[:skip_if]
|
|
69
85
|
@if_option = options[:if]
|
|
70
86
|
@skip_condition = @skip_if_option || @if_option
|
|
@@ -76,7 +92,9 @@ class GenevaDrive::StepDefinition
|
|
|
76
92
|
@after_step = options[:after_step]&.to_s
|
|
77
93
|
|
|
78
94
|
validate!
|
|
95
|
+
@job_options = GenevaDrive::JobOptions.validate!(@job_options, context: "Step '#{@name}'").freeze
|
|
79
96
|
@exception_policy = build_exception_policy
|
|
97
|
+
@signal_matcher = build_signal_matcher
|
|
80
98
|
end
|
|
81
99
|
|
|
82
100
|
# Returns the action symbol from the exception policy.
|
|
@@ -106,6 +124,20 @@ class GenevaDrive::StepDefinition
|
|
|
106
124
|
evaluate_condition(@skip_condition, workflow)
|
|
107
125
|
end
|
|
108
126
|
|
|
127
|
+
# Returns whether this is a resumable step.
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean] false for standard steps
|
|
130
|
+
def resumable?
|
|
131
|
+
false
|
|
132
|
+
end
|
|
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
|
+
|
|
109
141
|
# Executes the step callable in the context of the workflow.
|
|
110
142
|
#
|
|
111
143
|
# @param workflow [GenevaDrive::Workflow] the workflow instance
|
|
@@ -131,6 +163,58 @@ class GenevaDrive::StepDefinition
|
|
|
131
163
|
validate_terminal_action_raw!
|
|
132
164
|
validate_positioning!
|
|
133
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}"
|
|
134
218
|
end
|
|
135
219
|
|
|
136
220
|
# Builds the ExceptionPolicy from validated raw inputs.
|
|
@@ -183,9 +267,19 @@ class GenevaDrive::StepDefinition
|
|
|
183
267
|
|
|
184
268
|
# Validates the wait duration.
|
|
185
269
|
#
|
|
186
|
-
# @raise [StepConfigurationError] if wait is negative
|
|
270
|
+
# @raise [StepConfigurationError] if wait is negative or signal-shaped
|
|
187
271
|
def validate_wait!
|
|
188
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
|
+
|
|
189
283
|
return if @wait.respond_to?(:to_i) && @wait.to_i >= 0
|
|
190
284
|
|
|
191
285
|
raise GenevaDrive::StepConfigurationError,
|