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
|
@@ -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",
|
|
@@ -36,6 +37,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
36
37
|
# Outcome values for audit purposes
|
|
37
38
|
OUTCOMES = %w[
|
|
38
39
|
success
|
|
40
|
+
continued
|
|
39
41
|
reattempted
|
|
40
42
|
skipped
|
|
41
43
|
canceled
|
|
@@ -50,6 +52,26 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
50
52
|
foreign_key: :workflow_id,
|
|
51
53
|
inverse_of: :step_executions
|
|
52
54
|
|
|
55
|
+
# For chained resumable step executions
|
|
56
|
+
belongs_to :continues_from,
|
|
57
|
+
class_name: "GenevaDrive::StepExecution",
|
|
58
|
+
foreign_key: :continues_from_id,
|
|
59
|
+
optional: true,
|
|
60
|
+
inverse_of: :successor
|
|
61
|
+
|
|
62
|
+
has_one :successor,
|
|
63
|
+
class_name: "GenevaDrive::StepExecution",
|
|
64
|
+
foreign_key: :continues_from_id,
|
|
65
|
+
inverse_of: :continues_from
|
|
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
|
+
|
|
53
75
|
# Validations
|
|
54
76
|
validates :step_name, presence: true
|
|
55
77
|
validates :scheduled_for, presence: true
|
|
@@ -60,6 +82,90 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
60
82
|
scheduled.where("scheduled_for <= ?", Time.current)
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
class << self
|
|
86
|
+
# Lazily checks whether the resumable-step columns (cursor and
|
|
87
|
+
# continues_from_id) have been migrated. Never hits the database at
|
|
88
|
+
# class definition time — only on the first runtime call.
|
|
89
|
+
#
|
|
90
|
+
# Deployments usually ship the gem update before running migrations,
|
|
91
|
+
# so all cursor/chaining behavior must degrade safely when the columns
|
|
92
|
+
# are absent: reads return nil, writes are no-ops, and executing an
|
|
93
|
+
# actual resumable_step raises a clear configuration error instead.
|
|
94
|
+
#
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def resumable_columns?
|
|
97
|
+
if defined?(@_resumable_columns)
|
|
98
|
+
return @_resumable_columns
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@_resumable_columns = table_exists? &&
|
|
102
|
+
column_names.include?("cursor") &&
|
|
103
|
+
column_names.include?("continues_from_id")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Clears the cached detection result. Call this in tests or after
|
|
107
|
+
# running migrations in-process so the next access re-checks.
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
def reset_resumable_columns_cache!
|
|
111
|
+
remove_instance_variable(:@_resumable_columns) if defined?(@_resumable_columns)
|
|
112
|
+
end
|
|
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
|
+
|
|
141
|
+
# Serializes a cursor value using ActiveJob serializers (handles Date,
|
|
142
|
+
# Time, and other types) and enforces GenevaDrive.max_cursor_size on
|
|
143
|
+
# the serialized JSON. The single serialization path for cursor writes.
|
|
144
|
+
#
|
|
145
|
+
# @param value [Object, nil] the cursor value
|
|
146
|
+
# @return [Object, nil] the serialized cursor
|
|
147
|
+
# @raise [CursorTooLargeError] if the serialized JSON exceeds the limit
|
|
148
|
+
def serialize_cursor(value)
|
|
149
|
+
return nil if value.nil?
|
|
150
|
+
|
|
151
|
+
serialized = ActiveJob::Arguments.serialize([value]).first
|
|
152
|
+
|
|
153
|
+
limit = GenevaDrive.max_cursor_size
|
|
154
|
+
if limit
|
|
155
|
+
bytesize = JSON.generate(serialized).bytesize
|
|
156
|
+
if bytesize > limit
|
|
157
|
+
raise GenevaDrive::CursorTooLargeError,
|
|
158
|
+
"Serialized cursor is #{bytesize} bytes, exceeding GenevaDrive.max_cursor_size " \
|
|
159
|
+
"(#{limit} bytes). The cursor is a position marker (an id, page number, or token), " \
|
|
160
|
+
"not a place to store the data being processed. Set GenevaDrive.max_cursor_size to " \
|
|
161
|
+
"nil to disable this check."
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
serialized
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
63
169
|
# Transitions the step execution to 'in_progress' state.
|
|
64
170
|
# Uses pessimistic locking to prevent double execution.
|
|
65
171
|
#
|
|
@@ -136,6 +242,37 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
136
242
|
end
|
|
137
243
|
end
|
|
138
244
|
|
|
245
|
+
# Returns the deserialized cursor value for resumable steps.
|
|
246
|
+
# Uses ActiveJob serializers to handle Date, Time, and other types.
|
|
247
|
+
# Returns nil when the cursor column has not been migrated yet.
|
|
248
|
+
#
|
|
249
|
+
# @return [Object, nil] the cursor value
|
|
250
|
+
def cursor_value
|
|
251
|
+
return nil unless self.class.resumable_columns?
|
|
252
|
+
return nil if cursor.blank?
|
|
253
|
+
ActiveJob::Arguments.deserialize([cursor]).first
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Sets the cursor value for resumable steps.
|
|
257
|
+
# Uses ActiveJob serializers to handle Date, Time, and other types.
|
|
258
|
+
# Silent no-op when the cursor column has not been migrated yet.
|
|
259
|
+
#
|
|
260
|
+
# @param value [Object] the cursor value to store
|
|
261
|
+
# @raise [CursorTooLargeError] if the serialized JSON exceeds GenevaDrive.max_cursor_size
|
|
262
|
+
# @return [void]
|
|
263
|
+
def cursor_value=(value)
|
|
264
|
+
return unless self.class.resumable_columns?
|
|
265
|
+
self.cursor = self.class.serialize_cursor(value)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Returns true if this execution is resuming from a prior execution.
|
|
269
|
+
# Always false when the continues_from_id column has not been migrated yet.
|
|
270
|
+
#
|
|
271
|
+
# @return [Boolean]
|
|
272
|
+
def resuming?
|
|
273
|
+
self.class.resumable_columns? && continues_from_id.present?
|
|
274
|
+
end
|
|
275
|
+
|
|
139
276
|
# Returns the step definition for this execution.
|
|
140
277
|
#
|
|
141
278
|
# @return [StepDefinition, nil] the step definition
|
|
@@ -145,9 +282,11 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
145
282
|
|
|
146
283
|
# Executes this step using the Executor.
|
|
147
284
|
#
|
|
285
|
+
# @param interruptible [Boolean] whether resumable steps respect interruption conditions
|
|
286
|
+
# @param max_iterations [Integer, nil] per-execution override of the step's max_iterations
|
|
148
287
|
# @return [void]
|
|
149
|
-
def execute!
|
|
150
|
-
GenevaDrive::Executor.execute!(self)
|
|
288
|
+
def execute!(interruptible: true, max_iterations: nil)
|
|
289
|
+
GenevaDrive::Executor.execute!(self, interruptible: interruptible, max_iterations: max_iterations)
|
|
151
290
|
end
|
|
152
291
|
|
|
153
292
|
# Same as ActiveRecord::Base#logger but supplemented with tags for step and workflow
|
|
@@ -20,11 +20,11 @@ module GenevaDrive::TestHelpers
|
|
|
20
20
|
# Executes all steps of a workflow synchronously for testing.
|
|
21
21
|
#
|
|
22
22
|
# This method runs through all scheduled steps, executing each one
|
|
23
|
-
# immediately regardless of wait times.
|
|
24
|
-
#
|
|
23
|
+
# immediately regardless of wait times. Resumable steps are run to
|
|
24
|
+
# completion (interruption limits are disabled).
|
|
25
25
|
#
|
|
26
26
|
# @param workflow [GenevaDrive::Workflow] the workflow to run
|
|
27
|
-
# @param max_iterations [Integer] maximum
|
|
27
|
+
# @param max_iterations [Integer] maximum step executions (safety limit)
|
|
28
28
|
# @return [GenevaDrive::Workflow] the workflow after execution
|
|
29
29
|
# @raise [RuntimeError] if max_iterations is exceeded
|
|
30
30
|
#
|
|
@@ -46,7 +46,10 @@ module GenevaDrive::TestHelpers
|
|
|
46
46
|
step_execution = workflow.current_execution
|
|
47
47
|
break unless step_execution
|
|
48
48
|
|
|
49
|
-
step_execution
|
|
49
|
+
raise_if_parked!(step_execution, "speedrun_workflow")
|
|
50
|
+
|
|
51
|
+
# interruptible: false so resumable steps run to completion
|
|
52
|
+
step_execution.execute!(interruptible: false)
|
|
50
53
|
iterations += 1
|
|
51
54
|
|
|
52
55
|
if iterations >= max_iterations
|
|
@@ -78,6 +81,8 @@ module GenevaDrive::TestHelpers
|
|
|
78
81
|
step_execution = workflow.current_execution
|
|
79
82
|
return nil unless step_execution
|
|
80
83
|
|
|
84
|
+
raise_if_parked!(step_execution, "perform_next_step")
|
|
85
|
+
|
|
81
86
|
step_execution.execute!
|
|
82
87
|
workflow.reload
|
|
83
88
|
step_execution
|
|
@@ -116,8 +121,9 @@ module GenevaDrive::TestHelpers
|
|
|
116
121
|
"Available steps: #{available}"
|
|
117
122
|
end
|
|
118
123
|
|
|
119
|
-
# Cancel any existing scheduled step executions to satisfy
|
|
120
|
-
|
|
124
|
+
# Cancel any existing scheduled or parked step executions to satisfy the
|
|
125
|
+
# uniqueness constraint
|
|
126
|
+
workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
|
|
121
127
|
state: "canceled",
|
|
122
128
|
outcome: "canceled",
|
|
123
129
|
canceled_at: Time.current
|
|
@@ -170,4 +176,204 @@ module GenevaDrive::TestHelpers
|
|
|
170
176
|
assert_equal expected_state.to_s, workflow.state,
|
|
171
177
|
"Expected workflow to be #{expected_state}, but was #{workflow.state}"
|
|
172
178
|
end
|
|
179
|
+
|
|
180
|
+
# === Resumable Step Test Helpers ===
|
|
181
|
+
|
|
182
|
+
# Runs the current step to completion without advancing to the next step.
|
|
183
|
+
#
|
|
184
|
+
# For regular steps, this is equivalent to `perform_next_step`.
|
|
185
|
+
# For resumable steps, this disables interruption checks and runs until
|
|
186
|
+
# the step completes (or fails/cancels/skips).
|
|
187
|
+
#
|
|
188
|
+
# @param workflow [GenevaDrive::Workflow] the workflow containing the step
|
|
189
|
+
# @param max_executions [Integer] safety limit on job executions (default: 100)
|
|
190
|
+
# @return [GenevaDrive::StepExecution] the step execution
|
|
191
|
+
#
|
|
192
|
+
# @example Run current step to completion
|
|
193
|
+
# workflow = BulkEmailWorkflow.create!(hero: campaign)
|
|
194
|
+
# speedrun_current_step(workflow)
|
|
195
|
+
# assert_step_executed(workflow, :send_emails)
|
|
196
|
+
#
|
|
197
|
+
def speedrun_current_step(workflow, max_executions: 100)
|
|
198
|
+
workflow.reload
|
|
199
|
+
step_execution = workflow.current_execution
|
|
200
|
+
return nil unless step_execution
|
|
201
|
+
|
|
202
|
+
raise_if_parked!(step_execution, "speedrun_current_step")
|
|
203
|
+
|
|
204
|
+
executions = 0
|
|
205
|
+
original_step_name = step_execution.step_name
|
|
206
|
+
|
|
207
|
+
loop do
|
|
208
|
+
step_execution.reload
|
|
209
|
+
raise_if_parked!(step_execution, "speedrun_current_step")
|
|
210
|
+
|
|
211
|
+
# Follow the execution chain: a completed execution that spawned a
|
|
212
|
+
# successor means the step is still going (suspend! and skip_to! create
|
|
213
|
+
# successors even with interruption checks disabled).
|
|
214
|
+
if GenevaDrive::StepExecution.resumable_columns? &&
|
|
215
|
+
step_execution.completed? && (successor = step_execution.successor)
|
|
216
|
+
step_execution = successor
|
|
217
|
+
next
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
break if step_execution.completed? || step_execution.failed? || step_execution.canceled? || step_execution.skipped?
|
|
221
|
+
|
|
222
|
+
# interruptible: false so the step runs to completion
|
|
223
|
+
step_execution.execute!(interruptible: false)
|
|
224
|
+
executions += 1
|
|
225
|
+
|
|
226
|
+
if executions >= max_executions
|
|
227
|
+
raise "speedrun_current_step exceeded max_executions (#{max_executions}). " \
|
|
228
|
+
"Step '#{original_step_name}' may be in an infinite loop."
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
workflow.reload
|
|
233
|
+
step_execution.reload
|
|
234
|
+
step_execution
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Runs the current (resumable) step for a limited number of iterations,
|
|
238
|
+
# then lets it interrupt and create a successor execution.
|
|
239
|
+
#
|
|
240
|
+
# Useful for testing partial progress and cursor state of resumable steps.
|
|
241
|
+
#
|
|
242
|
+
# @param workflow [GenevaDrive::Workflow] the workflow containing the step
|
|
243
|
+
# @param count [Integer] the number of iterations to run before interrupting
|
|
244
|
+
# @return [GenevaDrive::StepExecution] the executed (now completed) step execution
|
|
245
|
+
#
|
|
246
|
+
# @example Run three iterations, then inspect the cursor
|
|
247
|
+
# run_iterations(workflow, count: 3)
|
|
248
|
+
# assert_cursor(workflow, 3)
|
|
249
|
+
#
|
|
250
|
+
def run_iterations(workflow, count:)
|
|
251
|
+
workflow.reload
|
|
252
|
+
step_execution = workflow.current_execution
|
|
253
|
+
raise "No current step execution to run" unless step_execution
|
|
254
|
+
|
|
255
|
+
step_execution.execute!(max_iterations: count)
|
|
256
|
+
|
|
257
|
+
workflow.reload
|
|
258
|
+
step_execution.reload
|
|
259
|
+
step_execution
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Asserts that a resumable step's cursor matches the expected value.
|
|
263
|
+
#
|
|
264
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
265
|
+
# @param expected_cursor [Object] the expected cursor value
|
|
266
|
+
# @return [void]
|
|
267
|
+
#
|
|
268
|
+
# @example Check cursor position
|
|
269
|
+
# assert_cursor(workflow, 42)
|
|
270
|
+
# assert_cursor(workflow, "page_token_abc")
|
|
271
|
+
#
|
|
272
|
+
def assert_cursor(workflow, expected_cursor)
|
|
273
|
+
workflow.reload
|
|
274
|
+
execution = workflow.current_execution
|
|
275
|
+
actual = execution&.cursor_value
|
|
276
|
+
|
|
277
|
+
assert_equal expected_cursor, actual,
|
|
278
|
+
"Expected cursor #{expected_cursor.inspect}, got #{actual.inspect}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Asserts that a step completed and has a scheduled successor execution.
|
|
282
|
+
# Used to verify that a resumable step was interrupted and will continue.
|
|
283
|
+
#
|
|
284
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
285
|
+
# @param step_name [String, Symbol] the step name to check
|
|
286
|
+
# @return [void]
|
|
287
|
+
#
|
|
288
|
+
# @example Check step has a scheduled successor
|
|
289
|
+
# assert_step_has_successor(workflow, :bulk_process)
|
|
290
|
+
#
|
|
291
|
+
def assert_step_has_successor(workflow, step_name)
|
|
292
|
+
workflow.reload
|
|
293
|
+
completed = workflow.step_executions.find_by(step_name: step_name.to_s, state: "completed")
|
|
294
|
+
assert completed, "Expected completed execution for step #{step_name}"
|
|
295
|
+
|
|
296
|
+
successor = completed.successor
|
|
297
|
+
assert successor, "Expected successor execution for step #{step_name}, but none found"
|
|
298
|
+
assert successor.scheduled?, "Expected successor to be scheduled, but was #{successor.state}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# === Signal Test Helpers ===
|
|
302
|
+
|
|
303
|
+
# Asserts that the workflow is parked waiting for a signal.
|
|
304
|
+
#
|
|
305
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
306
|
+
# @param signal_name [String, Symbol, nil] the signal name the step waits for
|
|
307
|
+
# @return [void]
|
|
308
|
+
#
|
|
309
|
+
# @example Check that the workflow is waiting
|
|
310
|
+
# assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
311
|
+
#
|
|
312
|
+
def assert_waiting_for_signal(workflow, signal_name = nil)
|
|
313
|
+
workflow.reload
|
|
314
|
+
execution = workflow.step_executions.where(state: "waiting").first
|
|
315
|
+
|
|
316
|
+
assert execution,
|
|
317
|
+
"Expected #{workflow.class.name} to be waiting for a signal, but no parked step execution was found " \
|
|
318
|
+
"(current execution: #{workflow.current_execution&.state.inspect})"
|
|
319
|
+
|
|
320
|
+
return unless signal_name
|
|
321
|
+
|
|
322
|
+
matcher = execution.step_definition&.signal_matcher
|
|
323
|
+
assert_equal signal_name.to_s, matcher_name(matcher),
|
|
324
|
+
"Expected step '#{execution.step_name}' to be waiting for #{signal_name.inspect}, " \
|
|
325
|
+
"but it waits for #{matcher}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Asserts that a signal with the given name exists in the given state.
|
|
329
|
+
#
|
|
330
|
+
# @param workflow [GenevaDrive::Workflow] the workflow to check
|
|
331
|
+
# @param signal_name [String, Symbol] the signal name
|
|
332
|
+
# @param state [String, Symbol] the expected signal state
|
|
333
|
+
# @return [void]
|
|
334
|
+
#
|
|
335
|
+
# @example Check that the signal was consumed
|
|
336
|
+
# assert_signal_state(workflow, :payment_confirmed, :consumed)
|
|
337
|
+
#
|
|
338
|
+
def assert_signal_state(workflow, signal_name, state)
|
|
339
|
+
signal = workflow.signals.where(name: signal_name.to_s).order(:created_at, :id).last
|
|
340
|
+
|
|
341
|
+
assert signal, "Expected a #{signal_name.inspect} signal on #{workflow.class.name}, but none was found"
|
|
342
|
+
assert_equal state.to_s, signal.state,
|
|
343
|
+
"Expected signal #{signal_name.inspect} to be #{state}, but was #{signal.state}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private
|
|
347
|
+
|
|
348
|
+
# The signal name a matcher accepts, when it has one to give. Custom
|
|
349
|
+
# matchers own their whole predicate and need not expose a name.
|
|
350
|
+
#
|
|
351
|
+
# @param matcher [Object, nil] anything responding to #matches?(signal)
|
|
352
|
+
# @return [String, nil]
|
|
353
|
+
def matcher_name(matcher)
|
|
354
|
+
matcher.name if matcher.respond_to?(:name)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Raises a descriptive error when a step-driving helper runs into a parked
|
|
358
|
+
# execution. Without this a parked workflow would look like an infinite
|
|
359
|
+
# loop (or a silently passing assertion) instead of "you forgot to signal".
|
|
360
|
+
#
|
|
361
|
+
# @param step_execution [GenevaDrive::StepExecution] the execution about to be driven
|
|
362
|
+
# @param helper_name [String] the helper that was called
|
|
363
|
+
# @return [void]
|
|
364
|
+
def raise_if_parked!(step_execution, helper_name)
|
|
365
|
+
return unless step_execution.waiting?
|
|
366
|
+
|
|
367
|
+
matcher = begin
|
|
368
|
+
step_execution.step_definition&.signal_matcher
|
|
369
|
+
rescue
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
waiting_for = matcher ? " waiting for #{matcher}" : ""
|
|
373
|
+
example_name = matcher_name(matcher)&.to_sym
|
|
374
|
+
|
|
375
|
+
raise "#{helper_name} cannot drive step '#{step_execution.step_name}': its execution is parked" \
|
|
376
|
+
"#{waiting_for}. Deliver the signal first, e.g. " \
|
|
377
|
+
"workflow.signal!(#{example_name.inspect}), then call #{helper_name} again."
|
|
378
|
+
end
|
|
173
379
|
end
|
data/lib/geneva_drive/version.rb
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Provides safe read/write access to the freeform JSON metadata column
|
|
4
|
+
# on workflows. All "does the column exist?" logic lives here so
|
|
5
|
+
# the rest of the codebase can call read_metadata / write_metadata without
|
|
6
|
+
# guarding. When the metadata column has not been migrated yet, writes
|
|
7
|
+
# are silent no-ops and reads return nil.
|
|
8
|
+
#
|
|
9
|
+
# Once the migration becomes mandatory, delete this concern and replace with
|
|
10
|
+
# a plain `attribute :metadata, :json, default: -> { {} }` on Workflow.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module GenevaDrive::Workflow::MetadataAccessor
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
# Lazily checks whether the metadata column exists. Never hits the
|
|
18
|
+
# database at class definition time — only on the first runtime call.
|
|
19
|
+
#
|
|
20
|
+
# Serialization is handled manually in read/write_metadata rather
|
|
21
|
+
# than via `attribute :metadata, :json` to avoid timing issues —
|
|
22
|
+
# registering the JSON type after instances are already loaded
|
|
23
|
+
# causes those instances to persist Hashes via text-type `#to_s`
|
|
24
|
+
# instead of `#to_json`.
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def metadata_column?
|
|
28
|
+
if defined?(@_metadata_column)
|
|
29
|
+
return @_metadata_column
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@_metadata_column = table_exists? && column_names.include?("metadata")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Clears the cached detection result. Call this in tests or after
|
|
36
|
+
# running migrations in-process so the next access re-checks.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def reset_metadata_column_cache!
|
|
40
|
+
remove_instance_variable(:@_metadata_column) if defined?(@_metadata_column)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads a single key from the metadata hash.
|
|
45
|
+
#
|
|
46
|
+
# @param key [String, Symbol] the metadata key
|
|
47
|
+
# @return [Object, nil] the value, or nil if the column is absent
|
|
48
|
+
def read_metadata(key)
|
|
49
|
+
return nil unless self.class.metadata_column?
|
|
50
|
+
parsed_metadata[key.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Merges a key/value pair into the metadata hash (in-memory only, does
|
|
54
|
+
# not persist — call save!/update! separately or include in a broader
|
|
55
|
+
# update).
|
|
56
|
+
#
|
|
57
|
+
# @param key [String, Symbol] the metadata key
|
|
58
|
+
# @param value [Object] the value to store
|
|
59
|
+
# @return [void]
|
|
60
|
+
def write_metadata(key, value)
|
|
61
|
+
return unless self.class.metadata_column?
|
|
62
|
+
merged = parsed_metadata.merge(key.to_s => value)
|
|
63
|
+
write_attribute(:metadata, merged.to_json)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Returns metadata as a Hash regardless of whether the JSON attribute
|
|
69
|
+
# type has been applied to this instance. On SQLite/MySQL the raw value
|
|
70
|
+
# may still be a JSON string if the record was loaded before the
|
|
71
|
+
# attribute type was lazily registered.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def parsed_metadata
|
|
75
|
+
raw = metadata
|
|
76
|
+
case raw
|
|
77
|
+
when Hash then raw
|
|
78
|
+
when String then JSON.parse(raw)
|
|
79
|
+
when NilClass then {}
|
|
80
|
+
else {}
|
|
81
|
+
end
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
end
|