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,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
|