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.
@@ -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