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,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Claim and consumption accounting. A signal stays claimed - and therefore
|
|
6
|
+
# attachable - while any attached execution chain is unresolved, and flips to
|
|
7
|
+
# consumed when the last one resolves cleanly. With a single claimant that is
|
|
8
|
+
# the same moment as the step completing; the counters are what make a
|
|
9
|
+
# one-to-many dispatch legible.
|
|
10
|
+
class SignalConsumptionTest < ActiveSupport::TestCase
|
|
11
|
+
include GenevaDrive::TestHelpers
|
|
12
|
+
|
|
13
|
+
class InvoiceWorkflow < GenevaDrive::Workflow
|
|
14
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
15
|
+
raise "gateway unavailable" unless Thread.current[:consumption_healed]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
step :issue_receipt do
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class SkippingWorkflow < GenevaDrive::Workflow
|
|
23
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
24
|
+
skip!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class TwoWaitersWorkflow < GenevaDrive::Workflow
|
|
29
|
+
step :first_wait, wait_for: :ping do
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
step :second_wait, wait_for: :ping do
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class ResumableWaitWorkflow < GenevaDrive::Workflow
|
|
37
|
+
resumable_step :process_items, wait_for: :batch_ready, max_iterations: 2 do |iter|
|
|
38
|
+
iter.iterate_over([1, 2, 3, 4]) { |item| }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class SkippableWaitWorkflow < GenevaDrive::Workflow
|
|
43
|
+
step :await_payment, wait_for: :payment_confirmed, skip_if: -> { Thread.current[:consumption_presigned] } do
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
step :await_payment_again, wait_for: :payment_confirmed do
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class FailingWaitWorkflow < GenevaDrive::Workflow
|
|
51
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
52
|
+
raise "gateway unavailable"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
step :issue_receipt do
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
setup do
|
|
60
|
+
@user = create_user
|
|
61
|
+
Thread.current[:consumption_healed] = true
|
|
62
|
+
Thread.current[:consumption_presigned] = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
teardown do
|
|
66
|
+
Thread.current[:consumption_healed] = nil
|
|
67
|
+
Thread.current[:consumption_presigned] = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Builds a step execution attached to a signal, the way dispatch or the gate
|
|
71
|
+
# would. Used to stage attachment shapes that only the DAG scheduler can
|
|
72
|
+
# produce for real.
|
|
73
|
+
def attach_execution(workflow, signal, step_name:, state:, outcome: nil)
|
|
74
|
+
signal.claim!
|
|
75
|
+
workflow.step_executions.create!(
|
|
76
|
+
step_name: step_name,
|
|
77
|
+
state: state,
|
|
78
|
+
scheduled_for: Time.current,
|
|
79
|
+
waiting_since: (state == "waiting") ? Time.current : nil,
|
|
80
|
+
outcome: outcome,
|
|
81
|
+
signal_id: signal.id
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
test "a clean single-claimant run counts one claim and one consumption" do
|
|
86
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
87
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
88
|
+
|
|
89
|
+
assert_equal 0, signal.claimed
|
|
90
|
+
assert_equal 0, signal.consumed
|
|
91
|
+
|
|
92
|
+
perform_next_step(workflow)
|
|
93
|
+
signal.reload
|
|
94
|
+
|
|
95
|
+
assert_equal "consumed", signal.state
|
|
96
|
+
assert_equal 1, signal.claimed
|
|
97
|
+
assert_equal 1, signal.consumed
|
|
98
|
+
assert_not_nil signal.claimed_at
|
|
99
|
+
assert_not_nil signal.consumed_at
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
test "a skipping step consumes its signal too" do
|
|
103
|
+
workflow = SkippingWorkflow.create!(hero: @user)
|
|
104
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
105
|
+
|
|
106
|
+
perform_next_step(workflow)
|
|
107
|
+
signal.reload
|
|
108
|
+
|
|
109
|
+
assert_equal "consumed", signal.state
|
|
110
|
+
assert_equal 1, signal.consumed
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
test "a retry re-attaching counts a second claim but only one consumption" do
|
|
114
|
+
Thread.current[:consumption_healed] = false
|
|
115
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
116
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
117
|
+
|
|
118
|
+
assert_raises(RuntimeError) { perform_next_step(workflow) }
|
|
119
|
+
signal.reload
|
|
120
|
+
|
|
121
|
+
assert_equal "claimed", signal.state
|
|
122
|
+
assert_equal 1, signal.claimed
|
|
123
|
+
assert_equal 0, signal.consumed
|
|
124
|
+
first_claimed_at = signal.claimed_at
|
|
125
|
+
|
|
126
|
+
Thread.current[:consumption_healed] = true
|
|
127
|
+
workflow.resume!
|
|
128
|
+
speedrun_workflow(workflow)
|
|
129
|
+
signal.reload
|
|
130
|
+
|
|
131
|
+
assert_equal "consumed", signal.state
|
|
132
|
+
assert_equal 2, signal.claimed
|
|
133
|
+
assert_equal 1, signal.consumed
|
|
134
|
+
# claimed_at keeps meaning "when this event started being handled"
|
|
135
|
+
assert_equal first_claimed_at.to_i, signal.claimed_at.to_i
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
test "successor executions carry the pin over without counting a new claim" do
|
|
139
|
+
workflow = ResumableWaitWorkflow.create!(hero: @user)
|
|
140
|
+
signal = workflow.signal!(:batch_ready, payload: {batch_id: "b1"})
|
|
141
|
+
|
|
142
|
+
run_iterations(workflow, count: 2)
|
|
143
|
+
assert_equal 1, signal.reload.claimed
|
|
144
|
+
assert_equal 0, signal.consumed
|
|
145
|
+
|
|
146
|
+
speedrun_current_step(workflow)
|
|
147
|
+
signal.reload
|
|
148
|
+
|
|
149
|
+
assert_operator workflow.step_executions.where(signal_id: signal.id).count, :>, 1
|
|
150
|
+
assert_equal 1, signal.claimed
|
|
151
|
+
assert_equal 1, signal.consumed
|
|
152
|
+
assert_equal "consumed", signal.state
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
test "the signal stays claimed while another attached execution is still active" do
|
|
156
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
157
|
+
workflow.current_execution.execute!
|
|
158
|
+
assert_waiting_for_signal(workflow, :ping)
|
|
159
|
+
|
|
160
|
+
signal = workflow.signal!(:ping)
|
|
161
|
+
assert_equal 1, signal.reload.claimed
|
|
162
|
+
|
|
163
|
+
# Stage a second attached execution, as a DAG salvo would produce
|
|
164
|
+
attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
|
|
165
|
+
assert_equal 2, signal.reload.claimed
|
|
166
|
+
|
|
167
|
+
perform_next_step(workflow)
|
|
168
|
+
signal.reload
|
|
169
|
+
|
|
170
|
+
assert_equal "claimed", signal.state
|
|
171
|
+
assert_equal 1, signal.consumed
|
|
172
|
+
assert_nil signal.consumed_at
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
test "the flip happens when the last attached chain resolves" do
|
|
176
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
177
|
+
signal = workflow.signal!(:ping)
|
|
178
|
+
|
|
179
|
+
first = attach_execution(workflow, signal, step_name: "first_wait", state: "waiting")
|
|
180
|
+
second = attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
|
|
181
|
+
assert_equal 2, signal.reload.claimed
|
|
182
|
+
|
|
183
|
+
first.update!(state: "completed", outcome: "success", completed_at: Time.current)
|
|
184
|
+
signal.record_consumption!
|
|
185
|
+
|
|
186
|
+
assert_equal "claimed", signal.state
|
|
187
|
+
assert_equal 1, signal.consumed
|
|
188
|
+
|
|
189
|
+
second.update!(state: "skipped", outcome: "skipped", skipped_at: Time.current)
|
|
190
|
+
signal.record_consumption!
|
|
191
|
+
|
|
192
|
+
assert_equal "consumed", signal.state
|
|
193
|
+
assert_equal 2, signal.consumed
|
|
194
|
+
assert_not_nil signal.consumed_at
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
test "claimed? and consumed? read the counters, not the lifecycle state" do
|
|
198
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
199
|
+
signal = workflow.signal!(:ping)
|
|
200
|
+
|
|
201
|
+
assert_equal "pending", signal.state
|
|
202
|
+
assert_not signal.claimed?
|
|
203
|
+
assert_not signal.consumed?
|
|
204
|
+
|
|
205
|
+
execution = attach_execution(workflow, signal, step_name: "first_wait", state: "waiting")
|
|
206
|
+
signal.reload
|
|
207
|
+
|
|
208
|
+
assert_equal "claimed", signal.state
|
|
209
|
+
assert signal.claimed?
|
|
210
|
+
assert_not signal.consumed?
|
|
211
|
+
|
|
212
|
+
# A second chain keeps the state at claimed, but the first clean
|
|
213
|
+
# resolution already makes consumed? true
|
|
214
|
+
second = attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
|
|
215
|
+
execution.update!(state: "completed", outcome: "success")
|
|
216
|
+
signal.record_consumption!
|
|
217
|
+
|
|
218
|
+
assert_equal "claimed", signal.state
|
|
219
|
+
assert signal.consumed?
|
|
220
|
+
assert_equal 1, signal.consumed
|
|
221
|
+
|
|
222
|
+
second.update!(state: "completed", outcome: "success")
|
|
223
|
+
signal.record_consumption!
|
|
224
|
+
|
|
225
|
+
assert_equal "consumed", signal.state
|
|
226
|
+
assert signal.claimed?
|
|
227
|
+
assert signal.consumed?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
test "the state enum generates neither predicates nor scopes" do
|
|
231
|
+
assert_not GenevaDrive::Signal.new.respond_to?(:pending?)
|
|
232
|
+
assert_not GenevaDrive::Signal.respond_to?(:pending)
|
|
233
|
+
|
|
234
|
+
# The names the enum would have taken belong to the counters
|
|
235
|
+
assert_equal 0, GenevaDrive::Signal.new.claimed
|
|
236
|
+
assert_equal 0, GenevaDrive::Signal.new.consumed
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# === Skips settle the signal they were delivered ===
|
|
240
|
+
|
|
241
|
+
test "skip_if firing at wake consumes the signal that woke the step" do
|
|
242
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
243
|
+
workflow.current_execution.execute!
|
|
244
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
245
|
+
|
|
246
|
+
# The condition flips while the step sits parked, then the event arrives
|
|
247
|
+
Thread.current[:consumption_presigned] = true
|
|
248
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
249
|
+
assert_equal "claimed", signal.reload.state
|
|
250
|
+
assert_equal signal.id, workflow.reload.current_execution.signal_id
|
|
251
|
+
|
|
252
|
+
perform_next_step(workflow)
|
|
253
|
+
signal.reload
|
|
254
|
+
|
|
255
|
+
assert_step_executed(workflow, :await_payment, state: "skipped")
|
|
256
|
+
assert_equal "consumed", signal.state
|
|
257
|
+
assert_equal 1, signal.claimed
|
|
258
|
+
assert_equal 1, signal.consumed
|
|
259
|
+
assert_not_nil signal.consumed_at
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
test "a step skipped at wake does not hand its stale event to the next waiter" do
|
|
263
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
264
|
+
workflow.current_execution.execute!
|
|
265
|
+
|
|
266
|
+
Thread.current[:consumption_presigned] = true
|
|
267
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
268
|
+
perform_next_step(workflow)
|
|
269
|
+
|
|
270
|
+
# The second step waits on the same name and must park: the only event so
|
|
271
|
+
# far was settled by the skip
|
|
272
|
+
workflow.reload.current_execution.execute!
|
|
273
|
+
|
|
274
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
275
|
+
assert_nil workflow.reload.current_execution.signal_id
|
|
276
|
+
assert_equal 1, signal.reload.consumed
|
|
277
|
+
|
|
278
|
+
# ... and a fresh event moves it along
|
|
279
|
+
fresh = workflow.signal!(:payment_confirmed)
|
|
280
|
+
perform_next_step(workflow)
|
|
281
|
+
|
|
282
|
+
assert_equal "consumed", fresh.reload.state
|
|
283
|
+
assert_equal "finished", workflow.reload.state
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
test "external skip! settles the signal of a dispatched execution" do
|
|
287
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
288
|
+
workflow.current_execution.execute!
|
|
289
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
290
|
+
|
|
291
|
+
# Dispatched, attached, but its job never ran
|
|
292
|
+
assert_equal "scheduled", workflow.reload.current_execution.state
|
|
293
|
+
workflow.skip!
|
|
294
|
+
signal.reload
|
|
295
|
+
|
|
296
|
+
assert_step_executed(workflow, :await_payment, state: "skipped")
|
|
297
|
+
assert_equal "consumed", signal.state
|
|
298
|
+
assert_equal 1, signal.consumed
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
test "external skip! on a paused workflow settles the claim its failed step held" do
|
|
302
|
+
workflow = FailingWaitWorkflow.create!(hero: @user)
|
|
303
|
+
signal = workflow.signal!(:payment_confirmed)
|
|
304
|
+
|
|
305
|
+
assert_raises(RuntimeError) { perform_next_step(workflow) }
|
|
306
|
+
assert_equal "paused", workflow.reload.state
|
|
307
|
+
assert_equal "claimed", signal.reload.state
|
|
308
|
+
assert_equal 1, signal.claimed
|
|
309
|
+
assert_equal 0, signal.consumed
|
|
310
|
+
|
|
311
|
+
workflow.skip!
|
|
312
|
+
signal.reload
|
|
313
|
+
|
|
314
|
+
assert_equal "consumed", signal.state
|
|
315
|
+
assert_equal 1, signal.consumed
|
|
316
|
+
assert_not_nil signal.consumed_at
|
|
317
|
+
assert_equal "issue_receipt", workflow.reload.next_step_name
|
|
318
|
+
assert_equal "scheduled", workflow.current_execution.state
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
test "skipping at park time leaves a buffered signal pending" do
|
|
322
|
+
Thread.current[:consumption_presigned] = true
|
|
323
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
324
|
+
buffered = workflow.signal!(:unrelated_event)
|
|
325
|
+
|
|
326
|
+
# The step never attaches - skip_if is evaluated before the gate
|
|
327
|
+
perform_next_step(workflow)
|
|
328
|
+
|
|
329
|
+
assert_step_executed(workflow, :await_payment, state: "skipped")
|
|
330
|
+
buffered.reload
|
|
331
|
+
assert_equal "pending", buffered.state
|
|
332
|
+
assert_equal 0, buffered.claimed
|
|
333
|
+
assert_equal 0, buffered.consumed
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
test "external skip! of an unattached parked execution touches no buffered signal" do
|
|
337
|
+
workflow = SkippableWaitWorkflow.create!(hero: @user)
|
|
338
|
+
workflow.current_execution.execute!
|
|
339
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
340
|
+
buffered = workflow.signal!(:unrelated_event)
|
|
341
|
+
|
|
342
|
+
workflow.skip!
|
|
343
|
+
|
|
344
|
+
buffered.reload
|
|
345
|
+
assert_equal "pending", buffered.state
|
|
346
|
+
assert_equal 0, buffered.consumed
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
test "a signal with no attachments is vacuously resolved" do
|
|
350
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
351
|
+
|
|
352
|
+
assert workflow.signal!(:ping).fully_resolved?
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
test "an unresolved chain keeps the signal from being fully resolved" do
|
|
356
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
357
|
+
signal = workflow.signal!(:ping)
|
|
358
|
+
# Free up the one-active slot so each staged state can occupy it in turn
|
|
359
|
+
workflow.step_executions.update_all(state: "canceled", outcome: "canceled")
|
|
360
|
+
|
|
361
|
+
%w[waiting scheduled in_progress].each_with_index do |state, index|
|
|
362
|
+
execution = attach_execution(workflow, signal, step_name: "node_#{index}", state: state)
|
|
363
|
+
assert_not signal.fully_resolved?, "expected #{state} to count as unresolved"
|
|
364
|
+
execution.update!(state: "completed", outcome: "success")
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
assert signal.fully_resolved?
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
test "a chain whose latest execution failed or was canceled keeps the signal claimed" do
|
|
371
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
372
|
+
signal = workflow.signal!(:ping)
|
|
373
|
+
execution = attach_execution(workflow, signal, step_name: "first_wait", state: "failed", outcome: "failed")
|
|
374
|
+
|
|
375
|
+
assert_not signal.fully_resolved?
|
|
376
|
+
|
|
377
|
+
execution.update!(state: "canceled", outcome: "canceled")
|
|
378
|
+
assert_not signal.fully_resolved?
|
|
379
|
+
|
|
380
|
+
execution.update!(state: "completed", outcome: "success")
|
|
381
|
+
assert signal.fully_resolved?
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
test "only the latest execution of a chain decides whether it resolved" do
|
|
385
|
+
workflow = TwoWaitersWorkflow.create!(hero: @user)
|
|
386
|
+
signal = workflow.signal!(:ping)
|
|
387
|
+
|
|
388
|
+
attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "continued")
|
|
389
|
+
attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "reattempted")
|
|
390
|
+
|
|
391
|
+
assert_not signal.fully_resolved?
|
|
392
|
+
|
|
393
|
+
attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "success")
|
|
394
|
+
|
|
395
|
+
assert signal.fully_resolved?
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The sender side of the rendezvous: Workflow#signal! and the Signal record
|
|
6
|
+
# itself. The receiver side (gate, park, wake) lives in
|
|
7
|
+
# signal_rendezvous_test.rb.
|
|
8
|
+
class SignalDeliveryTest < ActiveSupport::TestCase
|
|
9
|
+
include GenevaDrive::TestHelpers
|
|
10
|
+
|
|
11
|
+
class InvoiceWorkflow < GenevaDrive::Workflow
|
|
12
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
13
|
+
Thread.current[:signal_delivery_amount] = received_signal.payload[:amount_cents]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
step :issue_receipt do
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class PlainWorkflow < GenevaDrive::Workflow
|
|
21
|
+
step :only_step do
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
setup do
|
|
26
|
+
@user = create_user
|
|
27
|
+
Thread.current[:signal_delivery_amount] = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
teardown do
|
|
31
|
+
Thread.current[:signal_delivery_amount] = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
test "signal! persists a pending signal" do
|
|
35
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
36
|
+
|
|
37
|
+
signal = workflow.signal!(:something_happened, payload: {order_id: 42})
|
|
38
|
+
|
|
39
|
+
assert signal.persisted?
|
|
40
|
+
assert_equal "something_happened", signal.name
|
|
41
|
+
assert_equal "pending", signal.state
|
|
42
|
+
assert_nil signal.claimed_at
|
|
43
|
+
assert_nil signal.consumed_at
|
|
44
|
+
assert_not signal.duplicate_delivery?
|
|
45
|
+
assert_equal workflow.id, signal.workflow_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
test "payload comes back with indifferent access" do
|
|
49
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
50
|
+
|
|
51
|
+
workflow.signal!(:webhook, payload: {"order_id" => 42, :amount_cents => 100})
|
|
52
|
+
signal = workflow.signals.last.reload
|
|
53
|
+
|
|
54
|
+
assert_equal 42, signal.payload[:order_id]
|
|
55
|
+
assert_equal 42, signal.payload["order_id"]
|
|
56
|
+
assert_equal 100, signal.payload[:amount_cents]
|
|
57
|
+
assert_equal 100, signal.payload["amount_cents"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
test "payload round-trips types through ActiveJob serialization" do
|
|
61
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
62
|
+
moment = Time.current.change(usec: 0)
|
|
63
|
+
|
|
64
|
+
workflow.signal!(:webhook, payload: {at: moment, hero: @user})
|
|
65
|
+
signal = workflow.signals.last.reload
|
|
66
|
+
|
|
67
|
+
assert_equal moment, signal.payload[:at]
|
|
68
|
+
assert_equal @user, signal.payload[:hero]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test "non-hash payloads are returned as-is" do
|
|
72
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
73
|
+
|
|
74
|
+
workflow.signal!(:webhook, payload: "just a string")
|
|
75
|
+
|
|
76
|
+
assert_equal "just a string", workflow.signals.last.reload.payload
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
test "an empty payload is the default" do
|
|
80
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
81
|
+
|
|
82
|
+
signal = workflow.signal!(:webhook)
|
|
83
|
+
|
|
84
|
+
assert_equal({}, signal.reload.payload)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
test "signal! requires a name" do
|
|
88
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
89
|
+
|
|
90
|
+
assert_raises(ArgumentError) { workflow.signal!(nil) }
|
|
91
|
+
assert_raises(ArgumentError) { workflow.signal!("") }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
test "signal! rejects unknown options so the keyword surface stays usable" do
|
|
95
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
96
|
+
|
|
97
|
+
error = assert_raises(ArgumentError) { workflow.signal!(:webhook, timeout: 5.minutes) }
|
|
98
|
+
assert_match(/timeout/, error.message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
test "an oversized payload raises before anything is persisted" do
|
|
102
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
103
|
+
original = GenevaDrive.max_signal_payload_size
|
|
104
|
+
GenevaDrive.max_signal_payload_size = 64
|
|
105
|
+
|
|
106
|
+
error = assert_raises(GenevaDrive::SignalPayloadTooLargeError) do
|
|
107
|
+
workflow.signal!(:webhook, payload: {blob: "x" * 1024})
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
assert_match(/max_signal_payload_size/, error.message)
|
|
111
|
+
assert_equal 0, workflow.signals.count
|
|
112
|
+
ensure
|
|
113
|
+
GenevaDrive.max_signal_payload_size = original
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
test "the payload bound can be disabled" do
|
|
117
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
118
|
+
original = GenevaDrive.max_signal_payload_size
|
|
119
|
+
GenevaDrive.max_signal_payload_size = nil
|
|
120
|
+
|
|
121
|
+
signal = workflow.signal!(:webhook, payload: {blob: "x" * 1024})
|
|
122
|
+
|
|
123
|
+
assert signal.persisted?
|
|
124
|
+
ensure
|
|
125
|
+
GenevaDrive.max_signal_payload_size = original
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
test "redelivery with the same idempotency key returns the original row" do
|
|
129
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
130
|
+
|
|
131
|
+
first = workflow.signal!(:webhook, payload: {n: 1}, idempotency_key: "evt_1")
|
|
132
|
+
second = workflow.signal!(:webhook, payload: {n: 2}, idempotency_key: "evt_1")
|
|
133
|
+
|
|
134
|
+
assert_equal first.id, second.id
|
|
135
|
+
assert second.duplicate_delivery?
|
|
136
|
+
assert_not first.duplicate_delivery?
|
|
137
|
+
assert_equal 1, workflow.signals.count
|
|
138
|
+
# The first payload wins - the duplicate is a no-op, not an update
|
|
139
|
+
assert_equal 1, second.payload[:n]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
test "the same idempotency key under a different name is a different signal" do
|
|
143
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
144
|
+
|
|
145
|
+
workflow.signal!(:webhook_a, idempotency_key: "evt_1")
|
|
146
|
+
workflow.signal!(:webhook_b, idempotency_key: "evt_1")
|
|
147
|
+
|
|
148
|
+
assert_equal 2, workflow.signals.count
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test "the same idempotency key on a different workflow is a different signal" do
|
|
152
|
+
other_user = create_user(email: "other@example.com")
|
|
153
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
154
|
+
other_workflow = PlainWorkflow.create!(hero: other_user)
|
|
155
|
+
|
|
156
|
+
workflow.signal!(:webhook, idempotency_key: "evt_1")
|
|
157
|
+
other_workflow.signal!(:webhook, idempotency_key: "evt_1")
|
|
158
|
+
|
|
159
|
+
assert_equal 1, workflow.signals.count
|
|
160
|
+
assert_equal 1, other_workflow.signals.count
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
test "signals without an idempotency key never deduplicate" do
|
|
164
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
165
|
+
|
|
166
|
+
workflow.signal!(:webhook)
|
|
167
|
+
workflow.signal!(:webhook)
|
|
168
|
+
|
|
169
|
+
assert_equal 2, workflow.signals.count
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
test "signal! on a finished workflow raises WorkflowNotOngoing" do
|
|
173
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
174
|
+
speedrun_workflow(workflow)
|
|
175
|
+
assert_equal "finished", workflow.state
|
|
176
|
+
|
|
177
|
+
error = assert_raises(GenevaDrive::WorkflowNotOngoing) { workflow.signal!(:webhook) }
|
|
178
|
+
|
|
179
|
+
assert_match(/finished workflow/, error.message)
|
|
180
|
+
assert_equal 0, workflow.signals.count
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
test "signal! on a canceled workflow raises WorkflowNotOngoing" do
|
|
184
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
185
|
+
workflow.cancel!
|
|
186
|
+
|
|
187
|
+
assert_raises(GenevaDrive::WorkflowNotOngoing) { workflow.signal!(:webhook) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
test "redelivery of the event that finished the workflow is a no-op" do
|
|
191
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
192
|
+
workflow.signal!(:payment_confirmed, payload: {amount_cents: 500}, idempotency_key: "evt_1")
|
|
193
|
+
speedrun_workflow(workflow)
|
|
194
|
+
assert_equal "finished", workflow.state
|
|
195
|
+
|
|
196
|
+
redelivered = workflow.signal!(:payment_confirmed, payload: {amount_cents: 500}, idempotency_key: "evt_1")
|
|
197
|
+
|
|
198
|
+
assert redelivered.duplicate_delivery?
|
|
199
|
+
assert_equal 1, workflow.signals.count
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
test "a new event on a finished workflow still raises even when other signals exist" do
|
|
203
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
204
|
+
workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
|
|
205
|
+
speedrun_workflow(workflow)
|
|
206
|
+
|
|
207
|
+
assert_raises(GenevaDrive::WorkflowNotOngoing) do
|
|
208
|
+
workflow.signal!(:payment_confirmed, idempotency_key: "evt_2")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
test "signal! survives an enclosing transaction thanks to the savepoint" do
|
|
213
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
214
|
+
workflow.signal!(:webhook, idempotency_key: "evt_1")
|
|
215
|
+
|
|
216
|
+
GenevaDrive::Workflow.transaction do
|
|
217
|
+
duplicate = workflow.signal!(:webhook, idempotency_key: "evt_1")
|
|
218
|
+
assert duplicate.duplicate_delivery?
|
|
219
|
+
|
|
220
|
+
# The enclosing transaction is still usable
|
|
221
|
+
workflow.signal!(:other_webhook)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
assert_equal 2, workflow.signals.count
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
test "signals can still be recorded for a workflow whose class was removed" do
|
|
228
|
+
workflow = InvoiceWorkflow.create!(hero: @user)
|
|
229
|
+
perform_next_step(workflow)
|
|
230
|
+
workflow.current_execution.execute!
|
|
231
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
232
|
+
|
|
233
|
+
# The class is gone: STI resolution falls back to the base Workflow, which
|
|
234
|
+
# has no step definitions - so no matcher can be resolved and nothing is
|
|
235
|
+
# woken, but the row is still written.
|
|
236
|
+
GenevaDrive::Workflow.where(id: workflow.id).update_all(type: "DeletedInvoiceWorkflow")
|
|
237
|
+
orphan = GenevaDrive::Workflow.find(workflow.id)
|
|
238
|
+
assert_equal GenevaDrive::Workflow, orphan.class
|
|
239
|
+
|
|
240
|
+
signal = orphan.signal!(:payment_confirmed)
|
|
241
|
+
|
|
242
|
+
assert signal.persisted?
|
|
243
|
+
assert_equal "pending", signal.reload.state
|
|
244
|
+
assert_equal "waiting", orphan.current_execution.state
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
test "signals are deleted with their workflow" do
|
|
248
|
+
workflow = PlainWorkflow.create!(hero: @user)
|
|
249
|
+
workflow.signal!(:webhook)
|
|
250
|
+
|
|
251
|
+
assert_difference -> { GenevaDrive::Signal.count }, -1 do
|
|
252
|
+
workflow.destroy!
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|