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,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "minitest/mock"
|
|
5
|
+
|
|
6
|
+
# Delivering a signal is one database transaction: the INSERT, the
|
|
7
|
+
# waiting-execution scan, the attach, the state flip and the counter bumps all
|
|
8
|
+
# land together or not at all. Anything less is a lost wakeup - a pending
|
|
9
|
+
# signal sitting next to a waiting execution with nothing left to introduce
|
|
10
|
+
# them - so these tests pin the atomicity rather than trusting the reading.
|
|
11
|
+
class SignalDurabilityTest < ActiveSupport::TestCase
|
|
12
|
+
include GenevaDrive::TestHelpers
|
|
13
|
+
|
|
14
|
+
class InvoiceWorkflow < GenevaDrive::Workflow
|
|
15
|
+
step :await_payment, wait_for: :payment_confirmed do
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
step :issue_receipt do
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
setup do
|
|
23
|
+
@user = create_user
|
|
24
|
+
@workflow = InvoiceWorkflow.create!(hero: @user)
|
|
25
|
+
@workflow.current_execution.execute!
|
|
26
|
+
@workflow.reload
|
|
27
|
+
@parked = @workflow.current_execution
|
|
28
|
+
assert_equal "waiting", @parked.state
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fails the last write of the dispatch path, after the signal row and the
|
|
32
|
+
# execution have already been mutated inside the transaction.
|
|
33
|
+
def failing_late_in_dispatch(workflow)
|
|
34
|
+
workflow.stub(:enqueue_woken_execution, ->(_execution, _step_def) { raise "crash after the rows moved" }) do
|
|
35
|
+
yield
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
test "a failure late in dispatch rolls the whole delivery back" do
|
|
40
|
+
original_scheduled_for = @parked.scheduled_for
|
|
41
|
+
original_waiting_since = @parked.waiting_since
|
|
42
|
+
|
|
43
|
+
failing_late_in_dispatch(@workflow) do
|
|
44
|
+
assert_raises(RuntimeError) { @workflow.signal!(:payment_confirmed, payload: {amount_cents: 10}) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
assert_equal 0, @workflow.signals.count
|
|
48
|
+
|
|
49
|
+
@parked.reload
|
|
50
|
+
assert_equal "waiting", @parked.state
|
|
51
|
+
assert_nil @parked.signal_id
|
|
52
|
+
assert_equal original_scheduled_for.to_i, @parked.scheduled_for.to_i
|
|
53
|
+
assert_equal original_waiting_since.to_i, @parked.waiting_since.to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test "a failed redelivery leaves the previously buffered signal untouched" do
|
|
57
|
+
buffered = @workflow.signal!(:unrelated_event, idempotency_key: "evt_0")
|
|
58
|
+
assert_equal "pending", buffered.reload.state
|
|
59
|
+
assert_equal 0, buffered.claimed
|
|
60
|
+
|
|
61
|
+
failing_late_in_dispatch(@workflow) do
|
|
62
|
+
assert_raises(RuntimeError) { @workflow.signal!(:payment_confirmed, idempotency_key: "evt_1") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
assert_equal 1, @workflow.signals.count
|
|
66
|
+
buffered.reload
|
|
67
|
+
assert_equal "pending", buffered.state
|
|
68
|
+
assert_equal 0, buffered.claimed
|
|
69
|
+
assert_equal 0, buffered.consumed
|
|
70
|
+
assert_equal "waiting", @parked.reload.state
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
test "every effect of a delivery is undone by an enclosing rollback" do
|
|
74
|
+
signal_id = nil
|
|
75
|
+
|
|
76
|
+
GenevaDrive::Workflow.transaction(requires_new: true) do
|
|
77
|
+
signal = @workflow.signal!(:payment_confirmed)
|
|
78
|
+
signal_id = signal.id
|
|
79
|
+
|
|
80
|
+
# Inside the transaction everything is in place
|
|
81
|
+
assert_equal "claimed", signal.reload.state
|
|
82
|
+
assert_equal 1, signal.claimed
|
|
83
|
+
assert_equal "scheduled", @parked.reload.state
|
|
84
|
+
assert_equal signal.id, @parked.signal_id
|
|
85
|
+
|
|
86
|
+
raise ActiveRecord::Rollback
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
assert_not GenevaDrive::Signal.exists?(signal_id)
|
|
90
|
+
assert_equal 0, @workflow.signals.count
|
|
91
|
+
|
|
92
|
+
@parked.reload
|
|
93
|
+
assert_equal "waiting", @parked.state
|
|
94
|
+
assert_nil @parked.signal_id
|
|
95
|
+
assert_not_nil @parked.waiting_since
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test "the workflow is locked before the signal is persisted" do
|
|
99
|
+
statements = []
|
|
100
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
101
|
+
statements << payload[:sql]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
@workflow.signal!(:payment_confirmed)
|
|
106
|
+
ensure
|
|
107
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# with_lock reloads the workflow row; that read has to happen before the
|
|
111
|
+
# INSERT, otherwise the signal is persisted in a transaction of its own and
|
|
112
|
+
# a crash before dispatch loses the wakeup. (SQLite has no SELECT ... FOR
|
|
113
|
+
# UPDATE, so the reload itself is what all three adapters have in common.)
|
|
114
|
+
lock_at = statements.index { |sql| sql.match?(/SELECT.*FROM ["`]?geneva_drive_workflows/i) }
|
|
115
|
+
insert_at = statements.index { |sql| sql.match?(/INSERT INTO ["`]?geneva_drive_signals/i) }
|
|
116
|
+
|
|
117
|
+
assert lock_at, "expected the workflow row to be locked: #{statements.inspect}"
|
|
118
|
+
assert insert_at, "expected the signal INSERT: #{statements.inspect}"
|
|
119
|
+
assert_operator lock_at, :<, insert_at
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
test "no commit separates the signal insert from the execution activation" do
|
|
123
|
+
statements = []
|
|
124
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
125
|
+
statements << payload[:sql]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
@workflow.signal!(:payment_confirmed)
|
|
130
|
+
ensure
|
|
131
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
insert_at = statements.index { |sql| sql.match?(/INSERT INTO ["`]?geneva_drive_signals/i) }
|
|
135
|
+
activation_at = statements.rindex do |sql|
|
|
136
|
+
sql.match?(/UPDATE ["`]?geneva_drive_step_executions/i) && sql.match?(/waiting_since/i)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
assert insert_at, "expected the signal INSERT in #{statements.inspect}"
|
|
140
|
+
assert activation_at, "expected the execution activation UPDATE in #{statements.inspect}"
|
|
141
|
+
assert_operator insert_at, :<, activation_at
|
|
142
|
+
|
|
143
|
+
between = statements[insert_at..activation_at]
|
|
144
|
+
assert_empty between.grep(/\Acommit/i),
|
|
145
|
+
"the signal INSERT was committed before the execution was activated: #{between.inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
test "a duplicate delivery inside a caller's transaction does not poison it" do
|
|
149
|
+
@workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
|
|
150
|
+
|
|
151
|
+
GenevaDrive::Workflow.transaction do
|
|
152
|
+
duplicate = @workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
|
|
153
|
+
assert duplicate.duplicate_delivery?
|
|
154
|
+
|
|
155
|
+
# The savepoint absorbed the unique violation, so the caller's
|
|
156
|
+
# transaction is still usable (this is where PostgreSQL would otherwise
|
|
157
|
+
# refuse every subsequent statement)
|
|
158
|
+
@workflow.signal!(:another_event)
|
|
159
|
+
assert_equal 2, @workflow.signals.count
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
assert_equal 2, @workflow.signals.reload.count
|
|
163
|
+
end
|
|
164
|
+
end
|