lepus-saga 0.3.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/Rakefile +8 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/lib/lepus/saga/configuration.rb +48 -0
- data/lib/lepus/saga/control.rb +27 -0
- data/lib/lepus/saga/definition.rb +123 -0
- data/lib/lepus/saga/execution_behaviour.rb +58 -0
- data/lib/lepus/saga/executor.rb +282 -0
- data/lib/lepus/saga/executor_consumer.rb +38 -0
- data/lib/lepus/saga/instrumentation.rb +41 -0
- data/lib/lepus/saga/log_subscriber.rb +42 -0
- data/lib/lepus/saga/queue_depth.rb +28 -0
- data/lib/lepus/saga/resume_stuck_job.rb +19 -0
- data/lib/lepus/saga/retry_policy.rb +19 -0
- data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
- data/lib/lepus/saga/rspec/helpers.rb +22 -0
- data/lib/lepus/saga/rspec/query_examples.rb +378 -0
- data/lib/lepus/saga/rspec/store_examples.rb +514 -0
- data/lib/lepus/saga/rspec.rb +6 -0
- data/lib/lepus/saga/step.rb +9 -0
- data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
- data/lib/lepus/saga/step_producer.rb +37 -0
- data/lib/lepus/saga/stores/base.rb +305 -0
- data/lib/lepus/saga/stores/memory.rb +375 -0
- data/lib/lepus/saga/stores.rb +40 -0
- data/lib/lepus/saga/testing.rb +35 -0
- data/lib/lepus/saga/version.rb +7 -0
- data/lib/lepus/saga/web/api.rb +326 -0
- data/lib/lepus/saga/web/configuration.rb +49 -0
- data/lib/lepus/saga/web/guard.rb +38 -0
- data/lib/lepus/saga/web/serializer.rb +59 -0
- data/lib/lepus/saga/web/static.rb +108 -0
- data/lib/lepus/saga/web.rb +61 -0
- data/lib/lepus/saga.rb +212 -0
- data/lib/lepus-saga.rb +3 -0
- data/mise.toml +2 -0
- data/web/assets/app.css +913 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +50 -0
- data/web/assets/js/dom.js +84 -0
- data/web/assets/js/format.js +79 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +143 -0
- data/web/assets/js/map.js +639 -0
- data/web/assets/js/range.js +19 -0
- data/web/assets/js/sagas.js +18 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/throughput.js +262 -0
- data/web/assets/js/views/execution.js +426 -0
- data/web/assets/js/views/executions.js +165 -0
- data/web/assets/js/views/overview.js +465 -0
- data/web/index.html +63 -0
- metadata +323 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
# The contract every store must meet. The including group defines `store`
|
|
6
|
+
# and starts each example with empty storage:
|
|
7
|
+
#
|
|
8
|
+
# RSpec.describe MyStore do
|
|
9
|
+
# let(:store) { described_class.new }
|
|
10
|
+
#
|
|
11
|
+
# it_behaves_like "a lepus saga store"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Pass `concurrency: false` for a backend that cannot be shared by threads
|
|
15
|
+
# (an in-memory SQLite database, for example) to skip the threaded examples.
|
|
16
|
+
RSpec.shared_examples "a lepus saga store" do |concurrency: true|
|
|
17
|
+
include Lepus::Saga::ExampleHelpers
|
|
18
|
+
|
|
19
|
+
def create_execution(**attributes)
|
|
20
|
+
store.create_execution({
|
|
21
|
+
saga_name: "store_contract.saga",
|
|
22
|
+
resource_type: "Resource",
|
|
23
|
+
resource_id: "resource-1",
|
|
24
|
+
current_step: "first",
|
|
25
|
+
context: {"key" => "value"},
|
|
26
|
+
correlation_id: nil,
|
|
27
|
+
partition: nil,
|
|
28
|
+
started_at: Time.now.utc,
|
|
29
|
+
last_progress_at: Time.now.utc
|
|
30
|
+
}.merge(attributes))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_attempt(execution, **attributes)
|
|
34
|
+
store.create_step_attempt(execution, {
|
|
35
|
+
step_name: "first", direction: "execute", epoch: 0, attempt: 1, started_at: Time.now.utc
|
|
36
|
+
}.merge(attributes))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def concurrently(count, &block)
|
|
40
|
+
Array.new(count) { |index| Thread.new(index, &block) }.each(&:join)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
let(:uuid_v7) { /\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/ }
|
|
44
|
+
|
|
45
|
+
describe "#create_execution" do
|
|
46
|
+
it "returns a record with the execution behaviour and a uuid v7 id" do
|
|
47
|
+
execution = create_execution
|
|
48
|
+
|
|
49
|
+
expect(execution).to be_a(Lepus::Saga::ExecutionBehaviour)
|
|
50
|
+
expect(execution.id).to match(uuid_v7)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "applies the defaults" do
|
|
54
|
+
execution = store.find_execution(create_execution.id)
|
|
55
|
+
|
|
56
|
+
expect(execution).to have_attributes(
|
|
57
|
+
status: "running", direction: "execute", epoch: 0, resume_count: 0,
|
|
58
|
+
failure_reason: nil, finished_at: nil
|
|
59
|
+
)
|
|
60
|
+
expect(execution.created_at).to be_within(1).of(Time.now.utc)
|
|
61
|
+
expect(execution.updated_at).to be_within(1).of(Time.now.utc)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "stores the given attributes" do
|
|
65
|
+
started_at = ago(minutes(1))
|
|
66
|
+
execution = create_execution(
|
|
67
|
+
resource_id: "resource-9", correlation_id: "corr-1", partition: "priority",
|
|
68
|
+
context: {"nested" => {"list" => [1, "two"]}}, started_at: started_at
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(store.find_execution(execution.id)).to have_attributes(
|
|
72
|
+
saga_name: "store_contract.saga", resource_type: "Resource", resource_id: "resource-9",
|
|
73
|
+
current_step: "first", correlation_id: "corr-1",
|
|
74
|
+
context: {"nested" => {"list" => [1, "two"]}}
|
|
75
|
+
)
|
|
76
|
+
expect(store.find_execution(execution.id)[:partition]).to eq("priority")
|
|
77
|
+
expect(store.find_execution(execution.id).started_at).to be_within(1).of(started_at)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "defaults context to an empty hash" do
|
|
81
|
+
execution = store.create_execution(
|
|
82
|
+
saga_name: "store_contract.saga", resource_type: "Resource", resource_id: "1",
|
|
83
|
+
current_step: "first", started_at: Time.now.utc, last_progress_at: Time.now.utc
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(store.find_execution(execution.id).context).to eq({})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "keeps a nil partition raw" do
|
|
90
|
+
execution = store.find_execution(create_execution.id)
|
|
91
|
+
|
|
92
|
+
expect(execution[:partition]).to be_nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe "#find_execution" do
|
|
97
|
+
it "returns nil for an unknown id" do
|
|
98
|
+
expect(store.find_execution(SecureRandom.uuid_v7)).to be_nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "returns a copy unaffected by unsaved changes" do
|
|
102
|
+
execution = create_execution
|
|
103
|
+
execution.status = "failed"
|
|
104
|
+
execution.context["key"] = "changed"
|
|
105
|
+
|
|
106
|
+
expect(store.find_execution(execution.id)).to have_attributes(status: "running", context: {"key" => "value"})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe "#save_execution" do
|
|
111
|
+
it "persists every changed attribute" do
|
|
112
|
+
execution = create_execution
|
|
113
|
+
execution.status = "compensating"
|
|
114
|
+
execution.direction = "compensate"
|
|
115
|
+
execution.current_step = "second"
|
|
116
|
+
execution.epoch = 2
|
|
117
|
+
execution.failure_reason = "boom " * 100
|
|
118
|
+
execution.context = {"after" => true}
|
|
119
|
+
execution.finished_at = Time.now.utc
|
|
120
|
+
|
|
121
|
+
expect(store.save_execution(execution)).to eq(execution)
|
|
122
|
+
expect(store.find_execution(execution.id)).to have_attributes(
|
|
123
|
+
status: "compensating", direction: "compensate", current_step: "second", epoch: 2,
|
|
124
|
+
failure_reason: "boom " * 100, context: {"after" => true}
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "persists a context changed in place" do
|
|
129
|
+
execution = create_execution
|
|
130
|
+
execution.context["added"] = 1
|
|
131
|
+
|
|
132
|
+
store.save_execution(execution)
|
|
133
|
+
|
|
134
|
+
expect(store.find_execution(execution.id).context).to eq("key" => "value", "added" => 1)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe "#update_execution" do
|
|
139
|
+
it "assigns and persists the attributes" do
|
|
140
|
+
execution = create_execution
|
|
141
|
+
|
|
142
|
+
store.update_execution(execution, status: "failed", finished_at: Time.now.utc)
|
|
143
|
+
|
|
144
|
+
expect(execution.status).to eq("failed")
|
|
145
|
+
expect(store.find_execution(execution.id).status).to eq("failed")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe "#with_lock" do
|
|
150
|
+
it "reloads the record in place before yielding it" do
|
|
151
|
+
execution = create_execution
|
|
152
|
+
store.update_execution(store.find_execution(execution.id), epoch: 3)
|
|
153
|
+
|
|
154
|
+
store.with_lock(execution) { |locked| expect(locked).to equal(execution) }
|
|
155
|
+
|
|
156
|
+
expect(execution.epoch).to eq(3)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "returns the block's value" do
|
|
160
|
+
expect(store.with_lock(create_execution) { :value }).to eq(:value)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "keeps the block's writes when it returns" do
|
|
164
|
+
execution = create_execution
|
|
165
|
+
|
|
166
|
+
store.with_lock(execution) do
|
|
167
|
+
store.update_execution(execution, status: "completed")
|
|
168
|
+
next :done
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
expect(store.find_execution(execution.id).status).to eq("completed")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "discards the block's writes when it raises" do
|
|
175
|
+
execution = create_execution
|
|
176
|
+
|
|
177
|
+
expect {
|
|
178
|
+
store.with_lock(execution) do
|
|
179
|
+
store.update_execution(execution, status: "completed")
|
|
180
|
+
create_attempt(execution)
|
|
181
|
+
raise ArgumentError, "rolled back"
|
|
182
|
+
end
|
|
183
|
+
}.to raise_error(ArgumentError, "rolled back")
|
|
184
|
+
|
|
185
|
+
expect(store.find_execution(execution.id).status).to eq("running")
|
|
186
|
+
expect(store.step_attempts(execution)).to be_empty
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if concurrency
|
|
190
|
+
it "makes a second locker wait until the first finishes" do
|
|
191
|
+
execution = create_execution
|
|
192
|
+
events = Queue.new
|
|
193
|
+
entered = Queue.new
|
|
194
|
+
release = Queue.new
|
|
195
|
+
|
|
196
|
+
holder = Thread.new do
|
|
197
|
+
store.with_lock(store.find_execution(execution.id)) do
|
|
198
|
+
entered << true
|
|
199
|
+
release.pop
|
|
200
|
+
events << :first_done
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
entered.pop
|
|
204
|
+
waiter = Thread.new { store.with_lock(store.find_execution(execution.id)) { events << :second_in } }
|
|
205
|
+
sleep 0.3
|
|
206
|
+
events << :released
|
|
207
|
+
release << true
|
|
208
|
+
[holder, waiter].each(&:join)
|
|
209
|
+
|
|
210
|
+
expect(Array.new(events.size) { events.pop }).to eq(%i[released first_done second_in])
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
describe "#with_locked_execution" do
|
|
216
|
+
it "yields a freshly loaded record" do
|
|
217
|
+
execution = create_execution
|
|
218
|
+
store.update_execution(store.find_execution(execution.id), current_step: "second")
|
|
219
|
+
|
|
220
|
+
store.with_locked_execution(execution.id) do |locked|
|
|
221
|
+
expect(locked).to be_a(Lepus::Saga::ExecutionBehaviour)
|
|
222
|
+
expect(locked.current_step).to eq("second")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "yields nil for an unknown id" do
|
|
227
|
+
expect(store.with_locked_execution(SecureRandom.uuid_v7) { |locked| [:yielded, locked] }).to eq([:yielded, nil])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "discards the block's writes when it raises" do
|
|
231
|
+
execution = create_execution
|
|
232
|
+
|
|
233
|
+
expect {
|
|
234
|
+
store.with_locked_execution(execution.id) do |locked|
|
|
235
|
+
store.update_execution(locked, epoch: 1)
|
|
236
|
+
raise ArgumentError
|
|
237
|
+
end
|
|
238
|
+
}.to raise_error(ArgumentError)
|
|
239
|
+
|
|
240
|
+
expect(store.find_execution(execution.id).epoch).to eq(0)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if concurrency
|
|
244
|
+
it "serializes concurrent read-modify-write cycles" do
|
|
245
|
+
execution = create_execution
|
|
246
|
+
|
|
247
|
+
concurrently(4) do
|
|
248
|
+
3.times do
|
|
249
|
+
store.with_locked_execution(execution.id) do |locked|
|
|
250
|
+
epoch = locked.epoch
|
|
251
|
+
sleep 0.005
|
|
252
|
+
store.update_execution(locked, epoch: epoch + 1)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
expect(store.find_execution(execution.id).epoch).to eq(12)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
describe "#increment_resume_count" do
|
|
263
|
+
it "adds one, updates the record and returns the stored value" do
|
|
264
|
+
execution = create_execution
|
|
265
|
+
|
|
266
|
+
expect(store.increment_resume_count(execution)).to eq(1)
|
|
267
|
+
expect(execution.resume_count).to eq(1)
|
|
268
|
+
expect(store.increment_resume_count(execution)).to eq(2)
|
|
269
|
+
expect(store.find_execution(execution.id).resume_count).to eq(2)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "increments the stored value, not the record's stale one" do
|
|
273
|
+
stale = create_execution
|
|
274
|
+
store.with_locked_execution(stale.id) do |locked|
|
|
275
|
+
store.update_execution(locked, resume_count: 3, status: "completed")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
expect(store.increment_resume_count(stale)).to eq(4)
|
|
279
|
+
expect(store.find_execution(stale.id)).to have_attributes(resume_count: 4, status: "completed")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "touches updated_at" do
|
|
283
|
+
execution = create_execution
|
|
284
|
+
before = store.find_execution(execution.id).updated_at
|
|
285
|
+
sleep 0.01
|
|
286
|
+
|
|
287
|
+
store.increment_resume_count(execution)
|
|
288
|
+
|
|
289
|
+
expect(store.find_execution(execution.id).updated_at).to be > before
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if concurrency
|
|
293
|
+
it "loses no increment under concurrency" do
|
|
294
|
+
execution = create_execution
|
|
295
|
+
|
|
296
|
+
concurrently(4) { 5.times { store.increment_resume_count(store.find_execution(execution.id)) } }
|
|
297
|
+
|
|
298
|
+
expect(store.find_execution(execution.id).resume_count).to eq(20)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def increment_during_locked_update(execution)
|
|
302
|
+
entered = Queue.new
|
|
303
|
+
release = Queue.new
|
|
304
|
+
holder = Thread.new do
|
|
305
|
+
store.with_lock(store.find_execution(execution.id)) do |locked|
|
|
306
|
+
store.update_execution(locked, status: "failed")
|
|
307
|
+
entered << true
|
|
308
|
+
release.pop
|
|
309
|
+
store.update_execution(locked, failure_reason: "locked update")
|
|
310
|
+
yield if block_given?
|
|
311
|
+
end
|
|
312
|
+
rescue ArgumentError
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
entered.pop
|
|
316
|
+
incrementer = Thread.new { store.increment_resume_count(store.find_execution(execution.id)) }
|
|
317
|
+
sleep 0.1
|
|
318
|
+
release << true
|
|
319
|
+
[holder, incrementer].each(&:join)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "is not clobbered by a concurrent locked update" do
|
|
323
|
+
execution = create_execution
|
|
324
|
+
|
|
325
|
+
increment_during_locked_update(execution)
|
|
326
|
+
|
|
327
|
+
expect(store.find_execution(execution.id)).to have_attributes(resume_count: 1, status: "failed", failure_reason: "locked update")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "is not undone when a concurrent locked update rolls back" do
|
|
331
|
+
execution = create_execution
|
|
332
|
+
|
|
333
|
+
increment_during_locked_update(execution) { raise ArgumentError, "rolled back" }
|
|
334
|
+
|
|
335
|
+
expect(store.find_execution(execution.id)).to have_attributes(resume_count: 1, status: "running")
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
describe "#stalled_executions" do
|
|
341
|
+
it "returns in-progress executions stalled before the cutoff, oldest first" do
|
|
342
|
+
newer = create_execution(last_progress_at: ago(minutes(20)))
|
|
343
|
+
oldest = create_execution(last_progress_at: ago(hours(2)))
|
|
344
|
+
compensating = create_execution(last_progress_at: ago(hours(1)))
|
|
345
|
+
store.update_execution(compensating, status: "compensating", direction: "compensate")
|
|
346
|
+
|
|
347
|
+
stalled = store.stalled_executions(before: ago(minutes(15))).to_a
|
|
348
|
+
|
|
349
|
+
expect(stalled.map(&:id)).to eq([oldest.id, compensating.id, newer.id])
|
|
350
|
+
expect(stalled).to all(be_a(Lepus::Saga::ExecutionBehaviour))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "skips executions that progressed recently or are terminal" do
|
|
354
|
+
create_execution(last_progress_at: ago(minutes(1)))
|
|
355
|
+
Lepus::Saga::ExecutionBehaviour::TERMINAL_STATUSES.each do |status|
|
|
356
|
+
store.update_execution(create_execution(last_progress_at: ago(hours(1))), status: status)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
expect(store.stalled_executions(before: ago(minutes(15))).to_a).to be_empty
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "can be cut short by the caller" do
|
|
363
|
+
ids = Array.new(3) { |index| create_execution(last_progress_at: ago(hours(index + 1))).id }
|
|
364
|
+
|
|
365
|
+
expect(store.stalled_executions(before: Time.now.utc).first(2).map(&:id)).to eq(ids.last(2).reverse)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
describe "#executions_for_resource" do
|
|
370
|
+
it "returns the resource's executions, newest first" do
|
|
371
|
+
older = create_execution(resource_id: "r-1")
|
|
372
|
+
sleep 0.01
|
|
373
|
+
newer = create_execution(resource_id: "r-1")
|
|
374
|
+
create_execution(resource_id: "r-2")
|
|
375
|
+
create_execution(resource_type: "Other", resource_id: "r-1")
|
|
376
|
+
|
|
377
|
+
expect(store.executions_for_resource("Resource", "r-1").map(&:id)).to eq([newer.id, older.id])
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
describe "#create_step_attempt" do
|
|
382
|
+
let(:execution) { create_execution }
|
|
383
|
+
|
|
384
|
+
it "returns a running attempt of the execution" do
|
|
385
|
+
attempt = create_attempt(execution, attempt: 2, epoch: 1, direction: "compensate")
|
|
386
|
+
|
|
387
|
+
expect(attempt).to be_a(Lepus::Saga::StepAttemptBehaviour)
|
|
388
|
+
expect(attempt.id).to match(uuid_v7)
|
|
389
|
+
expect(sole(store.step_attempts(execution))).to have_attributes(
|
|
390
|
+
id: attempt.id, saga_execution_id: execution.id, step_name: "first", direction: "compensate",
|
|
391
|
+
epoch: 1, attempt: 2, status: "running", context_after: {}, finished_at: nil
|
|
392
|
+
)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it "raises DuplicateAttemptError for the same step, direction, epoch and attempt" do
|
|
396
|
+
create_attempt(execution)
|
|
397
|
+
|
|
398
|
+
expect { create_attempt(execution) }.to raise_error(Lepus::Saga::DuplicateAttemptError)
|
|
399
|
+
expect(store.step_attempts(execution).size).to eq(1)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it "accepts the same attempt number in another epoch, direction, step or execution" do
|
|
403
|
+
create_attempt(execution)
|
|
404
|
+
create_attempt(execution, epoch: 1)
|
|
405
|
+
create_attempt(execution, direction: "compensate")
|
|
406
|
+
create_attempt(execution, step_name: "second")
|
|
407
|
+
create_attempt(create_execution)
|
|
408
|
+
|
|
409
|
+
expect(store.step_attempts(execution).size).to eq(4)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it "discards the lock block's writes when a duplicate is raised out of it" do
|
|
413
|
+
create_attempt(execution)
|
|
414
|
+
|
|
415
|
+
expect {
|
|
416
|
+
store.with_locked_execution(execution.id) do |locked|
|
|
417
|
+
store.update_execution(locked, status: "failed")
|
|
418
|
+
create_attempt(locked, attempt: 2)
|
|
419
|
+
create_attempt(locked)
|
|
420
|
+
end
|
|
421
|
+
}.to raise_error(Lepus::Saga::DuplicateAttemptError)
|
|
422
|
+
|
|
423
|
+
expect(store.find_execution(execution.id).status).to eq("running")
|
|
424
|
+
expect(store.step_attempts(execution).map(&:attempt)).to eq([1])
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
describe "#finish_step_attempt" do
|
|
429
|
+
let(:execution) { create_execution }
|
|
430
|
+
|
|
431
|
+
it "closes the attempt with its context and duration" do
|
|
432
|
+
attempt = create_attempt(execution, started_at: ago(2))
|
|
433
|
+
|
|
434
|
+
store.finish_step_attempt(attempt, "succeeded", context_after: {"out" => 1})
|
|
435
|
+
|
|
436
|
+
expect(sole(store.step_attempts(execution))).to have_attributes(
|
|
437
|
+
status: "succeeded", context_after: {"out" => 1}, error_class: nil, error_message: nil, error_backtrace: nil
|
|
438
|
+
)
|
|
439
|
+
expect(sole(store.step_attempts(execution)).finished_at).to be_within(1).of(Time.now.utc)
|
|
440
|
+
expect(sole(store.step_attempts(execution)).duration_ms).to be_within(1000).of(2000)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
it "records the error" do
|
|
444
|
+
attempt = create_attempt(execution)
|
|
445
|
+
error = begin
|
|
446
|
+
raise ArgumentError, "bad input " * 50
|
|
447
|
+
rescue => raised
|
|
448
|
+
raised
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
store.finish_step_attempt(attempt, "failed", error: error)
|
|
452
|
+
|
|
453
|
+
expect(sole(store.step_attempts(execution))).to have_attributes(
|
|
454
|
+
status: "failed", error_class: "ArgumentError", error_message: "bad input " * 50
|
|
455
|
+
)
|
|
456
|
+
expect(sole(store.step_attempts(execution)).error_backtrace).to include("store_examples.rb")
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
describe "#step_attempts" do
|
|
461
|
+
it "orders the execution's attempts by start time" do
|
|
462
|
+
execution = create_execution
|
|
463
|
+
later = create_attempt(execution, step_name: "second", started_at: ago(minutes(1)))
|
|
464
|
+
earlier = create_attempt(execution, step_name: "first", started_at: ago(minutes(2)))
|
|
465
|
+
create_attempt(create_execution)
|
|
466
|
+
|
|
467
|
+
expect(store.step_attempts(execution).map(&:id)).to eq([earlier.id, later.id])
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
describe "current step queries" do
|
|
472
|
+
let(:execution) { create_execution }
|
|
473
|
+
let(:scope) { {step_name: "first", direction: "execute", epoch: 0} }
|
|
474
|
+
|
|
475
|
+
before do
|
|
476
|
+
store.finish_step_attempt(create_attempt(execution, attempt: 1, started_at: ago(minutes(20))), "failed")
|
|
477
|
+
create_attempt(execution, attempt: 2, started_at: ago(minutes(10)))
|
|
478
|
+
create_attempt(execution, attempt: 3, started_at: ago(minutes(1)))
|
|
479
|
+
create_attempt(execution, epoch: 1, started_at: ago(minutes(1)))
|
|
480
|
+
create_attempt(execution, direction: "compensate", started_at: ago(minutes(10)))
|
|
481
|
+
create_attempt(execution, step_name: "second", started_at: ago(minutes(10)))
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
it "counts the attempts of the step, direction and epoch" do
|
|
485
|
+
expect(store.count_step_attempts(execution, **scope)).to eq(3)
|
|
486
|
+
expect(store.count_step_attempts(execution, **scope, epoch: 1)).to eq(1)
|
|
487
|
+
expect(store.count_step_attempts(execution, **scope, step_name: "third")).to eq(0)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it "finds a running attempt started since the cutoff" do
|
|
491
|
+
expect(store.running_step_attempt?(execution, **scope, started_since: ago(minutes(5)))).to be(true)
|
|
492
|
+
expect(store.running_step_attempt?(execution, **scope, started_since: ago(30))).to be(false)
|
|
493
|
+
expect(store.running_step_attempt?(execution, **scope, direction: "compensate", started_since: ago(minutes(5)))).to be(false)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
it "lists running attempts started before the cutoff" do
|
|
497
|
+
stale = store.stale_step_attempts(execution, **scope, started_before: ago(minutes(5))).to_a
|
|
498
|
+
|
|
499
|
+
expect(stale.map(&:attempt)).to eq([2])
|
|
500
|
+
expect(stale).to all(be_a(Lepus::Saga::StepAttemptBehaviour))
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
it "tells whether a step ever succeeded in the execute direction" do
|
|
504
|
+
expect(store.step_succeeded?(execution, "first")).to be(false)
|
|
505
|
+
|
|
506
|
+
store.finish_step_attempt(store.step_attempts(execution).find { |a| a.direction == "compensate" }, "succeeded")
|
|
507
|
+
expect(store.step_succeeded?(execution, "first")).to be(false)
|
|
508
|
+
|
|
509
|
+
store.finish_step_attempt(store.step_attempts(execution).find { |a| a.epoch == 1 }, "succeeded")
|
|
510
|
+
expect(store.step_succeeded?(execution, "first")).to be(true)
|
|
511
|
+
expect(store.step_succeeded?(execution, "second")).to be(false)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
# What the engine expects of a step attempt record, whatever stores it.
|
|
6
|
+
# Adapters include it into their step attempt class.
|
|
7
|
+
module StepAttemptBehaviour
|
|
8
|
+
ATTRIBUTES = %i[
|
|
9
|
+
id saga_execution_id step_name direction epoch attempt status
|
|
10
|
+
error_class error_message error_backtrace context_after started_at
|
|
11
|
+
finished_at duration_ms created_at updated_at
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
STATUSES = %w[running succeeded failed skipped].freeze
|
|
15
|
+
|
|
16
|
+
STATUSES.each do |value|
|
|
17
|
+
define_method(:"#{value}?") { status == value }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The attributes that close this attempt with `status`.
|
|
21
|
+
def finish_attributes(status, context_after: {}, error: nil)
|
|
22
|
+
now = Time.now.utc
|
|
23
|
+
{
|
|
24
|
+
status: status,
|
|
25
|
+
finished_at: now,
|
|
26
|
+
duration_ms: (now - started_at) * 1000.0,
|
|
27
|
+
context_after: context_after,
|
|
28
|
+
error_class: error&.class&.name,
|
|
29
|
+
error_message: error&.message,
|
|
30
|
+
error_backtrace: error&.backtrace&.first(20)&.join("\n")
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
# Publishes step messages on the configured saga exchange. The producer is
|
|
6
|
+
# (re)configured on first use and whenever config.exchange_name changes.
|
|
7
|
+
class StepProducer < Lepus::Producer
|
|
8
|
+
use(:json)
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def definition
|
|
12
|
+
exchange = Lepus::Saga.exchange_name
|
|
13
|
+
unless @definition&.exchange_name == exchange
|
|
14
|
+
configure(exchange: {name: exchange, type: :topic}, publish: {persistent: true})
|
|
15
|
+
end
|
|
16
|
+
@definition
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def publish_step(execution)
|
|
20
|
+
publish(
|
|
21
|
+
{
|
|
22
|
+
execution_id: execution.id,
|
|
23
|
+
saga: execution.saga_name,
|
|
24
|
+
step: execution.current_step,
|
|
25
|
+
direction: execution.direction,
|
|
26
|
+
epoch: execution.epoch
|
|
27
|
+
},
|
|
28
|
+
routing_key: Lepus::Saga.routing_key(execution.partition),
|
|
29
|
+
correlation_id: execution.correlation_id || execution.id,
|
|
30
|
+
message_id: SecureRandom.uuid_v7,
|
|
31
|
+
headers: {"x-saga-name" => execution.saga_name}
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|