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,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
# The optional query contract (see Lepus::Saga::Stores::Base#queries?). The
|
|
6
|
+
# including group defines `store` 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 query store"
|
|
12
|
+
# end
|
|
13
|
+
RSpec.shared_examples "a lepus saga query store" do
|
|
14
|
+
include Lepus::Saga::ExampleHelpers
|
|
15
|
+
|
|
16
|
+
let(:checkout) { "query_examples.orders_checkout" }
|
|
17
|
+
let(:invoice) { "query_examples.invoices_issue" }
|
|
18
|
+
let(:hour) { Time.at(Time.now.utc.to_i.div(3600) * 3600).utc - hours(3) }
|
|
19
|
+
|
|
20
|
+
before do
|
|
21
|
+
stub_const("LepusSagaQueryCheckout", Class.new(Lepus::Saga::Definition) do
|
|
22
|
+
saga_name "query_examples.orders_checkout"
|
|
23
|
+
|
|
24
|
+
step :reserve
|
|
25
|
+
step :charge
|
|
26
|
+
step :ship
|
|
27
|
+
end)
|
|
28
|
+
stub_const("LepusSagaQueryInvoice", Class.new(Lepus::Saga::Definition) do
|
|
29
|
+
saga_name "query_examples.invoices_issue"
|
|
30
|
+
partition "billing"
|
|
31
|
+
|
|
32
|
+
step :render
|
|
33
|
+
step :deliver
|
|
34
|
+
end)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def query_execution(saga_name: checkout, status: "running", created_at: hour, finished_at: nil, **attributes)
|
|
38
|
+
execution = store.create_execution({
|
|
39
|
+
saga_name: saga_name, resource_type: "Order", resource_id: "order-1", current_step: "reserve",
|
|
40
|
+
partition: nil, started_at: created_at, last_progress_at: created_at
|
|
41
|
+
}.merge(attributes))
|
|
42
|
+
store.update_execution(execution, status: status, created_at: created_at, finished_at: finished_at)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def query_attempt(execution, step_name: "charge", attempt: 1, direction: "execute", status: "succeeded",
|
|
46
|
+
started_at: hour, duration_ms: 100.0, **attributes)
|
|
47
|
+
record = store.create_step_attempt(execution, {
|
|
48
|
+
step_name: step_name, direction: direction, epoch: 0, attempt: attempt, started_at: started_at
|
|
49
|
+
})
|
|
50
|
+
store.update_step_attempt(record, {
|
|
51
|
+
status: status, duration_ms: duration_ms, finished_at: duration_ms && started_at + duration_ms / 1000.0
|
|
52
|
+
}.merge(attributes))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "supports queries" do
|
|
56
|
+
expect(store.queries?).to be(true)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "#list_executions" do
|
|
60
|
+
it "returns execution records, newest id first" do
|
|
61
|
+
ids = Array.new(3) { query_execution.id }
|
|
62
|
+
|
|
63
|
+
executions = store.list_executions
|
|
64
|
+
|
|
65
|
+
expect(executions).to all(be_a(Lepus::Saga::ExecutionBehaviour))
|
|
66
|
+
expect(executions.map(&:id)).to eq(ids.sort.reverse)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "pages with limit and before_id" do
|
|
70
|
+
ids = Array.new(5) { query_execution.id }.sort.reverse
|
|
71
|
+
|
|
72
|
+
first_page = store.list_executions(limit: 2)
|
|
73
|
+
second_page = store.list_executions(limit: 2, before_id: first_page.last.id)
|
|
74
|
+
last_page = store.list_executions(limit: 2, before_id: second_page.last.id)
|
|
75
|
+
|
|
76
|
+
expect([first_page, second_page, last_page].map { |page| page.map(&:id) }).to eq(ids.each_slice(2).to_a)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "filters by saga name and status, single or listed" do
|
|
80
|
+
running = query_execution
|
|
81
|
+
failed = query_execution(status: "failed", finished_at: hour)
|
|
82
|
+
invoice_failed = query_execution(saga_name: invoice, status: "failed", finished_at: hour)
|
|
83
|
+
query_execution(saga_name: invoice, status: "completed", finished_at: hour)
|
|
84
|
+
|
|
85
|
+
expect(store.list_executions(saga_name: checkout).map(&:id)).to contain_exactly(running.id, failed.id)
|
|
86
|
+
expect(store.list_executions(status: "failed").map(&:id)).to contain_exactly(failed.id, invoice_failed.id)
|
|
87
|
+
expect(store.list_executions(saga_name: [checkout], status: %w[running failed]).map(&:id))
|
|
88
|
+
.to contain_exactly(running.id, failed.id)
|
|
89
|
+
expect(store.list_executions(saga_name: [], status: nil).size).to eq(4)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "filters by current step, single or listed" do
|
|
93
|
+
reserving = query_execution(current_step: "reserve")
|
|
94
|
+
charging = query_execution(current_step: "charge")
|
|
95
|
+
query_execution(current_step: "ship")
|
|
96
|
+
|
|
97
|
+
expect(store.list_executions(current_step: "reserve").map(&:id)).to contain_exactly(reserving.id)
|
|
98
|
+
expect(store.list_executions(current_step: %w[reserve charge]).map(&:id)).to contain_exactly(reserving.id, charging.id)
|
|
99
|
+
expect(store.count_executions(group_by: :status, current_step: "charge")).to eq("running" => 1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "matches the partition column or, without one, the definition's partition" do
|
|
103
|
+
default = query_execution
|
|
104
|
+
invoice_default = query_execution(saga_name: invoice)
|
|
105
|
+
checkout_billing = query_execution(partition: "billing")
|
|
106
|
+
|
|
107
|
+
expect(store.list_executions(partition: "billing").map(&:id)).to contain_exactly(invoice_default.id, checkout_billing.id)
|
|
108
|
+
expect(store.list_executions(partition: "default").map(&:id)).to contain_exactly(default.id)
|
|
109
|
+
expect(store.list_executions(partition: :priority)).to be_empty
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "searches resource type and id case-insensitively" do
|
|
113
|
+
order = query_execution(resource_type: "Order", resource_id: "ORD-100")
|
|
114
|
+
invoice_record = query_execution(resource_type: "Invoice", resource_id: "inv-7")
|
|
115
|
+
|
|
116
|
+
expect(store.list_executions(resource: "ord-1").map(&:id)).to contain_exactly(order.id)
|
|
117
|
+
expect(store.list_executions(resource: "voice").map(&:id)).to contain_exactly(invoice_record.id)
|
|
118
|
+
expect(store.list_executions(resource: "or").map(&:id)).to contain_exactly(order.id)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "treats LIKE wildcards in the resource search literally" do
|
|
122
|
+
query_execution(resource_id: "order-100")
|
|
123
|
+
percent = query_execution(resource_id: "100%")
|
|
124
|
+
underscore = query_execution(resource_id: "a_b")
|
|
125
|
+
query_execution(resource_id: "axb")
|
|
126
|
+
|
|
127
|
+
expect(store.list_executions(resource: "100%").map(&:id)).to contain_exactly(percent.id)
|
|
128
|
+
expect(store.list_executions(resource: "a_b").map(&:id)).to contain_exactly(underscore.id)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "bounds created_at by from (inclusive) and to (exclusive)" do
|
|
132
|
+
query_execution(created_at: hour - minutes(1))
|
|
133
|
+
at_from = query_execution(created_at: hour)
|
|
134
|
+
inside = query_execution(created_at: hour + minutes(30))
|
|
135
|
+
query_execution(created_at: hour + hours(1))
|
|
136
|
+
|
|
137
|
+
expect(store.list_executions(from: hour, to: hour + hours(1)).map(&:id)).to contain_exactly(at_from.id, inside.id)
|
|
138
|
+
expect(store.list_executions(from: hour + minutes(1)).size).to eq(2)
|
|
139
|
+
expect(store.list_executions(to: hour).size).to eq(1)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe "progress and error filters" do
|
|
144
|
+
it "bounds last_progress_at by progress_from (inclusive) and progress_to (exclusive)" do
|
|
145
|
+
query_execution(last_progress_at: hour - minutes(1))
|
|
146
|
+
at_from = query_execution(last_progress_at: hour)
|
|
147
|
+
inside = query_execution(last_progress_at: hour + minutes(30))
|
|
148
|
+
query_execution(last_progress_at: hour + hours(1), created_at: hour - hours(2))
|
|
149
|
+
|
|
150
|
+
expect(store.list_executions(progress_from: hour, progress_to: hour + hours(1)).map(&:id)).to contain_exactly(at_from.id, inside.id)
|
|
151
|
+
expect(store.list_executions(progress_to: hour).size).to eq(1)
|
|
152
|
+
expect(store.count_executions(group_by: :status, progress_from: hour + minutes(1))).to eq("running" => 2)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "keeps executions with a failed attempt of the error class and step" do
|
|
156
|
+
timed_out = query_execution
|
|
157
|
+
query_attempt(timed_out, attempt: 1, status: "failed", error_class: "Timeout::Error")
|
|
158
|
+
query_attempt(timed_out, attempt: 2)
|
|
159
|
+
shipped = query_execution
|
|
160
|
+
query_attempt(shipped, step_name: "ship", status: "failed", error_class: "Timeout::Error")
|
|
161
|
+
rejected = query_execution(status: "failed", finished_at: hour)
|
|
162
|
+
query_attempt(rejected, status: "failed", error_class: "ArgumentError")
|
|
163
|
+
recovered = query_execution
|
|
164
|
+
query_attempt(recovered, error_class: "Timeout::Error")
|
|
165
|
+
|
|
166
|
+
expect(store.list_executions(error_class: "Timeout::Error").map(&:id)).to contain_exactly(timed_out.id, shipped.id)
|
|
167
|
+
expect(store.list_executions(error_class: "Timeout::Error", error_step: "charge").map(&:id)).to contain_exactly(timed_out.id)
|
|
168
|
+
expect(store.list_executions(error_class: %w[Timeout::Error ArgumentError], error_step: ["charge"]).map(&:id))
|
|
169
|
+
.to contain_exactly(timed_out.id, rejected.id)
|
|
170
|
+
expect(store.list_executions(error_step: "ship").map(&:id)).to contain_exactly(shipped.id)
|
|
171
|
+
expect(store.count_executions(group_by: :status, error_class: "Timeout::Error")).to eq("running" => 2)
|
|
172
|
+
expect(store.list_executions(error_class: "Timeout::Error", status: "failed")).to be_empty
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "bounds the failed attempt's started_at by error_from (inclusive) and error_to (exclusive)" do
|
|
176
|
+
older = query_execution(created_at: hour - days(1))
|
|
177
|
+
query_attempt(older, status: "failed", error_class: "Timeout::Error", started_at: hour + hours(1))
|
|
178
|
+
early = query_execution
|
|
179
|
+
query_attempt(early, status: "failed", error_class: "Timeout::Error", started_at: hour - minutes(1))
|
|
180
|
+
query_attempt(early, attempt: 2, started_at: hour + hours(1))
|
|
181
|
+
at_to = query_execution
|
|
182
|
+
query_attempt(at_to, status: "failed", error_class: "Timeout::Error", started_at: hour + hours(2))
|
|
183
|
+
|
|
184
|
+
window = {error_class: "Timeout::Error", error_step: "charge", error_from: hour, error_to: hour + hours(2)}
|
|
185
|
+
expect(store.list_executions(**window).map(&:id)).to contain_exactly(older.id)
|
|
186
|
+
expect(store.count_executions(group_by: :status, **window)).to eq("running" => 1)
|
|
187
|
+
expect(store.list_executions(error_from: hour).map(&:id)).to contain_exactly(older.id, at_to.id)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe "#count_executions" do
|
|
192
|
+
before do
|
|
193
|
+
query_execution
|
|
194
|
+
query_execution(status: "failed", finished_at: hour)
|
|
195
|
+
query_execution(status: "failed", finished_at: hour, current_step: "charge")
|
|
196
|
+
query_execution(saga_name: invoice, status: "completed", finished_at: hour, current_step: nil)
|
|
197
|
+
query_execution(saga_name: invoice, partition: "priority")
|
|
198
|
+
query_execution(saga_name: "query_examples.unregistered")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "groups by one attribute" do
|
|
202
|
+
expect(store.count_executions(group_by: :status)).to eq("running" => 3, "failed" => 2, "completed" => 1)
|
|
203
|
+
expect(store.count_executions(group_by: :current_step)).to eq("reserve" => 4, "charge" => 1, nil => 1)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "groups by several attributes under array keys" do
|
|
207
|
+
expect(store.count_executions(group_by: %i[saga_name status])).to eq(
|
|
208
|
+
[checkout, "running"] => 1, [checkout, "failed"] => 2,
|
|
209
|
+
[invoice, "completed"] => 1, [invoice, "running"] => 1,
|
|
210
|
+
["query_examples.unregistered", "running"] => 1
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "resolves executions without a partition to their definition's" do
|
|
215
|
+
expect(store.count_executions(group_by: :partition)).to eq("default" => 3, "billing" => 1, "priority" => 1, nil => 1)
|
|
216
|
+
expect(store.count_executions(group_by: %i[partition status])).to include(["default", "failed"] => 2)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "applies the list filters" do
|
|
220
|
+
expect(store.count_executions(group_by: :status, saga_name: checkout, status: %w[running failed]))
|
|
221
|
+
.to eq("running" => 1, "failed" => 2)
|
|
222
|
+
expect(store.count_executions(group_by: :saga_name, partition: "billing")).to eq(invoice => 1)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "rejects unknown groupings" do
|
|
226
|
+
expect { store.count_executions(group_by: :resource_id) }.to raise_error(ArgumentError)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
describe "#execution_series" do
|
|
231
|
+
it "counts starts by created_at and terminal statuses by finished_at in aligned buckets" do
|
|
232
|
+
query_execution(created_at: hour + minutes(5))
|
|
233
|
+
query_execution(created_at: hour + minutes(10), status: "completed", finished_at: hour + minutes(20))
|
|
234
|
+
query_execution(created_at: hour - hours(1), status: "failed", finished_at: hour + minutes(70))
|
|
235
|
+
query_execution(created_at: hour - hours(1), status: "compensated", finished_at: hour - minutes(1))
|
|
236
|
+
query_execution(created_at: hour + minutes(15), status: "compensating")
|
|
237
|
+
|
|
238
|
+
series = store.execution_series(from: hour + minutes(10), to: hour + hours(2), bucket: hours(1))
|
|
239
|
+
|
|
240
|
+
expect(series[:truncated]).to be(false)
|
|
241
|
+
expect(series[:rows].map(&:first)).to eq([hour, hour + hours(1)])
|
|
242
|
+
expect(series[:rows].map(&:last)).to eq([
|
|
243
|
+
{"started" => 2, "completed" => 1, "compensated" => 0, "failed" => 0, "compensation_failed" => 0},
|
|
244
|
+
{"started" => 0, "completed" => 0, "compensated" => 0, "failed" => 1, "compensation_failed" => 0}
|
|
245
|
+
])
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "keeps empty buckets and filters by saga" do
|
|
249
|
+
query_execution(created_at: hour + minutes(1))
|
|
250
|
+
query_execution(saga_name: invoice, created_at: hour + minutes(2))
|
|
251
|
+
|
|
252
|
+
series = store.execution_series(from: hour, to: hour + minutes(30), bucket: 600, saga_name: invoice)
|
|
253
|
+
|
|
254
|
+
expect(series[:rows].map(&:first)).to eq([hour, hour + minutes(10), hour + minutes(20)])
|
|
255
|
+
expect(series[:rows].map { |_, counts| counts["started"] }).to eq([1, 0, 0])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "counts at most max_rows timestamps of each kind, newest first" do
|
|
259
|
+
[1, 2, 61].each { |minute| query_execution(created_at: hour + minutes(minute)) }
|
|
260
|
+
|
|
261
|
+
series = store.execution_series(from: hour, to: hour + hours(2), bucket: hours(1), max_rows: 2)
|
|
262
|
+
|
|
263
|
+
expect(series[:truncated]).to be(true)
|
|
264
|
+
expect(series[:rows].map { |_, counts| counts["started"] }).to eq([1, 1])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it "rejects empty or too many buckets" do
|
|
268
|
+
expect { store.execution_series(from: hour, to: hour + hours(1), bucket: 0) }.to raise_error(ArgumentError)
|
|
269
|
+
expect { store.execution_series(from: hour, to: hour + days(1), bucket: 1) }.to raise_error(ArgumentError)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
describe "#step_latency" do
|
|
274
|
+
it "computes nearest-rank percentiles per saga, step and direction" do
|
|
275
|
+
execution = query_execution
|
|
276
|
+
(1..20).each { |attempt| query_attempt(execution, attempt: attempt, duration_ms: attempt * 10.0) }
|
|
277
|
+
query_attempt(execution, step_name: "reserve", direction: "compensate", status: "failed", duration_ms: 40.0)
|
|
278
|
+
query_attempt(query_execution(saga_name: invoice), step_name: "render", duration_ms: 5.0)
|
|
279
|
+
|
|
280
|
+
latency = store.step_latency(from: hour, to: hour + hours(1))
|
|
281
|
+
|
|
282
|
+
expect(latency[:truncated]).to be(false)
|
|
283
|
+
expect(latency[:rows]).to eq([
|
|
284
|
+
{saga_name: invoice, step_name: "render", direction: "execute", count: 1, p50: 5.0, p95: 5.0, p99: 5.0, max: 5.0},
|
|
285
|
+
{saga_name: checkout, step_name: "charge", direction: "execute", count: 20, p50: 100.0, p95: 190.0, p99: 200.0, max: 200.0},
|
|
286
|
+
{saga_name: checkout, step_name: "reserve", direction: "compensate", count: 1, p50: 40.0, p95: 40.0, p99: 40.0, max: 40.0}
|
|
287
|
+
])
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "leaves out running and skipped attempts, other sagas and attempts outside the window" do
|
|
291
|
+
execution = query_execution
|
|
292
|
+
query_attempt(execution, attempt: 1, duration_ms: 10.0)
|
|
293
|
+
query_attempt(execution, attempt: 2, status: "running", duration_ms: nil)
|
|
294
|
+
query_attempt(execution, attempt: 3, status: "skipped", duration_ms: 1.0)
|
|
295
|
+
query_attempt(execution, attempt: 4, started_at: hour - 1, duration_ms: 500.0)
|
|
296
|
+
query_attempt(execution, attempt: 5, started_at: hour + hours(1), duration_ms: 500.0)
|
|
297
|
+
query_attempt(query_execution(saga_name: invoice), step_name: "render", duration_ms: 5.0)
|
|
298
|
+
|
|
299
|
+
rows = store.step_latency(from: hour, to: hour + hours(1), saga_name: checkout)[:rows]
|
|
300
|
+
|
|
301
|
+
expect(rows).to contain_exactly(include(step_name: "charge", count: 1, max: 10.0))
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it "uses at most max_rows of the newest attempts" do
|
|
305
|
+
execution = query_execution
|
|
306
|
+
[10.0, 20.0, 30.0].each_with_index do |duration, index|
|
|
307
|
+
query_attempt(execution, attempt: index + 1, started_at: hour + minutes(index), duration_ms: duration)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
latency = store.step_latency(from: hour, to: hour + hours(1), max_rows: 2)
|
|
311
|
+
|
|
312
|
+
expect(latency[:truncated]).to be(true)
|
|
313
|
+
expect(latency[:rows]).to contain_exactly(include(count: 2, p50: 20.0, max: 30.0))
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
describe "#failure_summary" do
|
|
318
|
+
it "groups failed attempts by error class, saga and step, most frequent first" do
|
|
319
|
+
first = query_execution
|
|
320
|
+
second = query_execution
|
|
321
|
+
query_attempt(first, attempt: 1, status: "failed", started_at: hour + minutes(1),
|
|
322
|
+
error_class: "Timeout::Error", error_message: "first timeout")
|
|
323
|
+
query_attempt(second, attempt: 1, status: "failed", started_at: hour + minutes(3),
|
|
324
|
+
error_class: "Timeout::Error", error_message: "latest timeout")
|
|
325
|
+
query_attempt(first, attempt: 2, status: "failed", started_at: hour + minutes(2),
|
|
326
|
+
error_class: "ArgumentError", error_message: "bad amount")
|
|
327
|
+
query_attempt(first, step_name: "ship", status: "failed", started_at: hour + minutes(4),
|
|
328
|
+
error_class: "Timeout::Error", error_message: "ship timeout")
|
|
329
|
+
query_attempt(first, attempt: 3, started_at: hour + minutes(5))
|
|
330
|
+
|
|
331
|
+
summary = store.failure_summary(from: hour, to: hour + hours(1))
|
|
332
|
+
|
|
333
|
+
expect(summary.first).to include(
|
|
334
|
+
error_class: "Timeout::Error", saga_name: checkout, step_name: "charge", count: 2, execution_count: 2,
|
|
335
|
+
sample_message: "latest timeout", sample_execution_id: second.id
|
|
336
|
+
)
|
|
337
|
+
expect(summary.first[:last_seen_at]).to be_within(0.01).of(hour + minutes(3) + 0.1)
|
|
338
|
+
expect(summary.drop(1).map { |group| group.values_at(:error_class, :step_name, :count) })
|
|
339
|
+
.to eq([["Timeout::Error", "ship", 1], ["ArgumentError", "charge", 1]])
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "filters by window and saga and caps the groups at limit" do
|
|
343
|
+
execution = query_execution
|
|
344
|
+
query_attempt(execution, attempt: 1, status: "failed", started_at: hour - minutes(1), error_class: "OldError")
|
|
345
|
+
query_attempt(execution, attempt: 2, status: "failed", error_class: "KeyError")
|
|
346
|
+
query_attempt(execution, step_name: "ship", status: "failed", started_at: hour + minutes(1), error_class: "IOError")
|
|
347
|
+
query_attempt(query_execution(saga_name: invoice), step_name: "render", status: "failed", error_class: "KeyError")
|
|
348
|
+
|
|
349
|
+
expect(store.failure_summary(from: hour, to: hour + hours(1), saga_name: checkout).map { |group| group[:error_class] })
|
|
350
|
+
.to eq(%w[IOError KeyError])
|
|
351
|
+
expect(store.failure_summary(from: hour, to: hour + hours(1), limit: 1).size).to eq(1)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "counts the distinct executions of a group" do
|
|
355
|
+
execution = query_execution
|
|
356
|
+
query_attempt(execution, attempt: 1, status: "failed", error_class: "Timeout::Error")
|
|
357
|
+
query_attempt(execution, attempt: 2, status: "failed", error_class: "Timeout::Error")
|
|
358
|
+
|
|
359
|
+
expect(store.failure_summary(from: hour, to: hour + hours(1))).to contain_exactly(include(count: 2, execution_count: 1))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "is empty without failures" do
|
|
363
|
+
query_attempt(query_execution)
|
|
364
|
+
|
|
365
|
+
expect(store.failure_summary(from: hour, to: hour + hours(1))).to eq([])
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
describe "#saga_names" do
|
|
370
|
+
it "lists the distinct saga names, sorted" do
|
|
371
|
+
query_execution(saga_name: invoice)
|
|
372
|
+
query_execution
|
|
373
|
+
query_execution
|
|
374
|
+
|
|
375
|
+
expect(store.saga_names).to eq([invoice, checkout])
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|