geneva_drive 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -1
- data/MANUAL.md +176 -0
- data/README.md +2 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/geneva_drive/executor.rb +113 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +82 -1
- data/lib/geneva_drive/step_execution.rb +36 -0
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +327 -22
- data/lib/geneva_drive.rb +22 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +2 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- metadata +11 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The receiver side of the rendezvous, in both arrival orders: the gate that
|
|
6
|
+
# parks or attaches, and the dispatch that wakes a parked execution.
|
|
7
|
+
class SignalRendezvousTest < ActiveSupport::TestCase
|
|
8
|
+
include GenevaDrive::TestHelpers
|
|
9
|
+
include ActiveJob::TestHelper
|
|
10
|
+
|
|
11
|
+
class MinimumAmountMatcher
|
|
12
|
+
def initialize(min_cents:)
|
|
13
|
+
@min_cents = min_cents
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def matches?(signal)
|
|
17
|
+
signal.name == "payment_confirmed" && signal.payload[:amount_cents].to_i >= @min_cents
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class InvoiceWorkflow < GenevaDrive::Workflow
|
|
22
|
+
step :prepare do
|
|
23
|
+
Thread.current[:rendezvous_log] << :prepared
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
27
|
+
Thread.current[:rendezvous_log] << [:captured, received_signal.payload[:amount_cents]]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
step :issue_receipt do
|
|
31
|
+
Thread.current[:rendezvous_log] << :receipt
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class FirstStepWaitsWorkflow < GenevaDrive::Workflow
|
|
36
|
+
step :await_approval, wait_for: :approved do
|
|
37
|
+
Thread.current[:rendezvous_log] << :approved
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class NarrowedWorkflow < GenevaDrive::Workflow
|
|
42
|
+
# A matcher is a plain object, so it can live in a constant and be shared
|
|
43
|
+
FOR_THIS_HERO = GenevaDrive::SignalMatcher.new(:payment_confirmed) do |payload|
|
|
44
|
+
payload[:email] == hero.email
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
step :await_payment, wait_for: FOR_THIS_HERO do
|
|
48
|
+
Thread.current[:rendezvous_log] << :matched
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class MatcherObjectWorkflow < GenevaDrive::Workflow
|
|
53
|
+
step :await_payment, wait_for: MinimumAmountMatcher.new(min_cents: 1000) do
|
|
54
|
+
Thread.current[:rendezvous_log] << :big_enough
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class IdentityWorkflow < GenevaDrive::Workflow
|
|
59
|
+
# The block runs on signal.workflow - record which object that was
|
|
60
|
+
RECORDS_ITS_CONTEXT = GenevaDrive::SignalMatcher.new(:ping) do |_payload|
|
|
61
|
+
(Thread.current[:rendezvous_workflows] ||= []) << self
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
step :await_ping, wait_for: RECORDS_ITS_CONTEXT do
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
step :second_await_ping, wait_for: RECORDS_ITS_CONTEXT do
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class DelayedWaitWorkflow < GenevaDrive::Workflow
|
|
73
|
+
step :await_payment, wait: 2.days, wait_for: :payment_confirmed do
|
|
74
|
+
Thread.current[:rendezvous_log] << :captured
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class TwoWaitersWorkflow < GenevaDrive::Workflow
|
|
79
|
+
step :first_wait, wait_for: :ping do
|
|
80
|
+
Thread.current[:rendezvous_log] << [:first, received_signal.id]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
step :second_wait, wait_for: :ping do
|
|
84
|
+
Thread.current[:rendezvous_log] << [:second, received_signal.id]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class SkippableWaitWorkflow < GenevaDrive::Workflow
|
|
89
|
+
step :await_signature, wait_for: :signed, skip_if: -> { Thread.current[:rendezvous_presigned] } do
|
|
90
|
+
Thread.current[:rendezvous_log] << :signed
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
step :archive do
|
|
94
|
+
Thread.current[:rendezvous_log] << :archived
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class FinishingWaitWorkflow < GenevaDrive::Workflow
|
|
99
|
+
step :await_decision, wait_for: :decided do
|
|
100
|
+
finished!
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
step :never_reached do
|
|
104
|
+
Thread.current[:rendezvous_log] << :never
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class FlakyWaitWorkflow < GenevaDrive::Workflow
|
|
109
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
110
|
+
Thread.current[:rendezvous_log] << [:attempt, received_signal.payload[:amount_cents]]
|
|
111
|
+
raise "gateway unavailable" unless Thread.current[:rendezvous_healed]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class ResumableWaitWorkflow < GenevaDrive::Workflow
|
|
116
|
+
resumable_step :process_items, wait_for: :batch_ready, max_iterations: 2 do |iter|
|
|
117
|
+
iter.iterate_over([1, 2, 3, 4]) do |item|
|
|
118
|
+
Thread.current[:rendezvous_log] << [:item, item, received_signal.payload[:batch_id]]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class MatcherRaisingWorkflow < GenevaDrive::Workflow
|
|
124
|
+
step :await_payment, wait_for: GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| raise "matcher blew up" } do
|
|
125
|
+
Thread.current[:rendezvous_log] << :never
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
setup do
|
|
130
|
+
@user = create_user
|
|
131
|
+
Thread.current[:rendezvous_log] = []
|
|
132
|
+
Thread.current[:rendezvous_presigned] = nil
|
|
133
|
+
Thread.current[:rendezvous_healed] = nil
|
|
134
|
+
Thread.current[:rendezvous_workflows] = nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
teardown do
|
|
138
|
+
Thread.current[:rendezvous_log] = nil
|
|
139
|
+
Thread.current[:rendezvous_presigned] = nil
|
|
140
|
+
Thread.current[:rendezvous_healed] = nil
|
|
141
|
+
Thread.current[:rendezvous_workflows] = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- Receiver arrives first (park, then wake) ---
|
|
145
|
+
|
|
146
|
+
test "a waiting step parks instead of running, and holds no queue slot" do
|
|
147
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
148
|
+
perform_next_step(workflow)
|
|
149
|
+
|
|
150
|
+
execution = workflow.current_execution
|
|
151
|
+
assert_equal "await_payment", execution.step_name
|
|
152
|
+
assert_equal "scheduled", execution.state
|
|
153
|
+
|
|
154
|
+
execution.execute!
|
|
155
|
+
execution.reload
|
|
156
|
+
|
|
157
|
+
assert_equal "waiting", execution.state
|
|
158
|
+
assert_not_nil execution.waiting_since
|
|
159
|
+
assert_nil execution.signal_id
|
|
160
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
161
|
+
assert_equal [:prepared], Thread.current[:rendezvous_log]
|
|
162
|
+
assert_equal "ready", workflow.reload.state
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
test "dispatch wakes the parked execution and the step sees the payload" do
|
|
166
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
167
|
+
perform_next_step(workflow)
|
|
168
|
+
workflow.current_execution.execute!
|
|
169
|
+
|
|
170
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
|
|
171
|
+
|
|
172
|
+
execution = workflow.reload.current_execution
|
|
173
|
+
assert_equal "scheduled", execution.state
|
|
174
|
+
assert_equal signal.id, execution.signal_id
|
|
175
|
+
assert_nil execution.waiting_since
|
|
176
|
+
assert_equal "claimed", signal.reload.state
|
|
177
|
+
assert_not_nil signal.claimed_at
|
|
178
|
+
|
|
179
|
+
perform_next_step(workflow)
|
|
180
|
+
|
|
181
|
+
assert_equal [:prepared, [:captured, 12_500]], Thread.current[:rendezvous_log]
|
|
182
|
+
assert_equal "consumed", signal.reload.state
|
|
183
|
+
assert_not_nil signal.consumed_at
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
test "dispatch enqueues a job for the woken execution" do
|
|
187
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
188
|
+
perform_next_step(workflow)
|
|
189
|
+
workflow.current_execution.execute!
|
|
190
|
+
|
|
191
|
+
assert_enqueued_with(job: GenevaDrive::PerformStepJob) do
|
|
192
|
+
workflow.signal!(:payment_confirmed)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- Sender arrives first (buffer, then attach at the gate) ---
|
|
197
|
+
|
|
198
|
+
test "a signal arriving before the waiting step is buffered and claimed at the gate" do
|
|
199
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
200
|
+
|
|
201
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 700})
|
|
202
|
+
assert_equal "pending", signal.reload.state
|
|
203
|
+
|
|
204
|
+
speedrun_workflow(workflow)
|
|
205
|
+
|
|
206
|
+
assert_equal [:prepared, [:captured, 700], :receipt], Thread.current[:rendezvous_log]
|
|
207
|
+
assert_equal "consumed", signal.reload.state
|
|
208
|
+
assert_equal "finished", workflow.state
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
test "the first step of a workflow can wait for a signal" do
|
|
212
|
+
workflow = FirstStepWaitsWorkflow.create!(hero: @user)
|
|
213
|
+
workflow.current_execution.execute!
|
|
214
|
+
|
|
215
|
+
assert_waiting_for_signal(workflow, :approved)
|
|
216
|
+
|
|
217
|
+
workflow.signal!(:approved)
|
|
218
|
+
speedrun_workflow(workflow)
|
|
219
|
+
|
|
220
|
+
assert_equal [:approved], Thread.current[:rendezvous_log]
|
|
221
|
+
assert_equal "finished", workflow.state
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# --- Matching ---
|
|
225
|
+
|
|
226
|
+
test "a non-matching name leaves the execution parked" do
|
|
227
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
228
|
+
perform_next_step(workflow)
|
|
229
|
+
workflow.current_execution.execute!
|
|
230
|
+
|
|
231
|
+
workflow.signal!(:something_else)
|
|
232
|
+
|
|
233
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
234
|
+
assert_equal "pending", workflow.signals.last.reload.state
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
test "a matcher block narrows by payload and is evaluated on the workflow" do
|
|
238
|
+
workflow = NarrowedWorkflow.create!(hero: @user)
|
|
239
|
+
workflow.current_execution.execute!
|
|
240
|
+
|
|
241
|
+
workflow.signal!(:payment_confirmed, payload: {email: "nobody@example.com"})
|
|
242
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
243
|
+
|
|
244
|
+
workflow.signal!(:payment_confirmed, payload: {email: @user.email})
|
|
245
|
+
assert_equal "scheduled", workflow.reload.current_execution.state
|
|
246
|
+
|
|
247
|
+
speedrun_workflow(workflow)
|
|
248
|
+
assert_equal [:matched], Thread.current[:rendezvous_log]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
test "a matcher block runs against the live workflow instance, not a reloaded copy" do
|
|
252
|
+
workflow = IdentityWorkflow.create!(hero: @user)
|
|
253
|
+
|
|
254
|
+
# Gate side: the executor's own workflow object
|
|
255
|
+
execution = workflow.current_execution
|
|
256
|
+
executor_workflow = execution.workflow
|
|
257
|
+
workflow.signal!(:ping)
|
|
258
|
+
Thread.current[:rendezvous_workflows] = []
|
|
259
|
+
execution.execute!
|
|
260
|
+
|
|
261
|
+
assert_equal 1, Thread.current[:rendezvous_workflows].size
|
|
262
|
+
assert_same executor_workflow, Thread.current[:rendezvous_workflows].first
|
|
263
|
+
|
|
264
|
+
# Dispatch side: the object signal! was called on
|
|
265
|
+
workflow.reload.current_execution.execute!
|
|
266
|
+
assert_waiting_for_signal(workflow, :ping)
|
|
267
|
+
Thread.current[:rendezvous_workflows] = []
|
|
268
|
+
workflow.signal!(:ping)
|
|
269
|
+
|
|
270
|
+
assert_equal 1, Thread.current[:rendezvous_workflows].size
|
|
271
|
+
assert_same workflow, Thread.current[:rendezvous_workflows].first
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
test "a matcher object owns the whole predicate" do
|
|
275
|
+
workflow = MatcherObjectWorkflow.create!(hero: @user)
|
|
276
|
+
workflow.current_execution.execute!
|
|
277
|
+
|
|
278
|
+
workflow.signal!(:payment_confirmed, payload: {amount_cents: 100})
|
|
279
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
280
|
+
|
|
281
|
+
workflow.signal!(:payment_confirmed, payload: {amount_cents: 5000})
|
|
282
|
+
speedrun_workflow(workflow)
|
|
283
|
+
|
|
284
|
+
assert_equal [:big_enough], Thread.current[:rendezvous_log]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
test "the oldest matching signal wins at the gate" do
|
|
288
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
289
|
+
perform_next_step(workflow)
|
|
290
|
+
|
|
291
|
+
first = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
|
|
292
|
+
workflow.signal!(:payment_confirmed, payload: {amount_cents: 2})
|
|
293
|
+
|
|
294
|
+
perform_next_step(workflow)
|
|
295
|
+
|
|
296
|
+
assert_equal [:prepared, [:captured, 1]], Thread.current[:rendezvous_log]
|
|
297
|
+
assert_equal first.id, workflow.step_executions.find_by(step_name: "await_payment").signal_id
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
test "a matcher raising at the gate goes through the exception machinery" do
|
|
301
|
+
workflow = MatcherRaisingWorkflow.create!(hero: @user)
|
|
302
|
+
workflow.signal!(:payment_confirmed)
|
|
303
|
+
|
|
304
|
+
assert_raises(RuntimeError) { perform_next_step(workflow) }
|
|
305
|
+
|
|
306
|
+
assert_equal "paused", workflow.reload.state
|
|
307
|
+
assert_equal [], Thread.current[:rendezvous_log]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
test "a matcher raising at dispatch raises to the sender and rolls the delivery back" do
|
|
311
|
+
workflow = MatcherRaisingWorkflow.create!(hero: @user)
|
|
312
|
+
workflow.current_execution.execute!
|
|
313
|
+
assert_waiting_for_signal(workflow)
|
|
314
|
+
|
|
315
|
+
assert_raises(RuntimeError) { workflow.signal!(:payment_confirmed) }
|
|
316
|
+
|
|
317
|
+
# Delivery is all-or-nothing: nothing was written, so the sender's retry
|
|
318
|
+
# re-delivers rather than finding a half-delivered event
|
|
319
|
+
assert_equal 0, workflow.signals.count
|
|
320
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# --- Composition with other step options ---
|
|
324
|
+
|
|
325
|
+
test "wait: and wait_for: compose" do
|
|
326
|
+
workflow = DelayedWaitWorkflow.create!(hero: @user)
|
|
327
|
+
execution = workflow.current_execution
|
|
328
|
+
|
|
329
|
+
assert execution.scheduled_for > 1.day.from_now
|
|
330
|
+
|
|
331
|
+
# A signal arriving during the delay is simply buffered
|
|
332
|
+
workflow.signal!(:payment_confirmed)
|
|
333
|
+
assert_equal "pending", workflow.signals.last.reload.state
|
|
334
|
+
|
|
335
|
+
execution.execute!
|
|
336
|
+
|
|
337
|
+
assert_equal [:captured], Thread.current[:rendezvous_log]
|
|
338
|
+
assert_equal "consumed", workflow.signals.last.reload.state
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
test "skip_if beats waiting - a skippable step never parks" do
|
|
342
|
+
Thread.current[:rendezvous_presigned] = true
|
|
343
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
344
|
+
|
|
345
|
+
speedrun_workflow(workflow)
|
|
346
|
+
|
|
347
|
+
assert_equal [:archived], Thread.current[:rendezvous_log]
|
|
348
|
+
assert_step_executed(workflow, :await_signature, state: "skipped")
|
|
349
|
+
assert_equal "finished", workflow.state
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
test "skip_if is re-evaluated when the signal wakes the step" do
|
|
353
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
354
|
+
workflow.current_execution.execute!
|
|
355
|
+
assert_waiting_for_signal(workflow, :signed)
|
|
356
|
+
|
|
357
|
+
Thread.current[:rendezvous_presigned] = true
|
|
358
|
+
workflow.signal!(:signed)
|
|
359
|
+
speedrun_workflow(workflow)
|
|
360
|
+
|
|
361
|
+
assert_equal [:archived], Thread.current[:rendezvous_log]
|
|
362
|
+
assert_step_executed(workflow, :await_signature, state: "skipped")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
test "two sequential steps waiting on the same name consume one signal each" do
|
|
366
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
367
|
+
first_signal = workflow.signal!(:ping)
|
|
368
|
+
|
|
369
|
+
perform_next_step(workflow)
|
|
370
|
+
assert_equal [[:first, first_signal.id]], Thread.current[:rendezvous_log]
|
|
371
|
+
assert_equal "consumed", first_signal.reload.state
|
|
372
|
+
|
|
373
|
+
# The second step parks: the only signal so far has been consumed
|
|
374
|
+
workflow.current_execution.execute!
|
|
375
|
+
assert_waiting_for_signal(workflow, :ping)
|
|
376
|
+
|
|
377
|
+
second_signal = workflow.signal!(:ping)
|
|
378
|
+
perform_next_step(workflow)
|
|
379
|
+
|
|
380
|
+
assert_equal [[:first, first_signal.id], [:second, second_signal.id]], Thread.current[:rendezvous_log]
|
|
381
|
+
assert_equal "consumed", second_signal.reload.state
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
test "finished! from a waiting step consumes its signal" do
|
|
385
|
+
workflow = FinishingWaitWorkflow.create!(hero: @user)
|
|
386
|
+
signal = workflow.signal!(:decided)
|
|
387
|
+
|
|
388
|
+
perform_next_step(workflow)
|
|
389
|
+
|
|
390
|
+
assert_equal "finished", workflow.reload.state
|
|
391
|
+
assert_equal "consumed", signal.reload.state
|
|
392
|
+
assert_equal [], Thread.current[:rendezvous_log]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# --- Redelivery across attempts ---
|
|
396
|
+
|
|
397
|
+
test "a reattempt re-reads the same claimed signal" do
|
|
398
|
+
workflow = FlakyWaitWorkflow.create!(hero: @user)
|
|
399
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 42})
|
|
400
|
+
|
|
401
|
+
assert_raises(RuntimeError) { perform_next_step(workflow) }
|
|
402
|
+
assert_equal "paused", workflow.reload.state
|
|
403
|
+
assert_equal "claimed", signal.reload.state
|
|
404
|
+
|
|
405
|
+
Thread.current[:rendezvous_healed] = true
|
|
406
|
+
workflow.resume!
|
|
407
|
+
speedrun_workflow(workflow)
|
|
408
|
+
|
|
409
|
+
assert_equal [[:attempt, 42], [:attempt, 42]], Thread.current[:rendezvous_log]
|
|
410
|
+
assert_equal "consumed", signal.reload.state
|
|
411
|
+
assert_equal "finished", workflow.state
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
test "signals delivered while paused are dispatched on resume" do
|
|
415
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
416
|
+
perform_next_step(workflow)
|
|
417
|
+
workflow.current_execution.execute!
|
|
418
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
419
|
+
|
|
420
|
+
workflow.pause!
|
|
421
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 99})
|
|
422
|
+
|
|
423
|
+
assert_equal "pending", signal.reload.state
|
|
424
|
+
assert_equal "waiting", workflow.reload.current_execution.state
|
|
425
|
+
|
|
426
|
+
workflow.resume!
|
|
427
|
+
|
|
428
|
+
execution = workflow.reload.current_execution
|
|
429
|
+
assert_equal "scheduled", execution.state
|
|
430
|
+
assert_equal signal.id, execution.signal_id
|
|
431
|
+
|
|
432
|
+
speedrun_workflow(workflow)
|
|
433
|
+
assert_equal [:prepared, [:captured, 99], :receipt], Thread.current[:rendezvous_log]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
test "resume! leaves a parked execution parked when nothing matches" do
|
|
437
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
438
|
+
perform_next_step(workflow)
|
|
439
|
+
workflow.current_execution.execute!
|
|
440
|
+
|
|
441
|
+
workflow.pause!
|
|
442
|
+
workflow.resume!
|
|
443
|
+
|
|
444
|
+
assert_equal "ready", workflow.reload.state
|
|
445
|
+
assert_equal "waiting", workflow.current_execution.state
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
test "a workflow paused between dispatch and execution re-attaches on resume" do
|
|
449
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
450
|
+
perform_next_step(workflow)
|
|
451
|
+
workflow.current_execution.execute!
|
|
452
|
+
|
|
453
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 5})
|
|
454
|
+
workflow.pause!
|
|
455
|
+
|
|
456
|
+
# The job runs while paused: prepare cancels the execution
|
|
457
|
+
workflow.step_executions.find_by(state: "scheduled").execute!
|
|
458
|
+
assert_equal "claimed", signal.reload.state
|
|
459
|
+
|
|
460
|
+
workflow.reload.resume!
|
|
461
|
+
speedrun_workflow(workflow)
|
|
462
|
+
|
|
463
|
+
assert_equal [:prepared, [:captured, 5], :receipt], Thread.current[:rendezvous_log]
|
|
464
|
+
assert_equal "consumed", signal.reload.state
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# --- External verbs ---
|
|
468
|
+
|
|
469
|
+
test "cancel! cancels a parked execution and leaves the signal claimed" do
|
|
470
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
471
|
+
perform_next_step(workflow)
|
|
472
|
+
workflow.current_execution.execute!
|
|
473
|
+
|
|
474
|
+
workflow.cancel!
|
|
475
|
+
|
|
476
|
+
assert_equal "canceled", workflow.reload.state
|
|
477
|
+
assert_equal "canceled", workflow.step_executions.find_by(step_name: "await_payment").state
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
test "skip! is the operator escape hatch for a parked step" do
|
|
481
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
482
|
+
perform_next_step(workflow)
|
|
483
|
+
workflow.current_execution.execute!
|
|
484
|
+
assert_waiting_for_signal(workflow)
|
|
485
|
+
|
|
486
|
+
workflow.skip!
|
|
487
|
+
|
|
488
|
+
assert_step_executed(workflow, :await_payment, state: "skipped")
|
|
489
|
+
speedrun_workflow(workflow)
|
|
490
|
+
|
|
491
|
+
assert_equal [:prepared, :receipt], Thread.current[:rendezvous_log]
|
|
492
|
+
assert_equal "finished", workflow.state
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
test "a parked execution is swept when a new execution is created past it" do
|
|
496
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
497
|
+
perform_next_step(workflow)
|
|
498
|
+
parked = workflow.current_execution
|
|
499
|
+
parked.execute!
|
|
500
|
+
assert_equal "waiting", parked.reload.state
|
|
501
|
+
|
|
502
|
+
workflow.reschedule_current_step!
|
|
503
|
+
|
|
504
|
+
assert_equal "canceled", parked.reload.state
|
|
505
|
+
assert_equal 1, workflow.step_executions.where(state: "scheduled").count
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# --- Resumable chains ---
|
|
509
|
+
|
|
510
|
+
test "a resumable step gates once and carries the pin across successors" do
|
|
511
|
+
workflow = ResumableWaitWorkflow.create!(hero: @user)
|
|
512
|
+
signal = workflow.signal!(:batch_ready, payload: {batch_id: "b1"})
|
|
513
|
+
|
|
514
|
+
# Interrupt after two items so the step continues through a successor
|
|
515
|
+
run_iterations(workflow, count: 2)
|
|
516
|
+
successor = workflow.reload.current_execution
|
|
517
|
+
assert_equal signal.id, successor.signal_id
|
|
518
|
+
assert_equal "scheduled", successor.state
|
|
519
|
+
|
|
520
|
+
speedrun_current_step(workflow)
|
|
521
|
+
|
|
522
|
+
logged_batches = Thread.current[:rendezvous_log].map(&:last).uniq
|
|
523
|
+
assert_equal ["b1"], logged_batches
|
|
524
|
+
assert_equal 4, Thread.current[:rendezvous_log].size
|
|
525
|
+
|
|
526
|
+
executions = workflow.step_executions.where(step_name: "process_items").order(:id)
|
|
527
|
+
assert executions.count > 1, "expected a chain of executions"
|
|
528
|
+
assert executions.all? { |execution| execution.signal_id == signal.id }
|
|
529
|
+
assert_equal "consumed", signal.reload.state
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# --- Duplicates ---
|
|
533
|
+
|
|
534
|
+
test "a duplicate signal delivered while one is claimed just buffers" do
|
|
535
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
536
|
+
perform_next_step(workflow)
|
|
537
|
+
workflow.current_execution.execute!
|
|
538
|
+
|
|
539
|
+
claimed = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
|
|
540
|
+
buffered = workflow.signal!(:payment_confirmed, payload: {amount_cents: 2})
|
|
541
|
+
|
|
542
|
+
assert_equal "claimed", claimed.reload.state
|
|
543
|
+
assert_equal "pending", buffered.reload.state
|
|
544
|
+
|
|
545
|
+
perform_next_step(workflow)
|
|
546
|
+
|
|
547
|
+
assert_equal [:prepared, [:captured, 1]], Thread.current[:rendezvous_log]
|
|
548
|
+
assert_equal "consumed", claimed.reload.state
|
|
549
|
+
assert_equal "pending", buffered.reload.state
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
test "a deduplicated redelivery does not dispatch twice" do
|
|
553
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
554
|
+
perform_next_step(workflow)
|
|
555
|
+
workflow.current_execution.execute!
|
|
556
|
+
|
|
557
|
+
workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
|
|
558
|
+
execution_id = workflow.reload.current_execution.id
|
|
559
|
+
|
|
560
|
+
duplicate = workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
|
|
561
|
+
|
|
562
|
+
assert duplicate.duplicate_delivery?
|
|
563
|
+
assert_equal execution_id, workflow.reload.current_execution.id
|
|
564
|
+
assert_equal 1, workflow.signals.count
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# --- Test helper guardrails ---
|
|
568
|
+
|
|
569
|
+
test "step-driving helpers refuse to spin on a parked execution" do
|
|
570
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
571
|
+
perform_next_step(workflow)
|
|
572
|
+
workflow.current_execution.execute!
|
|
573
|
+
|
|
574
|
+
error = assert_raises(RuntimeError) { speedrun_workflow(workflow) }
|
|
575
|
+
assert_match(/parked/, error.message)
|
|
576
|
+
assert_match(/payment_confirmed/, error.message)
|
|
577
|
+
|
|
578
|
+
error = assert_raises(RuntimeError) { perform_next_step(workflow) }
|
|
579
|
+
assert_match(/parked/, error.message)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "minitest/mock"
|
|
5
|
+
|
|
6
|
+
# Deployments usually ship gem updates before running migrations. Everything
|
|
7
|
+
# except actually waiting for a signal must keep working when the signal_id
|
|
8
|
+
# and waiting_since columns are absent (simulated here by stubbing the column
|
|
9
|
+
# detection, like the resumable step tests do).
|
|
10
|
+
class SignalsWithoutMigrationTest < ActiveSupport::TestCase
|
|
11
|
+
include GenevaDrive::TestHelpers
|
|
12
|
+
|
|
13
|
+
class PlainWorkflow < GenevaDrive::Workflow
|
|
14
|
+
step :one do
|
|
15
|
+
Thread.current[:no_sig_one_ran] = true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
step :two do
|
|
19
|
+
Thread.current[:no_sig_two_ran] = true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class WaitingWorkflow < GenevaDrive::Workflow
|
|
24
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
25
|
+
Thread.current[:no_sig_waited] = true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
setup do
|
|
30
|
+
@user = create_user
|
|
31
|
+
reset_thread_tracking!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
teardown do
|
|
35
|
+
reset_thread_tracking!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset_thread_tracking!
|
|
39
|
+
%i[no_sig_one_ran no_sig_two_ran no_sig_waited].each { |key| Thread.current[key] = nil }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test "regular workflows run to completion without the signal columns" do
|
|
43
|
+
GenevaDrive::StepExecution.stub(:signal_columns?, false) do
|
|
44
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
45
|
+
speedrun_workflow(workflow)
|
|
46
|
+
|
|
47
|
+
assert_equal "finished", workflow.state
|
|
48
|
+
assert Thread.current[:no_sig_one_ran]
|
|
49
|
+
assert Thread.current[:no_sig_two_ran]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
test "executing a waiting step without the columns fails with a clear error" do
|
|
54
|
+
GenevaDrive::StepExecution.stub(:signal_columns?, false) do
|
|
55
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
56
|
+
|
|
57
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { perform_next_step(workflow) }
|
|
58
|
+
assert_match(/geneva_drive:install/, error.message)
|
|
59
|
+
|
|
60
|
+
workflow.reload
|
|
61
|
+
assert_equal "paused", workflow.state
|
|
62
|
+
failed = workflow.step_executions.find_by(state: "failed")
|
|
63
|
+
assert_not_nil failed
|
|
64
|
+
assert_match(/wait_for:/, failed.error_message)
|
|
65
|
+
assert_not Thread.current[:no_sig_waited]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "signal! still records the event without the columns, it just cannot dispatch" do
|
|
70
|
+
workflow = WaitingWorkflow.create!(hero: @user)
|
|
71
|
+
|
|
72
|
+
GenevaDrive::StepExecution.stub(:signal_columns?, false) do
|
|
73
|
+
signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
|
|
74
|
+
|
|
75
|
+
assert signal.persisted?
|
|
76
|
+
assert_equal "pending", signal.reload.state
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "housekeeping skips the waiting gauges without the columns" do
|
|
81
|
+
GenevaDrive::StepExecution.stub(:signal_columns?, false) do
|
|
82
|
+
assert_nothing_raised { GenevaDrive::HousekeepingJob.perform_now }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "housekeeping skips the signal delete pass without the signals table" do
|
|
87
|
+
original = GenevaDrive.delete_completed_workflows_after
|
|
88
|
+
GenevaDrive.delete_completed_workflows_after = 30.days
|
|
89
|
+
|
|
90
|
+
GenevaDrive::Signal.stub(:table_available?, false) do
|
|
91
|
+
results = GenevaDrive::HousekeepingJob.perform_now
|
|
92
|
+
assert_equal 0, results[:signals_cleaned_up]
|
|
93
|
+
end
|
|
94
|
+
ensure
|
|
95
|
+
GenevaDrive.delete_completed_workflows_after = original
|
|
96
|
+
end
|
|
97
|
+
end
|