lepus-saga 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7886960ade3be84a12c25f22f85047e113bdd2ca19055b6f065624c15d1bb3af
4
- data.tar.gz: 4cd2d1fd471c40db739ccb41dcef0bf52196a1a240f95c53404fced9bb264566
3
+ metadata.gz: 420a92218368dabfe1bd08ffa853f1b6dd3658c4459ced0a8244bb30e2c77999
4
+ data.tar.gz: 56eced08bd2568db86a0a6bb039c4ee55f277d08a3e3b05916227ddf6d72c516
5
5
  SHA512:
6
- metadata.gz: c1e827eb0eb4924c0951a5dd0c6aa08233fa141c7393e275ea0d19f1fbd5d038f2a675f7747762246814ec6dfca1f3ea6949fda0cc4d16ecc85c4ceda3891138
7
- data.tar.gz: 3f9759919aa086d1afda979ee040010b549b9dd353944e4f5eb03a3373c6c2b9de4beaffb7a995cebf5ad30706aa19f43a880483bf3198ca65e274fb5a808d79
6
+ metadata.gz: 4c05aa5bc8fc6907bcb712bb42e575660f577e75d659c64874f17ea3d7fb4181d998c6aa58ed5dd6195924d82b325453d055ca110b77ab300416e45389aec2f2
7
+ data.tar.gz: 398f27e8b2d184c34ea0a76f67e82db5fe6d0334cf00616d7602c70b30647393b4d1476e357d10820e0e1e908dfdaf55e47db7da78a115a6ffe4312effb80c21
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ 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.1 - Unreleased
8
+ * 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
9
+ * 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`
10
+ * 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)
11
+ * 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
12
+ * Changed: the `from`/`to` filters and the series' starts use started_at instead of created_at
13
+ * 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`
14
+ * 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"
15
+ * Added: `bin/web-demo` starts quick webhook deliveries (more with `HEAVY=1`) that finish between refreshes
16
+
7
17
  ## 0.3.0 - Unreleased
8
18
  * Added: brand mark and README logo
9
19
  * 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.0)
4
+ lepus-saga (0.3.1)
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.0)
192
+ lepus-saga (0.3.1)
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 reads as the key: how many executions are moving, compensating and stalled, and how many 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 paged list filtered by status, saga, step, error, time range or stalled.
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 `finish_attributes`. Adapters include them into their own record classes.
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, newest first |
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(saga_name:, status:, current_step:, partition:, resource:, from:, to:, progress_from:, progress_to:, error_class:, error_step:, error_from:, error_to:, before_id:, limit: 50)` | Executions newest first by id, `limit` of them, the page after `before_id` when given. 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 created_at and `progress_from`/`progress_to` bound last_progress_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 |
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
- | `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 created_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) |
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
@@ -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, finished_at: Time.now.utc)
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", finished_at: now,
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
- finished_at: Time.now.utc)
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.finished_at = Time.now.utc
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, newest id first" do
61
- ids = Array.new(3) { query_execution.id }
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
- executions = store.list_executions
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(executions).to all(be_a(Lepus::Saga::ExecutionBehaviour))
66
- expect(executions.map(&:id)).to eq(ids.sort.reverse)
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 with limit and before_id" do
70
- ids = Array.new(5) { query_execution.id }.sort.reverse
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
- 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)
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
- expect([first_page, second_page, last_page].map { |page| page.map(&:id) }).to eq(ids.each_slice(2).to_a)
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 created_at by from (inclusive) and to (exclusive)" do
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 created_at and terminal statuses by finished_at in aligned buckets" do
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, newest first" do
371
- older = create_execution(resource_id: "r-1")
372
- sleep 0.01
373
- newer = create_execution(resource_id: "r-1")
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([newer.id, older.id])
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