lepus-saga 0.3.0 → 0.3.2
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +2 -2
- data/README.md +10 -8
- data/lib/lepus/saga/execution_behaviour.rb +10 -1
- data/lib/lepus/saga/executor.rb +4 -4
- data/lib/lepus/saga/rspec/engine_examples.rb +8 -1
- data/lib/lepus/saga/rspec/query_examples.rb +187 -15
- data/lib/lepus/saga/rspec/store_examples.rb +8 -7
- data/lib/lepus/saga/stores/base.rb +89 -18
- data/lib/lepus/saga/stores/memory.rb +35 -10
- data/lib/lepus/saga/version.rb +1 -1
- data/lib/lepus/saga/web/api.rb +53 -4
- data/lib/lepus/saga/web/serializer.rb +14 -1
- data/lib/lepus/saga.rb +1 -1
- data/web/assets/app.css +45 -1
- data/web/assets/js/api.js +1 -0
- data/web/assets/js/dom.js +1 -1
- data/web/assets/js/map.js +142 -10
- data/web/assets/js/views/execution.js +3 -3
- data/web/assets/js/views/executions.js +95 -21
- data/web/assets/js/views/overview.js +171 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78b15c3001956fd3fc7b24559d5445a0992398c9e9a648a374071f7017376e55
|
|
4
|
+
data.tar.gz: 868a8b17b8284a4cd09e3bde7ddd5d61be7cfea9e525eac2fe2df4d8c8d36d3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74b9f0adadfeb24255abeab7fea95d26b4168442291690bd2e7f3a9c118cd243096a3d1a32958083cc8ba36e07f7dfc689477b2a08677f6266d627b032553bd1
|
|
7
|
+
data.tar.gz: 6bcf0f25574c99f05ddf173269c9c16ee638389a59a01e44275c4c4581edaa2128d1290841c6d65dbc569c0ea0a6eb05638b3f6fea700eb89a20f2fe6e8a36e0
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 0.3.2 - Unreleased
|
|
8
|
+
* Fixed: the console's execution page no longer shows a stray "null" under the summary when the execution has no failure reason
|
|
9
|
+
|
|
10
|
+
## 0.3.1 - Unreleased
|
|
11
|
+
* Fixed: executions list in time order when their ids are not time-ordered (e.g. random UUIDv4): `list_executions` sorts by started_at and pages by `after` (the last row's id) instead of by id, `executions_for_resource` orders by started_at, and the stalled list and failure samples break ties by id only after time
|
|
12
|
+
* Added: `duration_ms` on executions (`ExecutionBehaviour::ATTRIBUTES`), written by the engine when an execution ends (completed, compensated, failed, compensation_failed, abandoned) and cleared when a retry reopens it; `finish_attributes` and `reopen_attributes` on `ExecutionBehaviour`
|
|
13
|
+
* Added: `list_executions(sort:, direction:)`: `:started_at` (default), `:duration` or `:finished_at`, `:desc` (default) or `:asc`, ties by id, executions without a value last in either direction; an unknown `after` returns an empty page. The console's Started and Took headers sort the executions list (buttons with `aria-sort`, a select on phones, kept in the URL)
|
|
14
|
+
* Changed: the executions list pages along the active sort ("Show older" follows it, `/api/executions` takes `sort`, `dir`, `after` and `limit` and returns `next`), changing the sort starts the list over and each refresh reloads the rows shown in that order. `before_id` and `next_before` are gone
|
|
15
|
+
* Changed: the `from`/`to` filters and the series' starts use started_at instead of created_at
|
|
16
|
+
* Added: `finished_executions(from:, to:, listed_from:, saga_name:, per_saga:, limit:)`, `executed_steps(executions)` and the `finished_from`/`finished_to` filters in the query contract, `GET /api/recent?since=&replay_from=` and the server's `now` on `/api/sagas` and `/api/moving`
|
|
17
|
+
* Added: the map replays executions that finished between refreshes: tokens seen moving run on to how they ended, executions that started and finished in between replay their path in a lighter shade (at most 8 a line, a "+N just finished" pill for the rest), a pause collapses into "+N finished while paused", reduced motion shows per-line summaries, and the map key gains "Just finished"
|
|
18
|
+
* Added: `bin/web-demo` starts quick webhook deliveries (more with `HEAVY=1`) that finish between refreshes
|
|
19
|
+
|
|
7
20
|
## 0.3.0 - Unreleased
|
|
8
21
|
* Added: brand mark and README logo
|
|
9
22
|
* Added: saga engine: `Lepus::Saga::Definition`, `Executor`, `ExecutorConsumer`, `StepProducer`, `QueueDepth`, `Instrumentation`, `LogSubscriber` and `Testing.drain`
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
lepus-saga (0.3.
|
|
4
|
+
lepus-saga (0.3.2)
|
|
5
5
|
lepus (>= 0.2.1)
|
|
6
6
|
rack (>= 2.2)
|
|
7
7
|
|
|
@@ -189,7 +189,7 @@ CHECKSUMS
|
|
|
189
189
|
json (3.1.0) sha256=3ce7ccf8fde58960177a3a756c31f292a4c1e1dedee884547096a5b0604e3bd8
|
|
190
190
|
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
191
191
|
lepus (0.2.2) sha256=7de6bcfb13e92b03af09d4a018c765aee6381624d8ace650705d5a87bea07fb7
|
|
192
|
-
lepus-saga (0.3.
|
|
192
|
+
lepus-saga (0.3.2)
|
|
193
193
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
194
194
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
195
195
|
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
|
data/README.md
CHANGED
|
@@ -151,8 +151,8 @@ Adapters usually offer richer queries on their own records (`Lepus::Saga::Active
|
|
|
151
151
|
|
|
152
152
|
`Lepus::Saga::Web` is a Rack app for the engineer on call. It has three screens:
|
|
153
153
|
|
|
154
|
-
- **Map**: every saga drawn as a line of its steps. Tokens are the executions moving right now, placed along the step by how long they have spent in it against the step's median, up to three lanes per step and a "+N running" pill for the rest (static when the viewer prefers reduced motion). Each step shows its stalled executions and its failures in the selected time range (1 h, 24 h or 7 d, kept in the URL). The headline
|
|
155
|
-
- **Executions**: a
|
|
154
|
+
- **Map**: every saga drawn as a line of its steps. Tokens are the executions moving right now, placed along the step by how long they have spent in it against the step's median, up to three lanes per step and a "+N running" pill for the rest (static when the viewer prefers reduced motion). Each step shows its stalled executions and its failures in the selected time range (1 h, 24 h or 7 d, kept in the URL). The headline counts how many executions are moving, compensating and stalled, and how many failed; the key under it names the marks (running, compensating, stalled, failed, just finished). Between refreshes the map also shows what finished: a token seen moving runs on from where it is to its end (completed ones reach the end terminal and fade, failed ones stop at the failing step and flash red, compensated ones turn back along the compensation rail), and executions that started and finished between two refreshes replay their whole path in about a second, in a lighter shade, staggered over the refresh in the order they started. At most 8 replay per line; a "+N just finished" pill at the end of the line counts the rest. The first load shows only what is running. After a pause or a hidden tab only the last refresh interval is animated and the rest becomes "+N finished while paused" (or "while away" for a hidden tab). With reduced motion nothing moves: each line ends with a short summary instead ("+3 completed · 1 failed"). Below the map, a full-width throughput chart (with a table view), **Needs attention** (failed attempts grouped by error, saga and step, each linking to exactly the executions that failed that way in the range) and **Waiting the longest** (the stalled executions with the oldest progress).
|
|
155
|
+
- **Executions**: a list filtered by status, saga, step, error, time range or stalled, sorted by Started (the default, latest first) or Took, either way; the sort is kept in the URL and on phones it is a select. "Show older" (or "Show more" in another order) pages along the active sort, and each refresh reloads the rows shown so far in that order.
|
|
156
156
|
- **Execution**: a replay of the execution along its line, its attempt history with errors and backtraces, its context and other runs of the same resource. Retry and compensate are one click plus a confirmation.
|
|
157
157
|
|
|
158
158
|
It reads through the store's optional query methods, so the store must report `queries?` (`Stores::Memory`, `Stores::ActiveRecord` and `Stores::Sequel` do).
|
|
@@ -204,11 +204,11 @@ end
|
|
|
204
204
|
|
|
205
205
|
`authorize` runs on every request: a falsy answer for `:read` returns 403 for the whole console, and for `:retry` or `:compensate` it refuses that action and the page hides its button. Retry and compensate are POSTs that need a JSON body, an `X-Lepus-Saga` header and a same-origin `Origin` (or `Referer`), so a cookie-authenticated session cannot be driven from another site.
|
|
206
206
|
|
|
207
|
-
The page refreshes itself by polling the JSON API. The Live button in the top bar picks the interval: Paused, 2, 5, 15 or 60 seconds (`default_poll_interval` until the viewer picks one). Polling also pauses while the tab is hidden, and the map's tokens freeze while paused. The theme follows the system, or Light or Dark when picked; the interval, the theme and the time range are kept in the browser.
|
|
207
|
+
The page refreshes itself by polling the JSON API. Responses that feed the map carry the server's `now`; the map asks `/api/recent?since=` with the previous one (and `replay_from` a little earlier, to catch late writes, which are drawn but not counted), so what finished in between is never measured against the browser's clock. The Live button in the top bar picks the interval: Paused, 2, 5, 15 or 60 seconds (`default_poll_interval` until the viewer picks one). Polling also pauses while the tab is hidden, and the map's tokens freeze while paused. The theme follows the system, or Light or Dark when picked; the interval, the theme and the time range are kept in the browser.
|
|
208
208
|
|
|
209
209
|
Content Security Policy: the console loads only its own files (no inline scripts or styles, no `eval`, no CDN) and sends `default-src 'self'` with its page. A host-wide policy works as long as it allows `'self'` for scripts, styles, fonts and fetches, and `data:` images. Static files are served with ETags; the page references them under a digest path, cached for a year.
|
|
210
210
|
|
|
211
|
-
To see the console without a database or RabbitMQ, run `bin/web-demo` from a checkout: it seeds an in-memory store with a week of executions generated by the real engine, keeps starting new ones, and serves the console on http://127.0.0.1:9292 (`PORT`, `HOST` and `DAYS` change it). `HEAVY=1` adds the load of a bad day: thousands of failed attempts, hundreds of executions crowding one step and hundreds of stalled ones. It is not part of the gem.
|
|
211
|
+
To see the console without a database or RabbitMQ, run `bin/web-demo` from a checkout: it seeds an in-memory store with a week of executions generated by the real engine, keeps starting new ones (among them quick webhook deliveries that start and finish between two refreshes, so the map has replays to show), and serves the console on http://127.0.0.1:9292 (`PORT`, `HOST` and `DAYS` change it). `HEAVY=1` adds the load of a bad day: thousands of failed attempts, hundreds of executions crowding one step and hundreds of stalled ones. It is not part of the gem.
|
|
212
212
|
|
|
213
213
|
The font is Overpass, under the SIL Open Font License (`web/assets/fonts/overpass/OFL.txt`).
|
|
214
214
|
|
|
@@ -254,7 +254,7 @@ ExecutorConsumer <────────────────────
|
|
|
254
254
|
|
|
255
255
|
- `Definition`, `Executor`, `ExecutorConsumer`, `StepProducer`, `QueueDepth`, `Instrumentation`, `LogSubscriber` and `Testing` only talk to `Lepus::Saga.store`.
|
|
256
256
|
- `Lepus::Saga::Stores::Base` is the persistence contract. Every method is documented in [`lib/lepus/saga/stores/base.rb`](lib/lepus/saga/stores/base.rb).
|
|
257
|
-
- `Lepus::Saga::ExecutionBehaviour` and `Lepus::Saga::StepAttemptBehaviour` hold what records mean: statuses and predicates (`running?`, `terminal?`, `compensate_direction?`), `record_progress`, `definition_class`, `partition`, `current_step_scope`, `duration` and `
|
|
257
|
+
- `Lepus::Saga::ExecutionBehaviour` and `Lepus::Saga::StepAttemptBehaviour` hold what records mean: statuses and predicates (`running?`, `terminal?`, `compensate_direction?`), `record_progress`, `definition_class`, `partition`, `current_step_scope`, `duration`, `finish_attributes` and, on executions, `reopen_attributes`. The engine stores an execution's `duration_ms` (finished_at minus started_at, in milliseconds) whenever it ends, and clears it with finished_at when a retry reopens it. Adapters include them into their own record classes.
|
|
258
258
|
|
|
259
259
|
### The store contract
|
|
260
260
|
|
|
@@ -267,7 +267,7 @@ ExecutorConsumer <────────────────────
|
|
|
267
267
|
| `with_locked_execution(id) { \|execution_or_nil\| }` | Same, from an id; Base implements it with `find_execution` + `with_lock`, adapters may do it in one query |
|
|
268
268
|
| `increment_resume_count(execution)` | One atomic `resume_count = resume_count + 1` that touches updated_at; sets and returns the stored value |
|
|
269
269
|
| `stalled_executions(before:)` | Running or compensating executions with `last_progress_at < before`, oldest first, as an Enumerable the caller may stop early |
|
|
270
|
-
| `executions_for_resource(type, id)` | A resource's executions,
|
|
270
|
+
| `executions_for_resource(type, id)` | A resource's executions, latest started_at first, ties by id |
|
|
271
271
|
| `create_step_attempt(execution, attributes)` | Inserts a `running` attempt; a second one with the same execution, step, direction, epoch and attempt raises `Lepus::Saga::DuplicateAttemptError` |
|
|
272
272
|
| `save_step_attempt(attempt)` | Persists the attempt, returns it |
|
|
273
273
|
| `step_attempts(execution)` | Attempts ordered by started_at, created_at |
|
|
@@ -283,9 +283,11 @@ The query methods are optional. The engine never calls them; read-only tools suc
|
|
|
283
283
|
| Method | Semantics |
|
|
284
284
|
| --- | --- |
|
|
285
285
|
| `queries?` | Whether the store implements the methods below |
|
|
286
|
-
| `list_executions(
|
|
286
|
+
| `list_executions(sort: :started_at, direction: :desc, after: nil, limit: 50, **filters)` | Executions in `sort` order (`:started_at`, `:duration` for duration_ms, or `:finished_at`), `direction` `:desc` or `:asc`, ties broken by id in the same direction, executions without a value (in progress) last in either direction. Ids need not follow time: rows with ids that are not time-ordered (e.g. random UUIDv4) still list in time order. `after` is the id of the last execution of the previous page; the page holds the executions strictly after it by (sort value, id), and an `after` naming no execution returns `[]`. Every filter is optional: `saga_name`, `status` and `current_step` take a value or a list, `partition` matches the column or (when it is nil) the definition's partition, `resource` is a case-insensitive substring of resource_type or resource_id, `from`/`to` bound started_at, `progress_from`/`progress_to` bound last_progress_at and `finished_from`/`finished_to` bound finished_at (inclusive/exclusive), `error_class` and `error_step` (a value or a list) keep executions with a failed attempt of that error class at that step, started between `error_from` and `error_to` when given |
|
|
287
287
|
| `count_executions(group_by:, **filters)` | A Hash of counts keyed by `:saga_name`, `:status`, `:partition` or `:current_step`, or by arrays of values for an array such as `[:saga_name, :status]`; partition groups resolve nil to the definition's partition |
|
|
288
|
-
| `
|
|
288
|
+
| `finished_executions(from:, to:, listed_from: nil, saga_name: nil, per_saga: nil, limit: 100)` | `{executions: [...], counts: {[saga_name, status] => n}}`: executions that finished in the window, latest finish first (ties by id), at most `per_saga` of each saga and `limit` in all, with counts of every one. `listed_from` (before `from`) also lists, without counting, the ones that finished from then on. Base builds it on `list_executions` and `count_executions` |
|
|
289
|
+
| `executed_steps(executions)` | `{id => [step_name, ...]}`: the steps each execution ran forward, every id present. Base calls `step_attempts` per execution; stores override it with one query |
|
|
290
|
+
| `execution_series(from:, to:, bucket:, saga_name: nil, max_rows:)` | `{rows: [[bucket_start, {"started" => n, "completed" => n, "compensated" => n, "failed" => n, "compensation_failed" => n}], ...], truncated:}`: starts by started_at, terminal statuses by finished_at, every bucket of the window aligned to multiples of `bucket` seconds, at most `max_rows` timestamps of each kind (the newest) |
|
|
289
291
|
| `step_latency(from:, to:, saga_name: nil, max_rows:)` | `{rows: [{saga_name:, step_name:, direction:, count:, p50:, p95:, p99:, max:}, ...], truncated:}`: nearest-rank percentiles in milliseconds of succeeded and failed attempts started in the window, from the newest `max_rows` |
|
|
290
292
|
| `failure_summary(from:, to:, saga_name: nil, limit: 20)` | `[{error_class:, saga_name:, step_name:, count:, execution_count:, last_seen_at:, sample_message:, sample_execution_id:}, ...]`: failed attempts started in the window grouped by error class, saga and step, most frequent first, `execution_count` being the distinct executions and the sample the latest failure |
|
|
291
293
|
| `saga_names` | The distinct saga names in storage, sorted |
|
|
@@ -8,7 +8,7 @@ module Lepus
|
|
|
8
8
|
ATTRIBUTES = %i[
|
|
9
9
|
id saga_name resource_type resource_id status current_step direction
|
|
10
10
|
epoch context correlation_id failure_reason partition started_at
|
|
11
|
-
finished_at last_progress_at resume_count created_at updated_at
|
|
11
|
+
finished_at duration_ms last_progress_at resume_count created_at updated_at
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
14
|
STATUSES = %w[running completed compensating compensated failed compensation_failed].freeze
|
|
@@ -53,6 +53,15 @@ module Lepus
|
|
|
53
53
|
def duration
|
|
54
54
|
(finished_at || Time.now.utc) - started_at
|
|
55
55
|
end
|
|
56
|
+
|
|
57
|
+
# The attributes that end this execution at `now`: finished_at and the
|
|
58
|
+
# whole run's duration_ms, which the console sorts by.
|
|
59
|
+
def finish_attributes(now = Time.now.utc)
|
|
60
|
+
{finished_at: now, duration_ms: ((now - started_at) * 1000).round}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The attributes that reopen a finished execution.
|
|
64
|
+
def reopen_attributes = {finished_at: nil, duration_ms: nil}
|
|
56
65
|
end
|
|
57
66
|
end
|
|
58
67
|
end
|
data/lib/lepus/saga/executor.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Lepus
|
|
|
30
30
|
current_step: target.name, failure_reason: reason)
|
|
31
31
|
else
|
|
32
32
|
store.update_execution(execution,
|
|
33
|
-
status: "failed", failure_reason: reason,
|
|
33
|
+
status: "failed", failure_reason: reason, **execution.finish_attributes)
|
|
34
34
|
end
|
|
35
35
|
execution
|
|
36
36
|
end
|
|
@@ -182,7 +182,7 @@ module Lepus
|
|
|
182
182
|
finished = with_fresh_lock do
|
|
183
183
|
now = Time.now.utc
|
|
184
184
|
store.finish_step_attempt(attempt, "succeeded", context_after: new_context)
|
|
185
|
-
store.update_execution(execution, context: new_context, status: "completed",
|
|
185
|
+
store.update_execution(execution, context: new_context, status: "completed", **execution.finish_attributes(now),
|
|
186
186
|
last_progress_at: now, resume_count: 0)
|
|
187
187
|
end
|
|
188
188
|
return :ack unless finished
|
|
@@ -231,7 +231,7 @@ module Lepus
|
|
|
231
231
|
def apply_failure(reason)
|
|
232
232
|
if execution.compensate_direction?
|
|
233
233
|
store.update_execution(execution, status: "compensation_failed", failure_reason: reason,
|
|
234
|
-
|
|
234
|
+
**execution.finish_attributes)
|
|
235
235
|
:compensation_failed
|
|
236
236
|
else
|
|
237
237
|
self.class.begin_compensation(execution, reason: reason)
|
|
@@ -270,7 +270,7 @@ module Lepus
|
|
|
270
270
|
|
|
271
271
|
def complete_as(status)
|
|
272
272
|
execution.status = status
|
|
273
|
-
execution.
|
|
273
|
+
execution.finish_attributes.each { |name, value| execution.public_send(:"#{name}=", value) }
|
|
274
274
|
"saga.#{status}"
|
|
275
275
|
end
|
|
276
276
|
|
|
@@ -152,6 +152,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
152
152
|
|
|
153
153
|
expect(execution.status).to eq("completed")
|
|
154
154
|
expect(execution.finished_at).not_to be_nil
|
|
155
|
+
expect(execution.duration_ms).to be_within(1).of((execution.finished_at - execution.started_at) * 1000)
|
|
155
156
|
expect(execution.context).to include("first_output" => "ok", "second_output" => "ok")
|
|
156
157
|
expect(succeeded_steps(execution)).to eq(%w[first second third])
|
|
157
158
|
end
|
|
@@ -174,6 +175,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
174
175
|
execution = start_and_drain("complete_on_first" => true)
|
|
175
176
|
|
|
176
177
|
expect(execution.status).to eq("completed")
|
|
178
|
+
expect(execution.duration_ms).to be >= 0
|
|
177
179
|
expect(execution.context["completed_reason"]).to eq("early exit")
|
|
178
180
|
expect(attempts(execution).map(&:step_name)).to eq(%w[first])
|
|
179
181
|
end
|
|
@@ -197,6 +199,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
197
199
|
execution = start_and_drain("second_transient_failures" => 99)
|
|
198
200
|
|
|
199
201
|
expect(execution.status).to eq("compensated")
|
|
202
|
+
expect(execution.duration_ms).to be_within(1).of((execution.finished_at - execution.started_at) * 1000)
|
|
200
203
|
expect(execution.failure_reason).to eq("transient boom")
|
|
201
204
|
expect(attempt_statuses(execution, "second")).to eq(%w[failed failed])
|
|
202
205
|
expect(sole(attempts(execution, step_name: "first", direction: "compensate")).status).to eq("succeeded")
|
|
@@ -215,6 +218,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
215
218
|
execution = start_and_drain("fail_first" => true)
|
|
216
219
|
|
|
217
220
|
expect(execution.status).to eq("failed")
|
|
221
|
+
expect(execution.duration_ms).to be >= 0
|
|
218
222
|
expect(execution.failure_reason).to eq("first rejected")
|
|
219
223
|
expect(attempts(execution, direction: "compensate")).to be_empty
|
|
220
224
|
end
|
|
@@ -231,6 +235,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
231
235
|
execution = start_and_drain("fail_second" => true, "fail_undo_first" => true)
|
|
232
236
|
|
|
233
237
|
expect(execution).to have_attributes(status: "compensation_failed", current_step: "first", direction: "compensate")
|
|
238
|
+
expect(execution.duration_ms).to be >= 0
|
|
234
239
|
end
|
|
235
240
|
|
|
236
241
|
it "acks stale-epoch messages without running anything" do
|
|
@@ -353,7 +358,8 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
353
358
|
execution = start_and_drain("fail_first" => true)
|
|
354
359
|
|
|
355
360
|
expect(Lepus::Saga.retry(execution)).to equal(execution)
|
|
356
|
-
expect(execution).to have_attributes(status: "running", epoch: 1, failure_reason: nil, finished_at: nil)
|
|
361
|
+
expect(execution).to have_attributes(status: "running", epoch: 1, failure_reason: nil, finished_at: nil, duration_ms: nil)
|
|
362
|
+
expect(reload(execution)).to have_attributes(finished_at: nil, duration_ms: nil)
|
|
357
363
|
end
|
|
358
364
|
|
|
359
365
|
it "refuses to retry completed executions" do
|
|
@@ -533,6 +539,7 @@ RSpec.shared_examples "a lepus saga engine" do |concurrency: true|
|
|
|
533
539
|
expect(Lepus::Saga.resume_stuck).to eq(0)
|
|
534
540
|
expect(reload(execution).status).to eq("failed")
|
|
535
541
|
expect(reload(execution).finished_at).not_to be_nil
|
|
542
|
+
expect(reload(execution).duration_ms).to be >= 0
|
|
536
543
|
end
|
|
537
544
|
|
|
538
545
|
it "gives an abandoned execution a fresh budget on manual retry" do
|
|
@@ -34,12 +34,37 @@ RSpec.shared_examples "a lepus saga query store" do
|
|
|
34
34
|
end)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def query_execution(saga_name: checkout, status: "running", created_at: hour, finished_at: nil, **attributes)
|
|
37
|
+
def query_execution(saga_name: checkout, status: "running", created_at: hour, finished_at: nil, duration_ms: nil, **attributes)
|
|
38
38
|
execution = store.create_execution({
|
|
39
39
|
saga_name: saga_name, resource_type: "Order", resource_id: "order-1", current_step: "reserve",
|
|
40
40
|
partition: nil, started_at: created_at, last_progress_at: created_at
|
|
41
41
|
}.merge(attributes))
|
|
42
|
-
store.update_execution(execution, status: status, created_at: created_at, finished_at: finished_at)
|
|
42
|
+
store.update_execution(execution, status: status, created_at: created_at, finished_at: finished_at, duration_ms: duration_ms)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Executions created in the order given, so their ids (time-ordered by
|
|
46
|
+
# creation) do not follow their started_at.
|
|
47
|
+
def started_at_minutes(*offsets, **attributes)
|
|
48
|
+
offsets.map { |minute| query_execution(created_at: hour + minutes(minute), **attributes) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def finished(duration_ms, created_at: hour, **attributes)
|
|
52
|
+
query_execution(status: "completed", created_at: created_at, finished_at: created_at + duration_ms / 1000.0, duration_ms: duration_ms, **attributes)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ids(executions) = executions.map(&:id)
|
|
56
|
+
|
|
57
|
+
def all_pages(size, **options)
|
|
58
|
+
pages = []
|
|
59
|
+
after = nil
|
|
60
|
+
loop do
|
|
61
|
+
page = store.list_executions(**options, after: after, limit: size)
|
|
62
|
+
break if page.empty?
|
|
63
|
+
|
|
64
|
+
pages << ids(page)
|
|
65
|
+
after = page.last.id
|
|
66
|
+
end
|
|
67
|
+
pages
|
|
43
68
|
end
|
|
44
69
|
|
|
45
70
|
def query_attempt(execution, step_name: "charge", attempt: 1, direction: "execute", status: "succeeded",
|
|
@@ -57,23 +82,82 @@ RSpec.shared_examples "a lepus saga query store" do
|
|
|
57
82
|
end
|
|
58
83
|
|
|
59
84
|
describe "#list_executions" do
|
|
60
|
-
it "returns execution records,
|
|
61
|
-
|
|
85
|
+
it "returns execution records, latest start first, whatever the ids" do
|
|
86
|
+
executions = started_at_minutes(3, 0, 4, 1, 2)
|
|
87
|
+
by_start = ids(executions.sort_by(&:started_at).reverse)
|
|
88
|
+
|
|
89
|
+
expect(store.list_executions).to all(be_a(Lepus::Saga::ExecutionBehaviour))
|
|
90
|
+
expect(ids(store.list_executions)).to eq(by_start)
|
|
91
|
+
expect(ids(store.list_executions(sort: :started_at, direction: :asc))).to eq(by_start.reverse)
|
|
92
|
+
expect(ids(store.list_executions(sort: "started_at", direction: "desc", limit: 2))).to eq(by_start.first(2))
|
|
93
|
+
end
|
|
62
94
|
|
|
63
|
-
|
|
95
|
+
it "breaks ties by id in the direction of the sort" do
|
|
96
|
+
tied = ids(started_at_minutes(1, 1, 1)).sort
|
|
97
|
+
later, earlier = started_at_minutes(2, 0)
|
|
64
98
|
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
99
|
+
expect(ids(store.list_executions)).to eq([later.id, *tied.reverse, earlier.id])
|
|
100
|
+
expect(ids(store.list_executions(direction: :asc))).to eq([earlier.id, *tied, later.id])
|
|
67
101
|
end
|
|
68
102
|
|
|
69
|
-
it "pages
|
|
70
|
-
|
|
103
|
+
it "pages after the last execution shown, through ties, without gaps or repeats" do
|
|
104
|
+
executions = started_at_minutes(2, 1, 1, 3, 1, 1, 0)
|
|
71
105
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
106
|
+
[:desc, :asc].each do |direction|
|
|
107
|
+
expected = ids(store.list_executions(direction: direction, limit: 100))
|
|
108
|
+
pages = all_pages(2, direction: direction)
|
|
75
109
|
|
|
76
|
-
|
|
110
|
+
expect(pages.flatten).to eq(expected)
|
|
111
|
+
expect(pages.map(&:size)).to eq([2, 2, 2, 1])
|
|
112
|
+
expect(expected).to match_array(ids(executions))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "sorts by duration with executions that have none last in either direction" do
|
|
117
|
+
slow = finished(9_000)
|
|
118
|
+
running = query_execution
|
|
119
|
+
fast = finished(100)
|
|
120
|
+
compensating = query_execution(status: "compensating")
|
|
121
|
+
medium = finished(2_500)
|
|
122
|
+
unfinished = [running, compensating].map(&:id).sort
|
|
123
|
+
|
|
124
|
+
expect(ids(store.list_executions(sort: :duration))).to eq([slow.id, medium.id, fast.id, *unfinished.reverse])
|
|
125
|
+
expect(ids(store.list_executions(sort: :duration, direction: :asc))).to eq([fast.id, medium.id, slow.id, *unfinished])
|
|
126
|
+
expect(store.list_executions(sort: :duration, limit: 1).first.duration_ms).to eq(9_000)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "pages by duration through ties and across executions without one" do
|
|
130
|
+
[400, nil, 200, 400, nil, 100, 400, nil].each_with_index do |duration, index|
|
|
131
|
+
duration ? finished(duration, created_at: hour + minutes(index)) : query_execution(created_at: hour + minutes(index))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
page_sizes = [1, 2, 3, 5]
|
|
135
|
+
{desc: [400, 400, 400, 200, 100, nil, nil, nil], asc: [100, 200, 400, 400, 400, nil, nil, nil]}.each do |direction, durations|
|
|
136
|
+
listed = store.list_executions(sort: :duration, direction: direction, limit: 100)
|
|
137
|
+
page_sizes.each do |size|
|
|
138
|
+
expect(all_pages(size, sort: :duration, direction: direction).flatten).to eq(ids(listed))
|
|
139
|
+
end
|
|
140
|
+
expect(listed.map(&:duration_ms)).to eq(durations)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "pages after an execution the filters leave out by its sort value" do
|
|
145
|
+
first, hidden, last = started_at_minutes(3, 2, 1)
|
|
146
|
+
hidden = store.update_execution(hidden, status: "failed")
|
|
147
|
+
|
|
148
|
+
expect(ids(store.list_executions(status: "running", after: hidden.id))).to eq([last.id])
|
|
149
|
+
expect(ids(store.list_executions(status: "running", after: first.id))).to eq([last.id])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "returns an empty page after an id that names no execution" do
|
|
153
|
+
query_execution
|
|
154
|
+
|
|
155
|
+
expect(store.list_executions(after: "00000000-0000-4000-8000-000000000000")).to eq([])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "rejects unknown sorts and directions" do
|
|
159
|
+
expect { store.list_executions(sort: :resource_id) }.to raise_error(ArgumentError)
|
|
160
|
+
expect { store.list_executions(direction: :sideways) }.to raise_error(ArgumentError)
|
|
77
161
|
end
|
|
78
162
|
|
|
79
163
|
it "filters by saga name and status, single or listed" do
|
|
@@ -128,7 +212,7 @@ RSpec.shared_examples "a lepus saga query store" do
|
|
|
128
212
|
expect(store.list_executions(resource: "a_b").map(&:id)).to contain_exactly(underscore.id)
|
|
129
213
|
end
|
|
130
214
|
|
|
131
|
-
it "bounds
|
|
215
|
+
it "bounds started_at by from (inclusive) and to (exclusive)" do
|
|
132
216
|
query_execution(created_at: hour - minutes(1))
|
|
133
217
|
at_from = query_execution(created_at: hour)
|
|
134
218
|
inside = query_execution(created_at: hour + minutes(30))
|
|
@@ -140,6 +224,94 @@ RSpec.shared_examples "a lepus saga query store" do
|
|
|
140
224
|
end
|
|
141
225
|
end
|
|
142
226
|
|
|
227
|
+
describe "finished filter" do
|
|
228
|
+
it "bounds finished_at by finished_from (inclusive) and finished_to (exclusive)" do
|
|
229
|
+
query_execution(status: "completed", finished_at: hour - 1)
|
|
230
|
+
at_from = query_execution(status: "failed", finished_at: hour)
|
|
231
|
+
inside = query_execution(status: "completed", finished_at: hour + minutes(30))
|
|
232
|
+
query_execution(status: "completed", finished_at: hour + hours(1))
|
|
233
|
+
query_execution
|
|
234
|
+
|
|
235
|
+
expect(ids(store.list_executions(finished_from: hour, finished_to: hour + hours(1)))).to contain_exactly(at_from.id, inside.id)
|
|
236
|
+
expect(store.count_executions(group_by: :status, finished_from: hour)).to eq("failed" => 1, "completed" => 2)
|
|
237
|
+
expect(store.list_executions(finished_to: hour).size).to eq(1)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
describe "#finished_executions" do
|
|
242
|
+
it "returns the executions that finished in the window, latest finish first, with counts by saga and status" do
|
|
243
|
+
query_execution(status: "completed", finished_at: hour - 1)
|
|
244
|
+
at_from = query_execution(status: "completed", finished_at: hour)
|
|
245
|
+
failed = query_execution(status: "failed", finished_at: hour + minutes(2))
|
|
246
|
+
compensated = query_execution(saga_name: invoice, status: "compensated", finished_at: hour + minutes(1))
|
|
247
|
+
query_execution(status: "completed", finished_at: hour + minutes(5))
|
|
248
|
+
query_execution
|
|
249
|
+
|
|
250
|
+
result = store.finished_executions(from: hour, to: hour + minutes(5))
|
|
251
|
+
|
|
252
|
+
expect(result[:executions]).to all(be_a(Lepus::Saga::ExecutionBehaviour))
|
|
253
|
+
expect(ids(result[:executions])).to eq([failed.id, compensated.id, at_from.id])
|
|
254
|
+
expect(result[:counts]).to eq([checkout, "completed"] => 1, [checkout, "failed"] => 1, [invoice, "compensated"] => 1)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "keeps the latest per_saga of each saga and at most limit, counting every one" do
|
|
258
|
+
checkouts = [4, 1, 3, 2].map { |minute| query_execution(status: "completed", finished_at: hour + minutes(minute)) }
|
|
259
|
+
invoice_failed = query_execution(saga_name: invoice, status: "failed", finished_at: hour)
|
|
260
|
+
window = {from: hour, to: hour + hours(1)}
|
|
261
|
+
|
|
262
|
+
result = store.finished_executions(**window, per_saga: 2)
|
|
263
|
+
|
|
264
|
+
expect(ids(result[:executions])).to eq([checkouts[0].id, checkouts[2].id, invoice_failed.id])
|
|
265
|
+
expect(result[:counts]).to eq([checkout, "completed"] => 4, [invoice, "failed"] => 1)
|
|
266
|
+
expect(ids(store.finished_executions(**window, limit: 2)[:executions])).to eq([checkouts[0].id, checkouts[2].id])
|
|
267
|
+
expect(ids(store.finished_executions(**window, saga_name: [invoice])[:executions])).to eq([invoice_failed.id])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "breaks finish ties by id, latest first" do
|
|
271
|
+
tied = Array.new(3) { query_execution(status: "completed", finished_at: hour) }
|
|
272
|
+
|
|
273
|
+
expect(ids(store.finished_executions(from: hour, to: hour + 1)[:executions])).to eq(ids(tied).sort.reverse)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "is empty when nothing finished in the window" do
|
|
277
|
+
query_execution
|
|
278
|
+
|
|
279
|
+
expect(store.finished_executions(from: hour, to: hour + hours(1))).to eq(executions: [], counts: {})
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "lists, without counting, the executions that finished from listed_from" do
|
|
283
|
+
before = query_execution(saga_name: invoice, status: "failed", finished_at: hour - minutes(1))
|
|
284
|
+
query_execution(status: "completed", finished_at: hour - minutes(5))
|
|
285
|
+
inside = query_execution(status: "completed", finished_at: hour + minutes(1))
|
|
286
|
+
|
|
287
|
+
result = store.finished_executions(from: hour, to: hour + hours(1), listed_from: hour - minutes(2), per_saga: 5)
|
|
288
|
+
|
|
289
|
+
expect(ids(result[:executions])).to eq([inside.id, before.id])
|
|
290
|
+
expect(result[:counts]).to eq([checkout, "completed"] => 1)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
describe "#executed_steps" do
|
|
295
|
+
it "names the steps each execution ran forward, keyed by id, every id present" do
|
|
296
|
+
failed = query_execution(status: "failed")
|
|
297
|
+
query_attempt(failed, step_name: "reserve")
|
|
298
|
+
query_attempt(failed, step_name: "charge", status: "failed")
|
|
299
|
+
query_attempt(failed, step_name: "charge", attempt: 2, status: "failed")
|
|
300
|
+
compensated = query_execution(status: "compensated")
|
|
301
|
+
query_attempt(compensated, step_name: "reserve")
|
|
302
|
+
query_attempt(compensated, step_name: "reserve", direction: "compensate")
|
|
303
|
+
untouched = query_execution
|
|
304
|
+
|
|
305
|
+
steps = store.executed_steps([failed, compensated, untouched])
|
|
306
|
+
|
|
307
|
+
expect(steps.keys).to contain_exactly(failed.id, compensated.id, untouched.id)
|
|
308
|
+
expect(steps[failed.id]).to contain_exactly("reserve", "charge")
|
|
309
|
+
expect(steps[compensated.id]).to eq(["reserve"])
|
|
310
|
+
expect(steps[untouched.id]).to eq([])
|
|
311
|
+
expect(store.executed_steps([])).to eq({})
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
143
315
|
describe "progress and error filters" do
|
|
144
316
|
it "bounds last_progress_at by progress_from (inclusive) and progress_to (exclusive)" do
|
|
145
317
|
query_execution(last_progress_at: hour - minutes(1))
|
|
@@ -228,7 +400,7 @@ RSpec.shared_examples "a lepus saga query store" do
|
|
|
228
400
|
end
|
|
229
401
|
|
|
230
402
|
describe "#execution_series" do
|
|
231
|
-
it "counts starts by
|
|
403
|
+
it "counts starts by started_at and terminal statuses by finished_at in aligned buckets" do
|
|
232
404
|
query_execution(created_at: hour + minutes(5))
|
|
233
405
|
query_execution(created_at: hour + minutes(10), status: "completed", finished_at: hour + minutes(20))
|
|
234
406
|
query_execution(created_at: hour - hours(1), status: "failed", finished_at: hour + minutes(70))
|
|
@@ -55,7 +55,7 @@ RSpec.shared_examples "a lepus saga store" do |concurrency: true|
|
|
|
55
55
|
|
|
56
56
|
expect(execution).to have_attributes(
|
|
57
57
|
status: "running", direction: "execute", epoch: 0, resume_count: 0,
|
|
58
|
-
failure_reason: nil, finished_at: nil
|
|
58
|
+
failure_reason: nil, finished_at: nil, duration_ms: nil
|
|
59
59
|
)
|
|
60
60
|
expect(execution.created_at).to be_within(1).of(Time.now.utc)
|
|
61
61
|
expect(execution.updated_at).to be_within(1).of(Time.now.utc)
|
|
@@ -117,11 +117,12 @@ RSpec.shared_examples "a lepus saga store" do |concurrency: true|
|
|
|
117
117
|
execution.failure_reason = "boom " * 100
|
|
118
118
|
execution.context = {"after" => true}
|
|
119
119
|
execution.finished_at = Time.now.utc
|
|
120
|
+
execution.duration_ms = 86_400_000 * 400
|
|
120
121
|
|
|
121
122
|
expect(store.save_execution(execution)).to eq(execution)
|
|
122
123
|
expect(store.find_execution(execution.id)).to have_attributes(
|
|
123
124
|
status: "compensating", direction: "compensate", current_step: "second", epoch: 2,
|
|
124
|
-
failure_reason: "boom " * 100, context: {"after" => true}
|
|
125
|
+
failure_reason: "boom " * 100, context: {"after" => true}, duration_ms: 86_400_000 * 400
|
|
125
126
|
)
|
|
126
127
|
end
|
|
127
128
|
|
|
@@ -367,14 +368,14 @@ RSpec.shared_examples "a lepus saga store" do |concurrency: true|
|
|
|
367
368
|
end
|
|
368
369
|
|
|
369
370
|
describe "#executions_for_resource" do
|
|
370
|
-
it "returns the resource's executions,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
it "returns the resource's executions, latest start first, whatever the ids" do
|
|
372
|
+
latest = create_execution(resource_id: "r-1", started_at: ago(minutes(1)))
|
|
373
|
+
earliest = create_execution(resource_id: "r-1", started_at: ago(hours(2)))
|
|
374
|
+
middle = create_execution(resource_id: "r-1", started_at: ago(hours(1)))
|
|
374
375
|
create_execution(resource_id: "r-2")
|
|
375
376
|
create_execution(resource_type: "Other", resource_id: "r-1")
|
|
376
377
|
|
|
377
|
-
expect(store.executions_for_resource("Resource", "r-1").map(&:id)).to eq([
|
|
378
|
+
expect(store.executions_for_resource("Resource", "r-1").map(&:id)).to eq([latest.id, middle.id, earliest.id])
|
|
378
379
|
end
|
|
379
380
|
end
|
|
380
381
|
|