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,635 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/testing"
4
+ require "lepus/saga/testing"
5
+ require_relative "helpers"
6
+
7
+ # The engine's behaviour (executor, retry, compensate, resume_stuck) run end to
8
+ # end on a store, with Lepus fake publishing. The including group defines
9
+ # `store` and starts each example with empty storage:
10
+ #
11
+ # RSpec.describe MyStore do
12
+ # let(:store) { described_class.new }
13
+ #
14
+ # it_behaves_like "a lepus saga engine"
15
+ # end
16
+ #
17
+ # Pass `concurrency: false` to skip the examples that deliver from threads.
18
+ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
19
+ include Lepus::Saga::ExampleHelpers
20
+
21
+ # Step behaviour is driven by the execution context so each example declares
22
+ # its own scenario: "<step>_transient_failures" fails the first N attempts,
23
+ # "fail_<step>" raises a permanent failure, "skip_second" skips, and
24
+ # "complete_on_first" finishes the saga early.
25
+ let(:workflow) do
26
+ stub_const("LepusSagaEngineWorkflow", Class.new(Lepus::Saga::Definition) do
27
+ saga_name "engine_examples.workflow"
28
+
29
+ step :first, compensate: :undo_first
30
+ step :second, retry: {attempts: 2}
31
+ step :third
32
+
33
+ private
34
+
35
+ def first
36
+ complete!("early exit") if context["complete_on_first"]
37
+ fail!("first rejected") if context["fail_first"]
38
+ sleep context["first_sleep"] if context["first_sleep"]
39
+ context["first_output"] = "ok"
40
+ end
41
+
42
+ def second
43
+ skip!("not needed") if context["skip_second"]
44
+ fail!("second rejected") if context["fail_second"]
45
+ raise "transient boom" if context.fetch("second_transient_failures", 0) >= step_attempt.attempt
46
+
47
+ context["second_output"] = "ok"
48
+ end
49
+
50
+ def third = nil
51
+
52
+ def undo_first
53
+ raise "undo boom" if context["fail_undo_first"]
54
+
55
+ context["undone_first"] = true
56
+ end
57
+ end)
58
+ end
59
+
60
+ # Nothing to undo, so abandoning it goes straight to failed rather than
61
+ # through compensation.
62
+ let(:uncompensated) do
63
+ stub_const("LepusSagaEngineUncompensated", Class.new(Lepus::Saga::Definition) do
64
+ saga_name "engine_examples.uncompensated"
65
+
66
+ step :only
67
+
68
+ private
69
+
70
+ def only = nil
71
+ end)
72
+ end
73
+
74
+ let(:consumer) do
75
+ stub_const("LepusSagaEngineConsumer", Class.new(Lepus::Saga::ExecutorConsumer) { saga_partition "default" })
76
+ end
77
+ let(:config) { Lepus::Saga.config }
78
+ let(:resource) { {type: "Order", id: "order-1"} }
79
+
80
+ around do |example|
81
+ previous = Lepus::Saga.config.store
82
+ Lepus::Saga.config.store = store
83
+ Lepus::Testing.enable!
84
+ Lepus::Testing.clear_all_messages!
85
+ example.run
86
+ ensure
87
+ Lepus::Testing.clear_all_messages!
88
+ Lepus::Saga.config.store = previous
89
+ end
90
+
91
+ def saga_exchange = Lepus::Testing.exchange(Lepus::Saga.exchange_name)
92
+
93
+ def drain = Lepus::Saga::Testing.drain(consumer: consumer)
94
+
95
+ def reload(execution) = store.find_execution(execution.id)
96
+
97
+ def force_update(execution, **attributes)
98
+ store.update_execution(reload(execution), attributes)
99
+ end
100
+
101
+ def open_attempt(execution, **attributes)
102
+ store.create_step_attempt(execution, {step_name: "first", direction: "execute", epoch: 0, attempt: 1, started_at: Time.now.utc}.merge(attributes))
103
+ end
104
+
105
+ def start_and_drain(context = {})
106
+ execution = workflow.start(resource: resource, context: context)
107
+ drain
108
+ reload(execution)
109
+ end
110
+
111
+ # The [name, payload[key]] pairs of the saga events instrumented from now on.
112
+ def record_events(key = :step)
113
+ events = []
114
+ allow(Lepus).to receive(:instrument).and_wrap_original do |original, name, **payload, &block|
115
+ events << [name, payload[key]] if name.start_with?("saga.")
116
+ original.call(name, **payload, &block)
117
+ end
118
+ events
119
+ end
120
+
121
+ def perform_message(payload)
122
+ Lepus::Testing.consumer_perform(consumer, {payload: payload.to_json})
123
+ end
124
+
125
+ def execution_payload(execution, step:, direction: "execute", epoch: nil)
126
+ {
127
+ "execution_id" => execution.id,
128
+ "saga" => execution.saga_name,
129
+ "step" => step,
130
+ "direction" => direction,
131
+ "epoch" => epoch.nil? ? execution.epoch : execution.epoch + epoch
132
+ }
133
+ end
134
+
135
+ def attempts(execution, **filters)
136
+ store.step_attempts(execution).select do |attempt|
137
+ filters.all? { |name, value| attempt.public_send(name) == value }
138
+ end
139
+ end
140
+
141
+ def succeeded_steps(execution, epoch: 0)
142
+ attempts(execution, status: "succeeded", direction: "execute", epoch: epoch).map(&:step_name)
143
+ end
144
+
145
+ def attempt_statuses(execution, step_name)
146
+ attempts(execution, step_name: step_name).map(&:status)
147
+ end
148
+
149
+ describe "executor" do
150
+ it "runs all steps to completion and persists context" do
151
+ execution = start_and_drain
152
+
153
+ expect(execution.status).to eq("completed")
154
+ expect(execution.finished_at).not_to be_nil
155
+ expect(execution.context).to include("first_output" => "ok", "second_output" => "ok")
156
+ expect(succeeded_steps(execution)).to eq(%w[first second third])
157
+ end
158
+
159
+ it "publishes the first step message on start" do
160
+ execution = workflow.start(resource: resource)
161
+
162
+ messages = saga_exchange.find_messages(routing_key: "saga.execute.default")
163
+ expect(messages.size).to eq(1)
164
+ expect(JSON.parse(messages.first[:payload])).to eq(
165
+ "execution_id" => execution.id,
166
+ "saga" => "engine_examples.workflow",
167
+ "step" => "first",
168
+ "direction" => "execute",
169
+ "epoch" => 0
170
+ )
171
+ end
172
+
173
+ it "finishes the execution early on complete!" do
174
+ execution = start_and_drain("complete_on_first" => true)
175
+
176
+ expect(execution.status).to eq("completed")
177
+ expect(execution.context["completed_reason"]).to eq("early exit")
178
+ expect(attempts(execution).map(&:step_name)).to eq(%w[first])
179
+ end
180
+
181
+ it "records the step as skipped on skip! and continues" do
182
+ execution = start_and_drain("skip_second" => true)
183
+
184
+ expect(execution.status).to eq("completed")
185
+ expect(attempt_statuses(execution, "second").last).to eq("skipped")
186
+ expect(succeeded_steps(execution)).to include("third")
187
+ end
188
+
189
+ it "retries transient failures until the step succeeds" do
190
+ execution = start_and_drain("second_transient_failures" => 1)
191
+
192
+ expect(execution.status).to eq("completed")
193
+ expect(attempt_statuses(execution, "second")).to eq(%w[failed succeeded])
194
+ end
195
+
196
+ it "compensates succeeded steps when retries are exhausted" do
197
+ execution = start_and_drain("second_transient_failures" => 99)
198
+
199
+ expect(execution.status).to eq("compensated")
200
+ expect(execution.failure_reason).to eq("transient boom")
201
+ expect(attempt_statuses(execution, "second")).to eq(%w[failed failed])
202
+ expect(sole(attempts(execution, step_name: "first", direction: "compensate")).status).to eq("succeeded")
203
+ expect(execution.context["undone_first"]).to be(true)
204
+ end
205
+
206
+ it "compensates immediately without retrying on fail!" do
207
+ execution = start_and_drain("fail_second" => true)
208
+
209
+ expect(execution.status).to eq("compensated")
210
+ expect(execution.failure_reason).to eq("second rejected")
211
+ expect(attempt_statuses(execution, "second")).to eq(%w[failed])
212
+ end
213
+
214
+ it "fails without compensation when nothing succeeded before the failure" do
215
+ execution = start_and_drain("fail_first" => true)
216
+
217
+ expect(execution.status).to eq("failed")
218
+ expect(execution.failure_reason).to eq("first rejected")
219
+ expect(attempts(execution, direction: "compensate")).to be_empty
220
+ end
221
+
222
+ it "stores a failure reason longer than 255 characters" do
223
+ execution = workflow.start(resource: resource)
224
+
225
+ Lepus::Saga.compensate(execution, reason: "x" * 1000)
226
+
227
+ expect(reload(execution)).to have_attributes(status: "failed", failure_reason: "x" * 1000)
228
+ end
229
+
230
+ it "marks the execution compensation_failed when a compensation exhausts retries" do
231
+ execution = start_and_drain("fail_second" => true, "fail_undo_first" => true)
232
+
233
+ expect(execution).to have_attributes(status: "compensation_failed", current_step: "first", direction: "compensate")
234
+ end
235
+
236
+ it "acks stale-epoch messages without running anything" do
237
+ execution = start_and_drain
238
+
239
+ expect { expect(perform_message(execution_payload(execution, step: "first", epoch: -1))).to eq(:ack) }
240
+ .not_to change { attempts(execution).size }
241
+ end
242
+
243
+ it "acks terminal executions without running anything" do
244
+ execution = start_and_drain
245
+
246
+ expect { expect(perform_message(execution_payload(execution, step: execution.current_step))).to eq(:ack) }
247
+ .not_to change { attempts(execution).size }
248
+ end
249
+
250
+ it "heals a cursor mismatch by re-publishing the current step" do
251
+ execution = workflow.start(resource: resource)
252
+ Lepus::Testing.clear_all_messages!
253
+ force_update(execution, current_step: "second")
254
+
255
+ result = perform_message(execution_payload(execution, step: "first"))
256
+
257
+ expect(result).to eq(:ack)
258
+ expect(attempts(execution)).to be_empty
259
+ expect(JSON.parse(sole(saga_exchange.messages)[:payload])["step"]).to eq("second")
260
+ end
261
+
262
+ it "acks while another attempt is in flight" do
263
+ execution = workflow.start(resource: resource)
264
+ open_attempt(execution)
265
+
266
+ result = perform_message(execution_payload(execution, step: "first"))
267
+
268
+ expect(result).to eq(:ack)
269
+ expect(attempts(execution).size).to eq(1)
270
+ end
271
+
272
+ it "closes abandoned attempts and runs a fresh one" do
273
+ execution = workflow.start(resource: resource)
274
+ stale = open_attempt(execution, started_at: ago(minutes(10)))
275
+
276
+ perform_message(execution_payload(execution, step: "first"))
277
+
278
+ expect(sole(attempts(execution, id: stale.id))).to have_attributes(
279
+ status: "failed", error_class: Lepus::Saga::Executor::AbandonedAttempt.name
280
+ )
281
+ expect(sole(attempts(execution, attempt: 2)).status).to eq("succeeded")
282
+ end
283
+
284
+ it "honours config.stale_running_attempt_after" do
285
+ allow(config).to receive(:stale_running_attempt_after).and_return(minutes(1))
286
+ execution = workflow.start(resource: resource)
287
+ stale = open_attempt(execution, started_at: ago(minutes(2)))
288
+
289
+ perform_message(execution_payload(execution, step: "first"))
290
+
291
+ expect(sole(attempts(execution, id: stale.id)).status).to eq("failed")
292
+ end
293
+
294
+ it "acks a duplicate attempt and keeps the store unchanged" do
295
+ execution = workflow.start(resource: resource)
296
+ allow(store).to receive(:count_step_attempts).and_return(0)
297
+ open_attempt(execution, started_at: ago(minutes(10)))
298
+
299
+ expect(perform_message(execution_payload(execution, step: "first"))).to eq(:ack)
300
+ expect(sole(attempts(execution)).status).to eq("running")
301
+ end
302
+
303
+ it "acks messages for unknown executions" do
304
+ result = perform_message(
305
+ "execution_id" => SecureRandom.uuid_v7, "saga" => "engine_examples.workflow",
306
+ "step" => "first", "direction" => "execute", "epoch" => 0
307
+ )
308
+
309
+ expect(result).to eq(:ack)
310
+ end
311
+
312
+ it "emits instrumentation events through the lifecycle" do
313
+ events = record_events
314
+
315
+ start_and_drain
316
+
317
+ expect(events).to include(
318
+ ["saga.started", nil],
319
+ ["saga.step.started", "first"],
320
+ ["saga.step.finished", "third"],
321
+ ["saga.completed", nil]
322
+ )
323
+ end
324
+
325
+ if concurrency
326
+ it "runs a step once when its message is delivered twice at the same time" do
327
+ execution = workflow.start(resource: resource, context: {"first_sleep" => 0.3})
328
+ payload = execution_payload(execution, step: "first")
329
+
330
+ Array.new(2) { Thread.new { perform_message(payload) } }.each(&:join)
331
+
332
+ expect(attempts(execution, step_name: "first").map(&:status)).to eq(%w[succeeded])
333
+ expect(reload(execution).current_step).to eq("second")
334
+ end
335
+ end
336
+ end
337
+
338
+ describe "Lepus::Saga.retry" do
339
+ it "resumes a failed execution under a new epoch" do
340
+ execution = start_and_drain("fail_first" => true)
341
+ force_update(execution, context: {})
342
+
343
+ Lepus::Saga.retry(reload(execution))
344
+ drain
345
+ execution = reload(execution)
346
+
347
+ expect(execution.status).to eq("completed")
348
+ expect(execution.epoch).to eq(1)
349
+ expect(succeeded_steps(execution, epoch: 1)).to eq(%w[first second third])
350
+ end
351
+
352
+ it "refreshes and returns the given record" do
353
+ execution = start_and_drain("fail_first" => true)
354
+
355
+ expect(Lepus::Saga.retry(execution)).to equal(execution)
356
+ expect(execution).to have_attributes(status: "running", epoch: 1, failure_reason: nil, finished_at: nil)
357
+ end
358
+
359
+ it "refuses to retry completed executions" do
360
+ execution = start_and_drain
361
+
362
+ expect { Lepus::Saga.retry(execution) }.to raise_error(Lepus::Saga::NotRetryableError)
363
+ end
364
+
365
+ it "refuses a stale copy of an execution that completed since it was loaded" do
366
+ stale = workflow.start(resource: resource)
367
+ drain
368
+ Lepus::Testing.clear_all_messages!
369
+
370
+ expect { Lepus::Saga.retry(stale) }.to raise_error(Lepus::Saga::NotRetryableError)
371
+ expect(reload(stale)).to have_attributes(status: "completed", epoch: 0)
372
+ expect(saga_exchange.messages).to be_empty
373
+ end
374
+
375
+ it "keeps the execution on its partition" do
376
+ execution = workflow.start(resource: resource, partition: "priority")
377
+ force_update(execution, status: "failed")
378
+ Lepus::Testing.clear_all_messages!
379
+
380
+ Lepus::Saga.retry(reload(execution))
381
+
382
+ expect(saga_exchange.find_messages(routing_key: "saga.execute.priority").size).to eq(1)
383
+ end
384
+ end
385
+
386
+ describe "Lepus::Saga.compensate" do
387
+ it "refuses a stale copy of an execution that completed since it was loaded" do
388
+ stale = workflow.start(resource: resource)
389
+ drain
390
+ Lepus::Testing.clear_all_messages!
391
+
392
+ expect { Lepus::Saga.compensate(stale) }.to raise_error(Lepus::Saga::NotRetryableError)
393
+ expect(reload(stale)).to have_attributes(status: "completed", epoch: 0)
394
+ expect(saga_exchange.messages).to be_empty
395
+ end
396
+
397
+ it "aborts a running execution" do
398
+ execution = start_and_drain
399
+ force_update(execution, status: "running", finished_at: nil, current_step: "third")
400
+
401
+ Lepus::Saga.compensate(reload(execution))
402
+ drain
403
+
404
+ expect(reload(execution).status).to eq("compensated")
405
+ expect(reload(execution).context["undone_first"]).to be(true)
406
+ end
407
+
408
+ it "fences out in-flight messages by bumping the epoch" do
409
+ execution = workflow.start(resource: resource)
410
+ payload = execution_payload(execution, step: "first")
411
+
412
+ Lepus::Saga.compensate(execution)
413
+
414
+ expect(execution).to have_attributes(status: "failed", epoch: 1, failure_reason: "manually compensated")
415
+ expect(perform_message(payload)).to eq(:ack)
416
+ expect(attempts(execution)).to be_empty
417
+ end
418
+ end
419
+
420
+ describe "Lepus::Saga.resume_stuck" do
421
+ # An execution left with its cursor standing still for an hour. `advance`
422
+ # runs the first step first, so there is a succeeded attempt to compensate.
423
+ def stalled_execution(resume_count: 0, advance: false)
424
+ execution = workflow.start(resource: resource)
425
+ perform_next_step if advance
426
+ Lepus::Testing.clear_all_messages!
427
+ force_update(execution, last_progress_at: ago(hours(1)), resume_count: resume_count)
428
+ end
429
+
430
+ # One message only: Saga::Testing.drain runs to completion and raises if
431
+ # the exchange never empties, which is the opposite of what these need.
432
+ def perform_next_step
433
+ message = saga_exchange.messages.first
434
+ saga_exchange.clear_messages
435
+ Lepus::Testing.consumer_perform(consumer, {payload: message[:payload]})
436
+ end
437
+
438
+ # Queues bare step messages without creating executions, so the only
439
+ # stalled executions are the ones an example sets up.
440
+ def fill_queue(messages: config.backlog_threshold + 1, partition: "default")
441
+ routing_key = Lepus::Saga.routing_key(partition)
442
+ messages.times { saga_exchange.add_message(payload: {}, routing_key: routing_key) }
443
+ end
444
+
445
+ it "re-publishes the current step of a stalled execution" do
446
+ execution = stalled_execution
447
+
448
+ expect(Lepus::Saga.resume_stuck(older_than: minutes(15))).to eq(1)
449
+ drain
450
+
451
+ expect(reload(execution).status).to eq("completed")
452
+ end
453
+
454
+ it "re-publishes an execution whose cursor has not moved" do
455
+ execution = stalled_execution
456
+
457
+ expect(Lepus::Saga.resume_stuck).to eq(1)
458
+ expect(reload(execution).resume_count).to eq(1)
459
+ expect(saga_exchange.messages.size).to eq(1)
460
+ end
461
+
462
+ # The bug this exists for: a wedged execution is written to on every pass
463
+ # (a lock, a re-publish, an attempt opened and abandoned), so selecting on
464
+ # `updated_at` let it reset its own clock and it was never picked up again.
465
+ it "re-publishes an execution that is still being written to" do
466
+ execution = stalled_execution
467
+ force_update(execution, updated_at: Time.now.utc)
468
+
469
+ expect(Lepus::Saga.resume_stuck).to eq(1)
470
+ expect(reload(execution).resume_count).to eq(1)
471
+ end
472
+
473
+ it "reports the stored resume count" do
474
+ stalled_execution(resume_count: 2)
475
+ events = record_events(:resume_count)
476
+
477
+ Lepus::Saga.resume_stuck
478
+
479
+ expect(events).to eq([["saga.resumed", 3]])
480
+ end
481
+
482
+ it "leaves an execution whose cursor moved recently alone" do
483
+ execution = stalled_execution
484
+ force_update(execution, last_progress_at: ago(minutes(1)))
485
+
486
+ expect(Lepus::Saga.resume_stuck).to eq(0)
487
+ expect(reload(execution).resume_count).to eq(0)
488
+ expect(saga_exchange.messages).to be_empty
489
+ end
490
+
491
+ it "resets the resume budget when a step advances" do
492
+ execution = stalled_execution(resume_count: 3)
493
+
494
+ Lepus::Saga.resume_stuck
495
+ drain
496
+
497
+ expect(reload(execution)).to have_attributes(status: "completed", resume_count: 0)
498
+ end
499
+
500
+ it "compensates an execution that never recovers" do
501
+ execution = stalled_execution(resume_count: config.max_resumes, advance: true)
502
+
503
+ expect(Lepus::Saga.resume_stuck).to eq(0)
504
+ expect(reload(execution)).to have_attributes(status: "compensating", failure_reason: "no progress after 5 resume attempts")
505
+ end
506
+
507
+ it "does not abandon an execution that completed after it was listed" do
508
+ execution = stalled_execution(resume_count: config.max_resumes, advance: true)
509
+ listed = reload(execution)
510
+ force_update(execution, status: "completed", finished_at: Time.now.utc)
511
+ allow(store).to receive(:stalled_executions).and_return([listed])
512
+
513
+ Lepus::Saga.resume_stuck
514
+
515
+ expect(reload(execution)).to have_attributes(status: "completed", failure_reason: nil)
516
+ expect(saga_exchange.messages).to be_empty
517
+ end
518
+
519
+ it "honours config.max_resumes" do
520
+ allow(config).to receive(:max_resumes).and_return(2)
521
+ execution = stalled_execution(resume_count: 2, advance: true)
522
+
523
+ Lepus::Saga.resume_stuck
524
+
525
+ expect(reload(execution).failure_reason).to eq("no progress after 2 resume attempts")
526
+ end
527
+
528
+ it "fails an execution that never recovers and has nothing to compensate" do
529
+ execution = uncompensated.start(resource: resource)
530
+ Lepus::Testing.clear_all_messages!
531
+ force_update(execution, last_progress_at: ago(hours(1)), resume_count: config.max_resumes)
532
+
533
+ expect(Lepus::Saga.resume_stuck).to eq(0)
534
+ expect(reload(execution).status).to eq("failed")
535
+ expect(reload(execution).finished_at).not_to be_nil
536
+ end
537
+
538
+ it "gives an abandoned execution a fresh budget on manual retry" do
539
+ execution = stalled_execution(resume_count: config.max_resumes, advance: true)
540
+ Lepus::Saga.resume_stuck
541
+ Lepus::Testing.clear_all_messages!
542
+
543
+ Lepus::Saga.retry(reload(execution))
544
+
545
+ expect(reload(execution).resume_count).to eq(0)
546
+ expect(Lepus::Saga.resume_stuck).to eq(0)
547
+ end
548
+
549
+ it "honours the limit so one sweep cannot run away" do
550
+ 3.times { stalled_execution }
551
+
552
+ expect(Lepus::Saga.resume_stuck(limit: 2)).to eq(2)
553
+ end
554
+
555
+ it "resumes the oldest stall first" do
556
+ newer = stalled_execution
557
+ older = stalled_execution
558
+ force_update(older, last_progress_at: ago(hours(2)))
559
+
560
+ Lepus::Saga.resume_stuck(limit: 1)
561
+
562
+ expect([reload(older).resume_count, reload(newer).resume_count]).to eq([1, 0])
563
+ end
564
+
565
+ # Under a backlog an execution's step message is still queued, so its
566
+ # cursor stands still while it waits its turn; re-publishing it only adds
567
+ # duplicates.
568
+ it "leaves executions alone while their partition's queue has a backlog" do
569
+ execution = stalled_execution
570
+ fill_queue
571
+
572
+ expect(Lepus::Saga.resume_stuck).to eq(0)
573
+ expect(reload(execution).resume_count).to eq(0)
574
+ end
575
+
576
+ it "does not abandon an execution while its partition's queue has a backlog" do
577
+ execution = stalled_execution(resume_count: config.max_resumes)
578
+ fill_queue
579
+
580
+ Lepus::Saga.resume_stuck
581
+
582
+ expect(reload(execution).status).to eq("running")
583
+ end
584
+
585
+ it "does not count a few queued messages as a backlog" do
586
+ execution = stalled_execution
587
+ fill_queue(messages: 2)
588
+
589
+ expect(Lepus::Saga.resume_stuck).to eq(1)
590
+ expect(reload(execution).resume_count).to eq(1)
591
+ end
592
+
593
+ it "honours config.backlog_threshold" do
594
+ allow(config).to receive(:backlog_threshold).and_return(1)
595
+ stalled_execution
596
+ fill_queue(messages: 2)
597
+
598
+ expect(Lepus::Saga.resume_stuck).to eq(0)
599
+ end
600
+
601
+ it "re-publishes an execution stalled behind a backlog past the patience window" do
602
+ execution = stalled_execution
603
+ force_update(execution, last_progress_at: ago(config.backlog_patience + hours(1)))
604
+ fill_queue
605
+
606
+ expect(Lepus::Saga.resume_stuck).to eq(1)
607
+ end
608
+
609
+ it "honours config.backlog_patience" do
610
+ allow(config).to receive(:backlog_patience).and_return(minutes(30))
611
+ stalled_execution
612
+ fill_queue
613
+
614
+ expect(Lepus::Saga.resume_stuck).to eq(1)
615
+ end
616
+
617
+ it "re-publishes a backlogged execution only once" do
618
+ execution = stalled_execution
619
+ force_update(execution, last_progress_at: ago(config.backlog_patience + hours(1)))
620
+ fill_queue
621
+ Lepus::Saga.resume_stuck
622
+
623
+ expect(Lepus::Saga.resume_stuck).to eq(0)
624
+ expect(reload(execution).resume_count).to eq(1)
625
+ end
626
+
627
+ it "does not let a backlog in another partition hold this one back" do
628
+ execution = stalled_execution
629
+ fill_queue(partition: "organic")
630
+
631
+ expect(Lepus::Saga.resume_stuck).to eq(1)
632
+ expect(reload(execution).resume_count).to eq(1)
633
+ end
634
+ end
635
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Helpers for the shared examples, which run with or without ActiveSupport.
6
+ module ExampleHelpers
7
+ def minutes(count) = count * 60
8
+
9
+ def hours(count) = count * 3600
10
+
11
+ def days(count) = count * 86_400
12
+
13
+ def ago(seconds) = Time.now.utc - seconds
14
+
15
+ def sole(records)
16
+ records = records.to_a
17
+ expect(records.size).to eq(1)
18
+ records.first
19
+ end
20
+ end
21
+ end
22
+ end