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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +237 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +337 -0
  7. data/Rakefile +8 -0
  8. data/docs/brand/BRAND.md +67 -0
  9. data/docs/brand/apple-touch-icon.png +0 -0
  10. data/docs/brand/favicon-16.png +0 -0
  11. data/docs/brand/favicon-32.png +0 -0
  12. data/docs/brand/favicon.ico +0 -0
  13. data/docs/brand/favicon.svg +1 -0
  14. data/docs/brand/icon-512.png +0 -0
  15. data/docs/brand/lockup-lepus-mono.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  17. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  21. data/docs/brand/lockup-lepus-saga.svg +1 -0
  22. data/docs/brand/lockup-lepus.svg +1 -0
  23. data/docs/brand/mark-mono.svg +1 -0
  24. data/docs/brand/mark-on-dark.svg +1 -0
  25. data/docs/brand/mark-on-light.svg +1 -0
  26. data/docs/brand/mark-small-mono.svg +1 -0
  27. data/docs/brand/mark-small-on-dark.svg +1 -0
  28. data/docs/brand/mark-small-on-light.svg +1 -0
  29. data/docs/brand/mark-small.svg +1 -0
  30. data/docs/brand/mark.svg +1 -0
  31. data/lib/lepus/saga/configuration.rb +48 -0
  32. data/lib/lepus/saga/control.rb +27 -0
  33. data/lib/lepus/saga/definition.rb +123 -0
  34. data/lib/lepus/saga/execution_behaviour.rb +58 -0
  35. data/lib/lepus/saga/executor.rb +282 -0
  36. data/lib/lepus/saga/executor_consumer.rb +38 -0
  37. data/lib/lepus/saga/instrumentation.rb +41 -0
  38. data/lib/lepus/saga/log_subscriber.rb +42 -0
  39. data/lib/lepus/saga/queue_depth.rb +28 -0
  40. data/lib/lepus/saga/resume_stuck_job.rb +19 -0
  41. data/lib/lepus/saga/retry_policy.rb +19 -0
  42. data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
  43. data/lib/lepus/saga/rspec/helpers.rb +22 -0
  44. data/lib/lepus/saga/rspec/query_examples.rb +378 -0
  45. data/lib/lepus/saga/rspec/store_examples.rb +514 -0
  46. data/lib/lepus/saga/rspec.rb +6 -0
  47. data/lib/lepus/saga/step.rb +9 -0
  48. data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
  49. data/lib/lepus/saga/step_producer.rb +37 -0
  50. data/lib/lepus/saga/stores/base.rb +305 -0
  51. data/lib/lepus/saga/stores/memory.rb +375 -0
  52. data/lib/lepus/saga/stores.rb +40 -0
  53. data/lib/lepus/saga/testing.rb +35 -0
  54. data/lib/lepus/saga/version.rb +7 -0
  55. data/lib/lepus/saga/web/api.rb +326 -0
  56. data/lib/lepus/saga/web/configuration.rb +49 -0
  57. data/lib/lepus/saga/web/guard.rb +38 -0
  58. data/lib/lepus/saga/web/serializer.rb +59 -0
  59. data/lib/lepus/saga/web/static.rb +108 -0
  60. data/lib/lepus/saga/web.rb +61 -0
  61. data/lib/lepus/saga.rb +212 -0
  62. data/lib/lepus-saga.rb +3 -0
  63. data/mise.toml +2 -0
  64. data/web/assets/app.css +913 -0
  65. data/web/assets/apple-touch-icon.png +0 -0
  66. data/web/assets/favicon-32.png +0 -0
  67. data/web/assets/favicon.svg +1 -0
  68. data/web/assets/fonts/overpass/OFL.txt +93 -0
  69. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  70. data/web/assets/js/api.js +50 -0
  71. data/web/assets/js/dom.js +84 -0
  72. data/web/assets/js/format.js +79 -0
  73. data/web/assets/js/live.js +74 -0
  74. data/web/assets/js/main.js +143 -0
  75. data/web/assets/js/map.js +639 -0
  76. data/web/assets/js/range.js +19 -0
  77. data/web/assets/js/sagas.js +18 -0
  78. data/web/assets/js/theme.js +8 -0
  79. data/web/assets/js/throughput.js +262 -0
  80. data/web/assets/js/views/execution.js +426 -0
  81. data/web/assets/js/views/executions.js +165 -0
  82. data/web/assets/js/views/overview.js +465 -0
  83. data/web/index.html +63 -0
  84. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/saga"
4
+ require_relative "rspec/store_examples"
5
+ require_relative "rspec/query_examples"
6
+ require_relative "rspec/engine_examples"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ Step = Data.define(:name, :compensate, :retry_policy) do
6
+ def compensatable? = !compensate.to_s.strip.empty?
7
+ end
8
+ end
9
+ 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