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