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
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
class GenevaDrive::Workflow < ActiveRecord::Base
|
|
23
23
|
self.table_name = "geneva_drive_workflows"
|
|
24
24
|
|
|
25
|
+
require_relative "workflow/metadata_accessor"
|
|
26
|
+
include MetadataAccessor
|
|
27
|
+
|
|
25
28
|
# Workflow states as enum with string values
|
|
26
29
|
# Provides: ready?, performing?, etc. predicates
|
|
27
30
|
# Provides: ready, performing, etc. scopes
|
|
@@ -40,6 +43,11 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
40
43
|
foreign_key: :workflow_id,
|
|
41
44
|
inverse_of: :workflow,
|
|
42
45
|
dependent: :delete_all
|
|
46
|
+
has_many :signals,
|
|
47
|
+
class_name: "GenevaDrive::Signal",
|
|
48
|
+
foreign_key: :workflow_id,
|
|
49
|
+
inverse_of: :workflow,
|
|
50
|
+
dependent: :delete_all
|
|
43
51
|
|
|
44
52
|
# Class-inheritable attributes for DSL
|
|
45
53
|
class_attribute :_step_definitions, instance_writer: false, default: []
|
|
@@ -68,6 +76,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
68
76
|
# @param name [String, Symbol, nil] the step name (auto-generated if nil)
|
|
69
77
|
# @param options [Hash] step options
|
|
70
78
|
# @option options [ActiveSupport::Duration, nil] :wait delay before execution
|
|
79
|
+
# @option options [Hash] :job_options options passed to Active Job's set method
|
|
71
80
|
# @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
|
|
72
81
|
# @option options [Symbol, GenevaDrive::ExceptionPolicy, Proc, Array<GenevaDrive::ExceptionPolicy>] :on_exception
|
|
73
82
|
# exception handling policy. Accepts:
|
|
@@ -119,24 +128,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
119
128
|
call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
|
|
120
129
|
block_location = block&.source_location
|
|
121
130
|
|
|
122
|
-
|
|
123
|
-
if _step_definitions.equal?(superclass._step_definitions)
|
|
124
|
-
self._step_definitions = _step_definitions.dup
|
|
125
|
-
end
|
|
126
|
-
# Invalidate cached step collection since we're adding a step
|
|
127
|
-
@steps = nil
|
|
128
|
-
|
|
129
|
-
step_name = (name || generate_step_name).to_s
|
|
130
|
-
|
|
131
|
-
# Check for duplicate step names
|
|
132
|
-
if _step_definitions.any? { |s| s.name == step_name }
|
|
133
|
-
raise GenevaDrive::StepConfigurationError,
|
|
134
|
-
"Step '#{step_name}' is already defined in #{self.name}"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Validate positioning references exist
|
|
138
|
-
validate_step_positioning_reference!(step_name, options[:before_step], :before_step)
|
|
139
|
-
validate_step_positioning_reference!(step_name, options[:after_step], :after_step)
|
|
131
|
+
step_name = prepare_step_registration!(name, options)
|
|
140
132
|
|
|
141
133
|
step_def = GenevaDrive::StepDefinition.new(
|
|
142
134
|
name: step_name,
|
|
@@ -151,6 +143,69 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
151
143
|
step_def
|
|
152
144
|
end
|
|
153
145
|
|
|
146
|
+
# Defines a resumable step that can iterate over large collections.
|
|
147
|
+
# The block receives an IterableStep object for cursor-based iteration
|
|
148
|
+
# that survives job restarts.
|
|
149
|
+
#
|
|
150
|
+
# @param name [String, Symbol, nil] the step name (auto-generated if nil)
|
|
151
|
+
# @param options [Hash] step options
|
|
152
|
+
# @option options [Integer, nil] :max_iterations interrupt after N iterations
|
|
153
|
+
# @option options [ActiveSupport::Duration, nil] :max_runtime interrupt after duration
|
|
154
|
+
# @option options [ActiveSupport::Duration, nil] :wait delay before execution
|
|
155
|
+
# @option options [Proc, Symbol, Boolean, nil] :skip_if condition for skipping
|
|
156
|
+
# @option options [Symbol] :on_exception exception handler (:pause!, :cancel!, :reattempt!, :skip!)
|
|
157
|
+
# @option options [String, Symbol, nil] :before_step position before this step
|
|
158
|
+
# @option options [String, Symbol, nil] :after_step position after this step
|
|
159
|
+
# @yield [iter] the step implementation receiving an IterableStep object
|
|
160
|
+
# @yieldparam iter [GenevaDrive::IterableStep] cursor management object
|
|
161
|
+
# @return [void]
|
|
162
|
+
#
|
|
163
|
+
# @example Iterate over records with automatic checkpointing
|
|
164
|
+
# resumable_step :process_users do |iter|
|
|
165
|
+
# iter.iterate_over_records(hero.users) do |user|
|
|
166
|
+
# process(user)
|
|
167
|
+
# end
|
|
168
|
+
# end
|
|
169
|
+
#
|
|
170
|
+
# @example Manual cursor control
|
|
171
|
+
# resumable_step :sync_pages do |iter|
|
|
172
|
+
# page = iter.cursor || 1
|
|
173
|
+
# loop do
|
|
174
|
+
# response = Api.fetch(page: page)
|
|
175
|
+
# break if response.empty?
|
|
176
|
+
# response.each { |item| process(item) }
|
|
177
|
+
# page += 1
|
|
178
|
+
# iter.set!(page)
|
|
179
|
+
# end
|
|
180
|
+
# end
|
|
181
|
+
#
|
|
182
|
+
# @example With iteration limits
|
|
183
|
+
# resumable_step :bulk_import, max_iterations: 10_000 do |iter|
|
|
184
|
+
# iter.iterate_over_records(records) { |r| import(r) }
|
|
185
|
+
# end
|
|
186
|
+
def resumable_step(name = nil, **options, &block)
|
|
187
|
+
raise ArgumentError, "resumable_step requires a block" unless block_given?
|
|
188
|
+
|
|
189
|
+
# Capture source locations before any other operations
|
|
190
|
+
caller_loc = caller_locations(1, 1).first
|
|
191
|
+
call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
|
|
192
|
+
block_location = block.source_location
|
|
193
|
+
|
|
194
|
+
step_name = prepare_step_registration!(name, options)
|
|
195
|
+
|
|
196
|
+
step_def = GenevaDrive::ResumableStepDefinition.new(
|
|
197
|
+
name: step_name,
|
|
198
|
+
call_location: call_location,
|
|
199
|
+
block_location: block_location,
|
|
200
|
+
**options,
|
|
201
|
+
&block
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
_step_definitions << step_def
|
|
205
|
+
|
|
206
|
+
step_def
|
|
207
|
+
end
|
|
208
|
+
|
|
154
209
|
# Defines a blanket cancellation condition for the workflow.
|
|
155
210
|
# Checked before every step execution.
|
|
156
211
|
#
|
|
@@ -180,8 +235,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
180
235
|
# @example Set queue for workflow steps
|
|
181
236
|
# set_step_job_options queue: :workflows, priority: 10
|
|
182
237
|
def set_step_job_options(**options)
|
|
238
|
+
validated = GenevaDrive::JobOptions.validate!(options, context: "#{name || "Workflow"}.set_step_job_options")
|
|
183
239
|
# Merge with parent's options
|
|
184
|
-
self._step_job_options = _step_job_options.merge(
|
|
240
|
+
self._step_job_options = _step_job_options.merge(validated)
|
|
185
241
|
end
|
|
186
242
|
|
|
187
243
|
# Allows the workflow to continue even if the hero is deleted.
|
|
@@ -205,21 +261,25 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
205
261
|
# Policies are checked when a step raises an exception and the step itself
|
|
206
262
|
# does not have an explicit `on_exception:` override.
|
|
207
263
|
#
|
|
208
|
-
# @overload on_exception(action, *exception_matchers, wait: nil, max_reattempts: nil)
|
|
264
|
+
# @overload on_exception(action, *exception_matchers, wait: nil, max_reattempts: nil, report: :always)
|
|
209
265
|
# Declarative mode — specify an action symbol.
|
|
210
266
|
# @param action [Symbol] :pause!, :cancel!, :reattempt!, or :skip!
|
|
211
267
|
# @param exception_matchers [Array<Class>] optional exception classes to match
|
|
212
268
|
# @param wait [ActiveSupport::Duration, nil] wait before reattempt
|
|
213
269
|
# @param max_reattempts [Integer, nil] max consecutive reattempts
|
|
270
|
+
# @param report [Symbol] when to report the exception to +Rails.error.report+
|
|
271
|
+
# (+:always+, +:never+, or +:terminal_only+). See {ExceptionPolicy} for details.
|
|
214
272
|
#
|
|
215
|
-
# @overload on_exception(*exception_matchers, action:, wait: nil, max_reattempts: nil)
|
|
273
|
+
# @overload on_exception(*exception_matchers, action:, wait: nil, max_reattempts: nil, report: :always)
|
|
216
274
|
# Declarative mode with exception classes as leading args and action as keyword.
|
|
217
275
|
# @param exception_matchers [Array<Class>] exception classes to match
|
|
218
276
|
# @param action [Symbol] :pause!, :cancel!, :reattempt!, or :skip!
|
|
277
|
+
# @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
|
|
219
278
|
#
|
|
220
|
-
# @overload on_exception(*exception_matchers, &block)
|
|
279
|
+
# @overload on_exception(*exception_matchers, report: :always, &block)
|
|
221
280
|
# Imperative mode — block receives exception, runs in workflow context.
|
|
222
281
|
# @param exception_matchers [Array<Class>] optional exception classes to match
|
|
282
|
+
# @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
|
|
223
283
|
# @yield [error] the exception that was raised
|
|
224
284
|
#
|
|
225
285
|
# @example Blanket default for all exceptions
|
|
@@ -233,7 +293,15 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
233
293
|
# on_exception RateLimitError do |error|
|
|
234
294
|
# reattempt! wait: error.retry_after.seconds
|
|
235
295
|
# end
|
|
236
|
-
|
|
296
|
+
#
|
|
297
|
+
# @example Suppress reporting for expected rate limits
|
|
298
|
+
# on_exception RateLimitError, report: :never do |error|
|
|
299
|
+
# reattempt! wait: error.retry_after.seconds
|
|
300
|
+
# end
|
|
301
|
+
#
|
|
302
|
+
# @example Report only when reattempts are exhausted
|
|
303
|
+
# on_exception Timeout::Error, action: :reattempt!, max_reattempts: 5, report: :terminal_only
|
|
304
|
+
def on_exception(*args, action: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always, &block)
|
|
237
305
|
# Separate exception classes from a leading action symbol
|
|
238
306
|
if args.first.is_a?(Symbol)
|
|
239
307
|
raise ArgumentError, "Cannot pass both a positional action and action: keyword" if action
|
|
@@ -258,10 +326,10 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
258
326
|
end
|
|
259
327
|
|
|
260
328
|
policy = if block
|
|
261
|
-
GenevaDrive::ExceptionPolicy.new(&block)
|
|
329
|
+
GenevaDrive::ExceptionPolicy.new(report: report, &block)
|
|
262
330
|
else
|
|
263
331
|
raise ArgumentError, "Either an action or a block is required" unless action
|
|
264
|
-
GenevaDrive::ExceptionPolicy.new(action, wait: wait, max_reattempts: max_reattempts, terminal_action: terminal_action)
|
|
332
|
+
GenevaDrive::ExceptionPolicy.new(action, wait: wait, max_reattempts: max_reattempts, terminal_action: terminal_action, report: report)
|
|
265
333
|
end
|
|
266
334
|
|
|
267
335
|
policy.exception_matchers.concat(exception_matchers)
|
|
@@ -314,6 +382,36 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
314
382
|
|
|
315
383
|
private
|
|
316
384
|
|
|
385
|
+
# Shared bookkeeping for registering a step definition: copy-on-write of
|
|
386
|
+
# the inherited definitions array, cache invalidation, name generation,
|
|
387
|
+
# duplicate-name check, and positioning validation.
|
|
388
|
+
#
|
|
389
|
+
# @param name [String, Symbol, nil] the requested step name
|
|
390
|
+
# @param options [Hash] the step options (read for before_step/after_step)
|
|
391
|
+
# @return [String] the resolved step name
|
|
392
|
+
def prepare_step_registration!(name, options)
|
|
393
|
+
# Duplicate parent's array only if we haven't already (avoid mutating inherited definitions)
|
|
394
|
+
if _step_definitions.equal?(superclass._step_definitions)
|
|
395
|
+
self._step_definitions = _step_definitions.dup
|
|
396
|
+
end
|
|
397
|
+
# Invalidate cached step collection since we're adding a step
|
|
398
|
+
@steps = nil
|
|
399
|
+
|
|
400
|
+
step_name = (name || generate_step_name).to_s
|
|
401
|
+
|
|
402
|
+
# Check for duplicate step names
|
|
403
|
+
if _step_definitions.any? { |s| s.name == step_name }
|
|
404
|
+
raise GenevaDrive::StepConfigurationError,
|
|
405
|
+
"Step '#{step_name}' is already defined in #{self.name}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Validate positioning references exist
|
|
409
|
+
validate_step_positioning_reference!(step_name, options[:before_step], :before_step)
|
|
410
|
+
validate_step_positioning_reference!(step_name, options[:after_step], :after_step)
|
|
411
|
+
|
|
412
|
+
step_name
|
|
413
|
+
end
|
|
414
|
+
|
|
317
415
|
# Validates that a positioning reference (before_step/after_step) exists.
|
|
318
416
|
#
|
|
319
417
|
# @param step_name [String] the step being defined
|
|
@@ -428,22 +526,249 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
428
526
|
update!(state: "ready", transitioned_at: nil)
|
|
429
527
|
end
|
|
430
528
|
|
|
431
|
-
#
|
|
529
|
+
# A scheduled execution preserved by pause! takes precedence - re-enqueue it
|
|
432
530
|
scheduled_execution = current_execution
|
|
433
531
|
if scheduled_execution
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
532
|
+
# A parked execution is not re-enqueued: it is waiting for an event, not
|
|
533
|
+
# for a clock. Re-run the rendezvous instead, which delivers any signal
|
|
534
|
+
# that arrived while the workflow was paused.
|
|
535
|
+
return rendezvous_waiting_execution!(scheduled_execution) if scheduled_execution.waiting?
|
|
536
|
+
|
|
537
|
+
return enqueue_scheduled_execution(scheduled_execution)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Resumable-step continuations need the cursor/continues_from_id columns
|
|
541
|
+
if GenevaDrive::StepExecution.resumable_columns?
|
|
542
|
+
# Check for a resumable step that was paused mid-iteration. Only the most
|
|
543
|
+
# recent marker row counts, and only while it has not been consumed yet
|
|
544
|
+
# (creating a successor consumes it - the successor's presence marks that).
|
|
545
|
+
paused_resumable_execution = step_executions
|
|
546
|
+
.where(outcome: "workflow_paused", state: "completed")
|
|
547
|
+
.order(created_at: :desc, id: :desc)
|
|
548
|
+
.first
|
|
549
|
+
|
|
550
|
+
if paused_resumable_execution && paused_resumable_execution.successor.nil?
|
|
551
|
+
logger.info("Resuming from resumable execution #{paused_resumable_execution.id}, cursor: #{paused_resumable_execution.cursor_value.inspect}")
|
|
552
|
+
return create_successor_execution!(paused_resumable_execution)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# A resumable step that failed (pausing the workflow) resumes from its
|
|
556
|
+
# persisted cursor instead of redoing the whole iteration.
|
|
557
|
+
failed_execution = step_executions
|
|
558
|
+
.where(step_name: next_step_name, state: "failed")
|
|
559
|
+
.order(created_at: :desc, id: :desc)
|
|
560
|
+
.first
|
|
561
|
+
if failed_execution && failed_execution.cursor.present? && failed_execution.successor.nil?
|
|
562
|
+
logger.info("Retrying failed resumable execution #{failed_execution.id} from cursor: #{failed_execution.cursor_value.inspect}")
|
|
563
|
+
return create_successor_execution!(failed_execution)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# No scheduled execution exists - create one for the next step
|
|
568
|
+
step_def = steps.named(next_step_name)
|
|
569
|
+
create_step_execution(step_def, wait: nil)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Delivers an external event to this workflow.
|
|
573
|
+
#
|
|
574
|
+
# The signal row is persisted first and dispatched second, which is what
|
|
575
|
+
# makes arrival order irrelevant: a signal that lands before the waiting
|
|
576
|
+
# step's execution even exists simply sits in the table until that step's
|
|
577
|
+
# gate picks it up, and a signal that lands while a step is already parked
|
|
578
|
+
# wakes it immediately.
|
|
579
|
+
#
|
|
580
|
+
# Pass an +idempotency_key+ (a webhook's event id, typically) to make
|
|
581
|
+
# redelivery a no-op: the second call returns the row the first one wrote,
|
|
582
|
+
# flagged {GenevaDrive::Signal#duplicate_delivery?}, without dispatching.
|
|
583
|
+
#
|
|
584
|
+
# On a paused workflow the row is persisted but not dispatched - waking a
|
|
585
|
+
# step while the workflow is paused would only get its execution canceled.
|
|
586
|
+
# {#resume!} performs the rendezvous instead.
|
|
587
|
+
#
|
|
588
|
+
# @param signal_name [Symbol, String] the event name
|
|
589
|
+
# @param payload [Object] data describing the event, serialized through ActiveJob
|
|
590
|
+
# @param idempotency_key [String, nil] deduplication key, scoped to (workflow, name)
|
|
591
|
+
# @return [GenevaDrive::Signal] the persisted signal
|
|
592
|
+
# @raise [ArgumentError] if the signal name is blank
|
|
593
|
+
# @raise [WorkflowNotOngoing] if the workflow is finished or canceled and this is a new event
|
|
594
|
+
# @raise [SignalPayloadTooLargeError] if the payload exceeds GenevaDrive.max_signal_payload_size
|
|
595
|
+
#
|
|
596
|
+
# @example Deliver a webhook event
|
|
597
|
+
# workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
|
|
598
|
+
# workflow.signal!(:payment_confirmed,
|
|
599
|
+
# payload: {amount_cents: 12_500},
|
|
600
|
+
# idempotency_key: event["id"])
|
|
601
|
+
def signal!(signal_name, payload: {}, idempotency_key: nil, **unknown_options)
|
|
602
|
+
if unknown_options.any?
|
|
603
|
+
raise ArgumentError,
|
|
604
|
+
"Unknown options passed to signal!: #{unknown_options.keys.join(", ")}"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
if signal_name.blank?
|
|
608
|
+
raise ArgumentError, "signal! requires a signal name"
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
name = signal_name.to_s
|
|
612
|
+
dedup_key = idempotency_key&.to_s
|
|
613
|
+
|
|
614
|
+
# Serialize (and bound) before anything is persisted.
|
|
615
|
+
serialized_payload = GenevaDrive::Signal.serialize_payload(payload)
|
|
616
|
+
|
|
617
|
+
signal = nil
|
|
618
|
+
duplicate = false
|
|
619
|
+
|
|
620
|
+
# One transaction covers the lot: the INSERT, the waiting-execution scan,
|
|
621
|
+
# and every attach, flip and counter bump. Taking the lock before writing
|
|
622
|
+
# anything is what makes delivery all-or-nothing - insert first and
|
|
623
|
+
# dispatch second, and a crash in between would leave a pending signal
|
|
624
|
+
# sitting next to a waiting execution with nothing left to introduce them.
|
|
625
|
+
with_lock do
|
|
626
|
+
# with_lock reloads; the terminal check belongs inside it
|
|
627
|
+
unless ongoing?
|
|
628
|
+
existing = dedup_key && signals.find_by(name: name, idempotency_key: dedup_key)
|
|
629
|
+
unless existing
|
|
630
|
+
raise GenevaDrive::WorkflowNotOngoing,
|
|
631
|
+
"Cannot deliver signal #{name.inspect} to a #{state} workflow"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
logger.info("Signal #{name.inspect} redelivered to #{state} workflow, returning existing row")
|
|
635
|
+
signal = existing
|
|
636
|
+
duplicate = true
|
|
637
|
+
next
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
begin
|
|
641
|
+
# A savepoint so that hitting the dedup index does not poison the
|
|
642
|
+
# transaction - the caller's, if they had one open, or ours.
|
|
643
|
+
transaction(requires_new: true) do
|
|
644
|
+
record = signals.new(name: name, idempotency_key: dedup_key, state: "pending")
|
|
645
|
+
record[:payload] = serialized_payload
|
|
646
|
+
record.save!
|
|
647
|
+
signal = record
|
|
648
|
+
end
|
|
649
|
+
rescue ActiveRecord::RecordNotUnique
|
|
650
|
+
signal = signals.find_by!(name: name, idempotency_key: dedup_key)
|
|
651
|
+
logger.info("Signal #{name.inspect} is a duplicate delivery of signal #{signal.id}, not dispatching")
|
|
652
|
+
duplicate = true
|
|
653
|
+
next
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
logger.info("Received signal #{name.inspect} as signal #{signal.id}")
|
|
657
|
+
|
|
658
|
+
if paused?
|
|
659
|
+
logger.info("Workflow is paused, buffering signal #{signal.id} until resume!")
|
|
660
|
+
else
|
|
661
|
+
wake_executions_matching!(signal)
|
|
662
|
+
end
|
|
439
663
|
end
|
|
664
|
+
|
|
665
|
+
signal.duplicate_delivery! if duplicate
|
|
666
|
+
signal
|
|
440
667
|
end
|
|
441
668
|
|
|
442
669
|
# Returns the current active step execution, if any.
|
|
670
|
+
# Includes scheduled, waiting and in_progress states.
|
|
443
671
|
#
|
|
444
672
|
# @return [StepExecution, nil] the current execution
|
|
445
673
|
def current_execution
|
|
446
|
-
step_executions.where(state: %w[scheduled in_progress]).first
|
|
674
|
+
step_executions.where(state: %w[scheduled waiting in_progress]).first
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Signals that may still be attached to an execution, oldest first, each
|
|
678
|
+
# pointing back at this workflow instance.
|
|
679
|
+
#
|
|
680
|
+
# Matcher blocks are instance_exec'd on `signal.workflow`, and `inverse_of:`
|
|
681
|
+
# only primes records loaded straight off the association - a scoped
|
|
682
|
+
# relation would hand each signal a freshly loaded workflow, which would
|
|
683
|
+
# then see none of the caller's unsaved state.
|
|
684
|
+
#
|
|
685
|
+
# @return [Array<GenevaDrive::Signal>]
|
|
686
|
+
# @api private
|
|
687
|
+
public def attachable_signals
|
|
688
|
+
signals.attachable.map { |signal| adopt_signal(signal) }
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Wakes every parked step execution whose matcher accepts the given signal.
|
|
692
|
+
#
|
|
693
|
+
# Runs under the workflow row lock - the same lock create_step_execution
|
|
694
|
+
# and the Executor take - so whichever of dispatch and the gate commits
|
|
695
|
+
# second sees the other's write and no signal can slip through the gap.
|
|
696
|
+
#
|
|
697
|
+
# @param signal [GenevaDrive::Signal] the signal to deliver
|
|
698
|
+
# @return [Array<StepExecution>] the executions that were woken
|
|
699
|
+
# @api private
|
|
700
|
+
public def dispatch_signal!(signal)
|
|
701
|
+
return [] unless GenevaDrive::StepExecution.signal_columns?
|
|
702
|
+
|
|
703
|
+
with_lock { wake_executions_matching!(signal) }
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Settles the signal attached to an execution we are deliberately moving
|
|
707
|
+
# past, wherever the skip came from: `skip_if` firing at wake, flow control
|
|
708
|
+
# inside the step, an exception policy, or an operator calling `skip!`.
|
|
709
|
+
#
|
|
710
|
+
# Consumption follows attachment. A skip in any flavor says "we are done
|
|
711
|
+
# with this step", so an event that was delivered to it is spent by that
|
|
712
|
+
# decision - leaving it claimed would hand a stale event to the next waiter
|
|
713
|
+
# for the same name, which is exactly what consume-once-per-event forbids.
|
|
714
|
+
# An execution that never attached has nothing to settle, so buffered
|
|
715
|
+
# signals a step never reached are left alone.
|
|
716
|
+
#
|
|
717
|
+
# Must be called with the workflow lock held, so the settle rides the same
|
|
718
|
+
# transaction as the skip itself.
|
|
719
|
+
#
|
|
720
|
+
# @param step_execution [StepExecution, nil] the execution being skipped past
|
|
721
|
+
# @return [GenevaDrive::Signal, nil] the settled signal, if there was one
|
|
722
|
+
# @api private
|
|
723
|
+
public def settle_signal_for_skipped!(step_execution)
|
|
724
|
+
return unless GenevaDrive::StepExecution.signal_columns?
|
|
725
|
+
return if step_execution.nil? || step_execution.signal_id.blank?
|
|
726
|
+
|
|
727
|
+
signal = step_execution.signal
|
|
728
|
+
return unless signal && signal.state == "claimed"
|
|
729
|
+
|
|
730
|
+
logger.info("Skipping past step #{step_execution.step_name}, settling attached signal #{signal.id} (#{signal.name})")
|
|
731
|
+
signal.record_consumption!(resolved_step_names: [step_execution.step_name])
|
|
732
|
+
signal
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# The body of dispatch, for callers that already hold the workflow lock
|
|
736
|
+
# (`signal!` holds it across the whole delivery so the rows land atomically).
|
|
737
|
+
#
|
|
738
|
+
# @param signal [GenevaDrive::Signal] the signal to deliver
|
|
739
|
+
# @return [Array<StepExecution>] the executions that were woken
|
|
740
|
+
# @api private
|
|
741
|
+
public def wake_executions_matching!(signal)
|
|
742
|
+
return [] unless GenevaDrive::StepExecution.signal_columns?
|
|
743
|
+
return [] if paused? || !ongoing?
|
|
744
|
+
|
|
745
|
+
# Matcher blocks run on signal.workflow, so point the signal at this very
|
|
746
|
+
# instance instead of letting it load a second copy from the database.
|
|
747
|
+
adopt_signal(signal)
|
|
748
|
+
woken = []
|
|
749
|
+
|
|
750
|
+
step_executions.where(state: "waiting").order(created_at: :asc, id: :asc).each do |execution|
|
|
751
|
+
step_def = execution.step_definition
|
|
752
|
+
# A step whose definition is gone (class removed, step renamed) can
|
|
753
|
+
# never match - the matcher lives on the definition.
|
|
754
|
+
next unless step_def&.waits_for_signal?
|
|
755
|
+
next unless step_def.signal_matcher.matches?(signal)
|
|
756
|
+
|
|
757
|
+
signal.claim!
|
|
758
|
+
execution.update!(
|
|
759
|
+
signal_id: signal.id,
|
|
760
|
+
state: "scheduled",
|
|
761
|
+
scheduled_for: Time.current,
|
|
762
|
+
waiting_since: nil
|
|
763
|
+
)
|
|
764
|
+
woken << [execution, step_def]
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
woken.each do |execution, step_def|
|
|
768
|
+
enqueue_woken_execution(execution, step_def)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
woken.map(&:first)
|
|
447
772
|
end
|
|
448
773
|
|
|
449
774
|
# Returns all step executions in chronological order.
|
|
@@ -525,6 +850,30 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
525
850
|
yield
|
|
526
851
|
end
|
|
527
852
|
|
|
853
|
+
# Returns per-instance step job options stored in metadata.
|
|
854
|
+
# These are merged with the class-level options when enqueuing step jobs,
|
|
855
|
+
# with instance options taking precedence.
|
|
856
|
+
#
|
|
857
|
+
# @return [Hash] symbolized job options (e.g. `{ queue: :high, priority: 5 }`)
|
|
858
|
+
def step_job_options
|
|
859
|
+
(read_metadata("step_job_options") || {}).symbolize_keys
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Sets per-instance step job options in metadata.
|
|
863
|
+
# Stored as part of the workflow's metadata column so they persist and
|
|
864
|
+
# survive across step boundaries.
|
|
865
|
+
#
|
|
866
|
+
# @param options [Hash, nil] job options to store
|
|
867
|
+
# @return [void]
|
|
868
|
+
def step_job_options=(options)
|
|
869
|
+
if options.present?
|
|
870
|
+
validated = GenevaDrive::JobOptions.validate!(options, context: "#{self.class.name}#step_job_options=")
|
|
871
|
+
write_metadata("step_job_options", validated.stringify_keys)
|
|
872
|
+
else
|
|
873
|
+
write_metadata("step_job_options", nil)
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
528
877
|
# Transitions the workflow to a new state.
|
|
529
878
|
#
|
|
530
879
|
# @param new_state [String] the target state
|
|
@@ -542,6 +891,17 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
542
891
|
|
|
543
892
|
private
|
|
544
893
|
|
|
894
|
+
# Returns class-level step job options merged with per-instance and step overrides.
|
|
895
|
+
# Instance options take precedence over class options; step options take final precedence.
|
|
896
|
+
#
|
|
897
|
+
# @param step_definition [StepDefinition, nil] step whose job options should override defaults
|
|
898
|
+
# @return [Hash] merged job options
|
|
899
|
+
def merged_step_job_options(step_definition)
|
|
900
|
+
self.class._step_job_options
|
|
901
|
+
.merge(step_job_options)
|
|
902
|
+
.merge(step_definition&.job_options || {})
|
|
903
|
+
end
|
|
904
|
+
|
|
545
905
|
# Validates that no other ongoing workflow exists for the same (type, hero).
|
|
546
906
|
# Mirrors the database unique index on (type, hero_type, hero_id) for ongoing workflows.
|
|
547
907
|
#
|
|
@@ -578,6 +938,64 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
578
938
|
create_step_execution(first_step, wait: first_step.wait)
|
|
579
939
|
end
|
|
580
940
|
|
|
941
|
+
# Points a signal's workflow association at this instance, so matcher
|
|
942
|
+
# blocks - which are instance_exec'd on it - see the live object.
|
|
943
|
+
#
|
|
944
|
+
# @param signal [GenevaDrive::Signal]
|
|
945
|
+
# @return [GenevaDrive::Signal] the same signal
|
|
946
|
+
def adopt_signal(signal)
|
|
947
|
+
signal.association(:workflow).target = self
|
|
948
|
+
signal
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Enqueues the PerformStepJob for an execution that dispatch just flipped
|
|
952
|
+
# from waiting to scheduled. Uses the same enqueue discipline (merged job
|
|
953
|
+
# options, after-commit deferral, job_id writeback) as create_step_execution.
|
|
954
|
+
#
|
|
955
|
+
# @param step_execution [StepExecution] the woken execution
|
|
956
|
+
# @param step_definition [StepDefinition] its step definition
|
|
957
|
+
# @return [void]
|
|
958
|
+
def enqueue_woken_execution(step_execution, step_definition)
|
|
959
|
+
job_options = merged_step_job_options(step_definition)
|
|
960
|
+
execution_id = step_execution.id
|
|
961
|
+
workflow_logger = logger
|
|
962
|
+
|
|
963
|
+
run_after_commit do
|
|
964
|
+
job = GenevaDrive::PerformStepJob
|
|
965
|
+
.set(**job_options)
|
|
966
|
+
.perform_later(execution_id)
|
|
967
|
+
|
|
968
|
+
workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for woken step execution #{execution_id}")
|
|
969
|
+
|
|
970
|
+
GenevaDrive::StepExecution
|
|
971
|
+
.where(id: execution_id)
|
|
972
|
+
.update_all(job_id: job.job_id)
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Re-runs the rendezvous for a step execution that is parked waiting for a
|
|
977
|
+
# signal. Signals that arrived while the workflow was paused are delivered
|
|
978
|
+
# here, on resume.
|
|
979
|
+
#
|
|
980
|
+
# @param step_execution [StepExecution] the parked execution
|
|
981
|
+
# @return [StepExecution] the same execution (rescheduled if a signal matched)
|
|
982
|
+
def rendezvous_waiting_execution!(step_execution)
|
|
983
|
+
step_def = step_execution.step_definition
|
|
984
|
+
matcher = step_def&.signal_matcher
|
|
985
|
+
|
|
986
|
+
if matcher
|
|
987
|
+
candidate = attachable_signals.detect { |signal| matcher.matches?(signal) }
|
|
988
|
+
if candidate
|
|
989
|
+
logger.info("Resuming into a matching signal #{candidate.id} (#{candidate.name}) for step #{step_execution.step_name}")
|
|
990
|
+
dispatch_signal!(candidate)
|
|
991
|
+
return step_execution.reload
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
logger.info("Step #{step_execution.step_name} stays parked - no matching signal has arrived yet")
|
|
996
|
+
step_execution
|
|
997
|
+
end
|
|
998
|
+
|
|
581
999
|
# Enqueues a job for an existing scheduled execution.
|
|
582
1000
|
#
|
|
583
1001
|
# If the execution is overdue (scheduled_for is in the past), runs immediately.
|
|
@@ -593,7 +1011,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
593
1011
|
logger.info("Enqueuing job for step #{step_execution.step_name} to run #{wait_msg}")
|
|
594
1012
|
|
|
595
1013
|
# Capture values for the callback
|
|
596
|
-
job_options =
|
|
1014
|
+
job_options = merged_step_job_options(step_execution.step_definition)
|
|
597
1015
|
job_options[:wait_until] = wait_until if wait_until
|
|
598
1016
|
execution_id = step_execution.id
|
|
599
1017
|
workflow_logger = logger
|
|
@@ -615,6 +1033,65 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
615
1033
|
step_execution
|
|
616
1034
|
end
|
|
617
1035
|
|
|
1036
|
+
# Creates a successor step execution that continues a resumable step from
|
|
1037
|
+
# the cursor persisted on the given execution. Used by the Executor when a
|
|
1038
|
+
# resumable step interrupts itself, by resume! for workflows paused
|
|
1039
|
+
# mid-iteration, and by housekeeping recovery of stuck resumable steps.
|
|
1040
|
+
#
|
|
1041
|
+
# The successor is enqueued with the same merged job options (class,
|
|
1042
|
+
# per-instance, per-step) as any other execution of the step.
|
|
1043
|
+
#
|
|
1044
|
+
# @param predecessor [StepExecution] the execution to continue from
|
|
1045
|
+
# @param wait [ActiveSupport::Duration, Numeric, nil] optional delay
|
|
1046
|
+
# @return [StepExecution] the new successor execution
|
|
1047
|
+
# @api private
|
|
1048
|
+
public def create_successor_execution!(predecessor, wait: nil)
|
|
1049
|
+
scheduled_for = wait ? wait.from_now : Time.current
|
|
1050
|
+
|
|
1051
|
+
with_lock do
|
|
1052
|
+
# Cancel any stray scheduled or parked executions - the successor is the
|
|
1053
|
+
# one execution that should run next (same as create_step_execution).
|
|
1054
|
+
cancel_stray_executions!
|
|
1055
|
+
|
|
1056
|
+
successor_attributes = {
|
|
1057
|
+
step_name: predecessor.step_name,
|
|
1058
|
+
state: "scheduled",
|
|
1059
|
+
scheduled_for: scheduled_for,
|
|
1060
|
+
continues_from_id: predecessor.id,
|
|
1061
|
+
cursor: predecessor.cursor
|
|
1062
|
+
}
|
|
1063
|
+
# Carry the attachment pin across the chain so successors never re-park
|
|
1064
|
+
# and received_signal stays stable for the whole iteration.
|
|
1065
|
+
if GenevaDrive::StepExecution.signal_columns?
|
|
1066
|
+
successor_attributes[:signal_id] = predecessor.signal_id
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
successor = step_executions.create!(**successor_attributes)
|
|
1070
|
+
|
|
1071
|
+
# next_step_name points to the step that's scheduled to run next
|
|
1072
|
+
update!(next_step_name: predecessor.step_name)
|
|
1073
|
+
|
|
1074
|
+
job_options = merged_step_job_options(predecessor.step_definition)
|
|
1075
|
+
job_options[:wait_until] = scheduled_for if wait
|
|
1076
|
+
successor_id = successor.id
|
|
1077
|
+
workflow_logger = logger
|
|
1078
|
+
|
|
1079
|
+
run_after_commit do
|
|
1080
|
+
job = GenevaDrive::PerformStepJob
|
|
1081
|
+
.set(**job_options)
|
|
1082
|
+
.perform_later(successor_id)
|
|
1083
|
+
|
|
1084
|
+
workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for successor execution ##{successor_id}")
|
|
1085
|
+
|
|
1086
|
+
GenevaDrive::StepExecution
|
|
1087
|
+
.where(id: successor_id)
|
|
1088
|
+
.update_all(job_id: job.job_id)
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
successor
|
|
1092
|
+
end
|
|
1093
|
+
end
|
|
1094
|
+
|
|
618
1095
|
# Creates a step execution and enqueues the job after transaction commits.
|
|
619
1096
|
# Any existing scheduled step executions are canceled first.
|
|
620
1097
|
# In-progress step executions are left alone - they're being executed.
|
|
@@ -629,15 +1106,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
629
1106
|
scheduled_for = wait ? wait.from_now : Time.current
|
|
630
1107
|
|
|
631
1108
|
with_lock do
|
|
632
|
-
|
|
633
|
-
# Safe to use update_all since we hold the workflow lock, blocking any executor
|
|
634
|
-
# that would try to start these steps.
|
|
635
|
-
canceled_count = step_executions.scheduled.update_all(
|
|
636
|
-
state: "canceled",
|
|
637
|
-
outcome: "canceled",
|
|
638
|
-
canceled_at: Time.current
|
|
639
|
-
)
|
|
640
|
-
logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
|
|
1109
|
+
cancel_stray_executions!
|
|
641
1110
|
|
|
642
1111
|
step_execution = step_executions.create!(
|
|
643
1112
|
step_name: step_definition.name,
|
|
@@ -649,7 +1118,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
649
1118
|
update!(next_step_name: step_definition.name)
|
|
650
1119
|
|
|
651
1120
|
# Capture values for the after_commit callback
|
|
652
|
-
job_options =
|
|
1121
|
+
job_options = merged_step_job_options(step_definition)
|
|
653
1122
|
job_options[:wait_until] = scheduled_for if wait
|
|
654
1123
|
execution_id = step_execution.id
|
|
655
1124
|
workflow_logger = logger
|
|
@@ -681,6 +1150,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
681
1150
|
end
|
|
682
1151
|
end
|
|
683
1152
|
|
|
1153
|
+
# Cancels any scheduled or parked step executions so that the execution
|
|
1154
|
+
# about to be created is the only one that will run. In-progress executions
|
|
1155
|
+
# are left alone - they are being executed.
|
|
1156
|
+
#
|
|
1157
|
+
# Safe to use update_all because the caller holds the workflow lock, which
|
|
1158
|
+
# blocks any executor that would try to start these steps. Parked executions
|
|
1159
|
+
# are swept for the same reason: no legitimate path creates a new execution
|
|
1160
|
+
# past one that is waiting.
|
|
1161
|
+
#
|
|
1162
|
+
# @return [Integer] the number of executions canceled
|
|
1163
|
+
def cancel_stray_executions!
|
|
1164
|
+
canceled_count = step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
1165
|
+
state: "canceled",
|
|
1166
|
+
outcome: "canceled",
|
|
1167
|
+
canceled_at: Time.current
|
|
1168
|
+
)
|
|
1169
|
+
logger.debug("Canceled #{canceled_count} previously scheduled step execution(s)") if canceled_count > 0
|
|
1170
|
+
canceled_count
|
|
1171
|
+
end
|
|
1172
|
+
|
|
684
1173
|
# Finishes the workflow.
|
|
685
1174
|
#
|
|
686
1175
|
# @return [nil]
|
|
@@ -737,6 +1226,33 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
737
1226
|
@tagged_logger = previous_tagged_logger
|
|
738
1227
|
end
|
|
739
1228
|
|
|
1229
|
+
# The signal that woke the step currently executing, or nil when the step
|
|
1230
|
+
# does not declare +wait_for:+. Available inside step bodies only.
|
|
1231
|
+
#
|
|
1232
|
+
# @return [GenevaDrive::Signal, nil]
|
|
1233
|
+
#
|
|
1234
|
+
# @example Read the payload of the event that woke the step
|
|
1235
|
+
# step :capture, wait_for: :payment_confirmed do
|
|
1236
|
+
# hero.capture!(received_signal.payload[:amount_cents])
|
|
1237
|
+
# end
|
|
1238
|
+
public attr_reader :received_signal
|
|
1239
|
+
|
|
1240
|
+
# Temporarily makes a signal available to step code as +received_signal+
|
|
1241
|
+
# for the duration of the block. Injected by the Executor the same way the
|
|
1242
|
+
# tagged logger is.
|
|
1243
|
+
#
|
|
1244
|
+
# @param signal [GenevaDrive::Signal, nil] the attached signal
|
|
1245
|
+
# @yield the block to execute with the signal in scope
|
|
1246
|
+
# @return [Object] the result of the block
|
|
1247
|
+
# @api private
|
|
1248
|
+
public def with_received_signal(signal)
|
|
1249
|
+
previous_received_signal = @received_signal
|
|
1250
|
+
@received_signal = signal
|
|
1251
|
+
yield
|
|
1252
|
+
ensure
|
|
1253
|
+
@received_signal = previous_received_signal
|
|
1254
|
+
end
|
|
1255
|
+
|
|
740
1256
|
# Returns the Logger properly tagged to this Workflow
|
|
741
1257
|
#
|
|
742
1258
|
# @return [Logger]
|