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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { api } from "../api.js";
|
|
2
|
-
import { h, s, clear, refill } from "../dom.js";
|
|
3
|
-
import { every, pollMs } from "../live.js";
|
|
2
|
+
import { h, s, clear, refill, reducedMotion } from "../dom.js";
|
|
3
|
+
import { every, isLive, onLive, pollMs } from "../live.js";
|
|
4
4
|
import { LineMap } from "../map.js";
|
|
5
5
|
import { rememberSagas } from "../sagas.js";
|
|
6
6
|
import { ago, compact, count, dayMonth, duration, hourMinute, plural, toTime, waited } from "../format.js";
|
|
@@ -14,6 +14,13 @@ const PER_STATION = 9;
|
|
|
14
14
|
const STATIONS_FETCHED = 40;
|
|
15
15
|
const SETTLE_PER_POLL = 12;
|
|
16
16
|
const SLOW_MS = 30_000;
|
|
17
|
+
const REPLAY_PER_LINE = 8;
|
|
18
|
+
const REPLAY_MS = 1000;
|
|
19
|
+
const OVERLAP_MS = 2000;
|
|
20
|
+
const HANDLED_KEPT = 5000;
|
|
21
|
+
const NOTE_MIN_MS = 4000;
|
|
22
|
+
const TERMINAL = ["completed", "compensated", "failed", "compensation_failed"];
|
|
23
|
+
const ENDINGS = [["completed", "completed", "done"], ["compensated", "compensated", "back"], ["failed", "failed", "stop"], ["compensation_failed", "compensation failed", "stop"]];
|
|
17
24
|
|
|
18
25
|
function symbol(kind) {
|
|
19
26
|
const svg = s("svg", { viewBox: "0 0 20 20", "aria-hidden": "true" });
|
|
@@ -21,9 +28,14 @@ function symbol(kind) {
|
|
|
21
28
|
if (kind === "idle") svg.append(s("rect", { class: "sym-idle", x: 2, y: 5.5, width: 16, height: 9, rx: 4.5 }));
|
|
22
29
|
if (kind === "stop") svg.append(s("rect", { class: "sym-stop", x: 7, y: 0, width: 6, height: 20, rx: 2 }));
|
|
23
30
|
if (kind === "back") svg.append(s("rect", { class: "sym-back", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
|
|
31
|
+
if (kind === "replay") svg.append(s("rect", { class: "sym-replay", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
|
|
24
32
|
return h("span", { class: "sym" }, svg);
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
function keyItem(kind, text) {
|
|
36
|
+
return h("li", {}, symbol(kind), text);
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const shortError = (name) => (name ? name.split("::").pop() : "Unknown error");
|
|
28
40
|
const withoutStep = (message, step) => (message || "").replace(`${step}: `, "");
|
|
29
41
|
|
|
@@ -75,6 +87,7 @@ export function overview(root, { me, params }) {
|
|
|
75
87
|
h("span", { class: "range-label", id: "range-label" }, "Time range"),
|
|
76
88
|
h("div", { class: "seg range", role: "group", "aria-labelledby": "range-label" }, rangeButtons));
|
|
77
89
|
const subline = h("p", { class: "subline" });
|
|
90
|
+
const mapKey = h("ul", { class: "m-key", "aria-label": "Map key" });
|
|
78
91
|
const mapHost = h("div", { class: "map" });
|
|
79
92
|
const throughputNote = h("p", { class: "section-note" });
|
|
80
93
|
const throughputHost = h("div", { class: "tp" });
|
|
@@ -89,6 +102,7 @@ export function overview(root, { me, params }) {
|
|
|
89
102
|
headline,
|
|
90
103
|
rangeGroup,
|
|
91
104
|
subline,
|
|
105
|
+
mapKey,
|
|
92
106
|
mapHost,
|
|
93
107
|
h("section", { class: "through", "aria-labelledby": "through-title" },
|
|
94
108
|
h("div", { class: "through-head" },
|
|
@@ -109,8 +123,16 @@ export function overview(root, { me, params }) {
|
|
|
109
123
|
);
|
|
110
124
|
const throughput = new Throughput(throughputHost);
|
|
111
125
|
|
|
126
|
+
function describeKey() {
|
|
127
|
+
const finished = reducedMotion.matches ? "Just finished, summed up at the end of the line" : "Just finished";
|
|
128
|
+
refill(mapKey, keyItem("move", "Running"), keyItem("back", "Compensating"), keyItem("idle", "Stalled"),
|
|
129
|
+
keyItem("stop", "Failed"), keyItem("replay", finished));
|
|
130
|
+
}
|
|
131
|
+
describeKey();
|
|
132
|
+
reducedMotion.addEventListener("change", describeKey);
|
|
133
|
+
|
|
112
134
|
function describeRange() {
|
|
113
|
-
subline.textContent = `Each line is a saga and each stop a step. Tokens are executions in flight right now, placed by how long they have spent in the step: halfway to the next stop at the step's median. Red bars are failures in ${range.phrase}. Point at a stop for exact counts, select it to list its executions.`;
|
|
135
|
+
subline.textContent = `Each line is a saga and each stop a step. Tokens are executions in flight right now, placed by how long they have spent in the step: halfway to the next stop at the step's median. Between refreshes, finished executions run on to how they ended, and ones that started and finished in between replay their path in a lighter shade. Red bars are failures in ${range.phrase}. Point at a stop for exact counts, select it to list its executions.`;
|
|
114
136
|
throughputNote.textContent = `Executions per ${range.per} in ${range.phrase}, stacked by how they ended. The line counts starts.`;
|
|
115
137
|
attentionMore.href = `#/executions?status=failed&range=${range.key}`;
|
|
116
138
|
}
|
|
@@ -140,6 +162,28 @@ export function overview(root, { me, params }) {
|
|
|
140
162
|
let expected = new Map();
|
|
141
163
|
let first = true;
|
|
142
164
|
let destroyed = false;
|
|
165
|
+
// Server time of the last poll: the next one asks what finished since.
|
|
166
|
+
let serverNow = null;
|
|
167
|
+
// The interval in force at the last poll, and whether polling was paused
|
|
168
|
+
// or the tab hidden since: a longer gap than that is a resume.
|
|
169
|
+
let lastInterval = pollMs();
|
|
170
|
+
let paused = !isLive();
|
|
171
|
+
let away = document.hidden;
|
|
172
|
+
const offLive = onLive(() => { if (!isLive()) paused = true; });
|
|
173
|
+
const markAway = () => { if (document.hidden) away = true; };
|
|
174
|
+
document.addEventListener("visibilitychange", markAway);
|
|
175
|
+
const handled = new Map();
|
|
176
|
+
const probe = { continued: 0, replayed: 0, collapsed: 0 };
|
|
177
|
+
|
|
178
|
+
function remember(id) {
|
|
179
|
+
handled.set(id, true);
|
|
180
|
+
if (handled.size > HANDLED_KEPT) handled.delete(handled.keys().next().value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tally(key, n) {
|
|
184
|
+
probe[key] += n;
|
|
185
|
+
mapHost.dataset[key] = String(probe[key]);
|
|
186
|
+
}
|
|
143
187
|
|
|
144
188
|
const lineFor = (saga) => definitions.get(saga);
|
|
145
189
|
const stepIndex = (saga, step) => {
|
|
@@ -161,7 +205,7 @@ export function overview(root, { me, params }) {
|
|
|
161
205
|
const spec = { id: token.id, line: token.saga, rail: back ? "back" : "fwd", target: token.index };
|
|
162
206
|
if (token.exit === "completed") return { ...spec, rail: "fwd", target: n, kind: "move", leave: true, creep: null, linger: 0.2 };
|
|
163
207
|
if (token.exit === "compensated") return { ...spec, rail: "back", target: -1, kind: "back", leave: true, creep: null, linger: 0.2 };
|
|
164
|
-
if (token.exit === "failed") return { ...spec, kind: "stop", leave: true, creep: null, linger: 1.6 };
|
|
208
|
+
if (token.exit === "failed") return { ...spec, kind: "stop", leave: true, creep: null, linger: 1.6, flash: true };
|
|
165
209
|
if (token.exit) return { ...spec, leave: true, creep: null };
|
|
166
210
|
const ms = expected.get(`${token.saga}|${token.step}|${token.direction}`) || MEDIAN_FALLBACK_MS;
|
|
167
211
|
return {
|
|
@@ -185,11 +229,114 @@ export function overview(root, { me, params }) {
|
|
|
185
229
|
map.setTokens(specs, partialStations);
|
|
186
230
|
}
|
|
187
231
|
|
|
232
|
+
// A token seen moving that has since finished runs on from where it is.
|
|
233
|
+
function finishToken(token, execution) {
|
|
234
|
+
const back = execution.status === "compensation_failed";
|
|
235
|
+
token.exit = execution.status === "compensation_failed" ? "failed" : execution.status;
|
|
236
|
+
if (token.exit === "failed") {
|
|
237
|
+
token.index = stepIndex(token.saga, back ? execution.back_to : execution.reached);
|
|
238
|
+
token.direction = back ? "compensate" : "execute";
|
|
239
|
+
}
|
|
240
|
+
setTimeout(() => { tokens.delete(token.id); }, 4000);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The whole path of an execution that started and finished between polls.
|
|
244
|
+
function replaySpec(execution, def) {
|
|
245
|
+
const n = def.steps.length;
|
|
246
|
+
const reached = stepIndex(execution.saga_name, execution.reached);
|
|
247
|
+
const backTo = stepIndex(execution.saga_name, execution.back_to);
|
|
248
|
+
if (execution.status === "completed") return { legs: [{ rail: "fwd", to: n }], end: "done" };
|
|
249
|
+
if (reached < 0) return null;
|
|
250
|
+
if (execution.status === "failed") return { legs: [{ rail: "fwd", to: reached }], end: "stop" };
|
|
251
|
+
if (execution.status === "compensated") return { legs: [{ rail: "fwd", to: reached }, { rail: "back", to: -1 }], end: "done" };
|
|
252
|
+
if (execution.status === "compensation_failed" && backTo >= 0) {
|
|
253
|
+
return { legs: [{ rail: "fwd", to: reached }, { rail: "back", to: backTo }], end: "stop" };
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Executions that finished since the last poll: the ones seen moving run
|
|
259
|
+
// on to their end, the others replay quickly, staggered over the poll in
|
|
260
|
+
// the order they started, at most REPLAY_PER_LINE a line. After a pause
|
|
261
|
+
// only the last poll interval is animated; the rest of each line is a
|
|
262
|
+
// count at its end, and with reduced motion every line just sums up how
|
|
263
|
+
// its executions ended. Counts cover what finished since `since`; the
|
|
264
|
+
// executions listed from the overlap before it are drawn, not counted.
|
|
265
|
+
function finishLines(recent, since, gapLimit) {
|
|
266
|
+
const now = toTime(recent.now);
|
|
267
|
+
const interval = pollMs();
|
|
268
|
+
const resumed = now - since > gapLimit;
|
|
269
|
+
const cut = resumed ? now - interval : -Infinity;
|
|
270
|
+
const reduce = reducedMotion.matches;
|
|
271
|
+
const counted = (execution) => toTime(execution.finished_at) >= since;
|
|
272
|
+
const replays = [];
|
|
273
|
+
const notes = [];
|
|
274
|
+
const pending = [];
|
|
275
|
+
for (const line of recent.lines) {
|
|
276
|
+
const def = lineFor(line.saga_name);
|
|
277
|
+
if (!def || !def.steps.length) continue;
|
|
278
|
+
const counts = { ...line.counts };
|
|
279
|
+
let total = line.total;
|
|
280
|
+
let continued = 0;
|
|
281
|
+
let shown = 0;
|
|
282
|
+
const fresh = [];
|
|
283
|
+
for (const execution of line.executions) {
|
|
284
|
+
if (handled.has(execution.id)) {
|
|
285
|
+
if (counted(execution)) {
|
|
286
|
+
counts[execution.status] = Math.max(0, (counts[execution.status] || 0) - 1);
|
|
287
|
+
total--;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
remember(execution.id);
|
|
292
|
+
const token = tokens.get(execution.id);
|
|
293
|
+
if (token && !token.exit) {
|
|
294
|
+
finishToken(token, execution);
|
|
295
|
+
continued++;
|
|
296
|
+
if (counted(execution)) shown++;
|
|
297
|
+
} else {
|
|
298
|
+
fresh.push(execution);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
tally("continued", continued);
|
|
302
|
+
if (reduce) {
|
|
303
|
+
const parts = ENDINGS.filter(([status]) => counts[status]).map(([status, words, tone]) => ({ text: `${status === "completed" ? "+" : ""}${count(counts[status])} ${words}`, tone }));
|
|
304
|
+
if (parts.length) notes.push({ line: line.saga_name, parts });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const animated = fresh.filter((execution) => toTime(execution.finished_at) >= cut)
|
|
308
|
+
.map((execution) => ({ execution, spec: replaySpec(execution, def) }))
|
|
309
|
+
.filter(({ spec }) => spec)
|
|
310
|
+
.sort((a, b) => toTime(a.execution.started_at) - toTime(b.execution.started_at) || (a.execution.id < b.execution.id ? -1 : 1))
|
|
311
|
+
.slice(0, REPLAY_PER_LINE);
|
|
312
|
+
const spread = Math.max(0, Math.min(interval, now - Math.max(since, cut)) - REPLAY_MS);
|
|
313
|
+
animated.forEach(({ execution, spec }, index) => {
|
|
314
|
+
replays.push({ ...spec, id: execution.id, line: line.saga_name, duration: REPLAY_MS, delay: (index / Math.max(1, animated.length)) * spread });
|
|
315
|
+
});
|
|
316
|
+
tally("replayed", animated.length);
|
|
317
|
+
shown += animated.filter(({ execution }) => counted(execution)).length;
|
|
318
|
+
pending.push({ line: line.saga_name, left: Math.max(0, total - shown) });
|
|
319
|
+
}
|
|
320
|
+
const words = !resumed ? "just finished" : paused ? "finished while paused" : away ? "finished while away" : "finished earlier";
|
|
321
|
+
return { replays, notes, pending, resumed, words };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The "+N" pill of each line, less the tokens that settle() runs out.
|
|
325
|
+
function finishNotes(finished, settledByLine) {
|
|
326
|
+
for (const { line, left: unsettled } of finished.pending) {
|
|
327
|
+
const left = Math.max(0, unsettled - (settledByLine.get(line) || 0));
|
|
328
|
+
if (!left) continue;
|
|
329
|
+
if (finished.resumed) tally("collapsed", left);
|
|
330
|
+
finished.notes.push({ line, parts: [{ text: `+${count(left)} ${finished.words}`, tone: "pale" }] });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
188
334
|
async function settle(id) {
|
|
189
335
|
const token = tokens.get(id);
|
|
336
|
+
remember(id);
|
|
190
337
|
try {
|
|
191
338
|
const { execution } = await api.execution(id);
|
|
192
|
-
token.exit =
|
|
339
|
+
token.exit = TERMINAL.includes(execution.status)
|
|
193
340
|
? (execution.status === "compensation_failed" ? "failed" : execution.status)
|
|
194
341
|
: "gone";
|
|
195
342
|
token.index = stepIndex(token.saga, execution.current_step);
|
|
@@ -358,12 +505,15 @@ export function overview(root, { me, params }) {
|
|
|
358
505
|
|
|
359
506
|
async function refresh() {
|
|
360
507
|
const asked = range;
|
|
361
|
-
const
|
|
508
|
+
const since = serverNow;
|
|
509
|
+
const [sagaData, stalledData, movingData, recentData] = await Promise.all([
|
|
362
510
|
api.sagas({ from: rangeFrom(asked) }),
|
|
363
511
|
api.stalled({ limit: WAITING_SHOWN }),
|
|
364
|
-
api.moving({ per_station: PER_STATION, stations: STATIONS_FETCHED })
|
|
512
|
+
api.moving({ per_station: PER_STATION, stations: STATIONS_FETCHED }),
|
|
513
|
+
since === null ? null : api.recent({ since: new Date(since).toISOString(), replay_from: new Date(since - OVERLAP_MS).toISOString(), per_saga: REPLAY_PER_LINE }).catch(() => null)
|
|
365
514
|
]);
|
|
366
515
|
if (destroyed || asked !== range) return;
|
|
516
|
+
serverNow = toTime(recentData ? recentData.now : movingData.now) || serverNow;
|
|
367
517
|
rememberSagas(sagaData.sagas);
|
|
368
518
|
definitions = new Map(sagaData.sagas.map((saga) => [saga.name, saga]));
|
|
369
519
|
|
|
@@ -416,9 +566,16 @@ export function overview(root, { me, params }) {
|
|
|
416
566
|
return `${saga}|${stepIndex(saga, step)}`;
|
|
417
567
|
}));
|
|
418
568
|
|
|
569
|
+
const gapLimit = lastInterval * 2;
|
|
570
|
+
lastInterval = pollMs();
|
|
571
|
+
const finished = recentData ? finishLines(recentData, since, gapLimit) : { replays: [], notes: [], pending: [] };
|
|
572
|
+
paused = !isLive();
|
|
573
|
+
away = document.hidden;
|
|
574
|
+
|
|
419
575
|
// Tokens that left a fully listed station are looked up to show how they
|
|
420
576
|
// ended; at a partial one they just drop out.
|
|
421
577
|
let settling = 0;
|
|
578
|
+
const settledByLine = new Map();
|
|
422
579
|
for (const [id, token] of tokens) {
|
|
423
580
|
if (seen.has(id) || token.exit || token.settling) continue;
|
|
424
581
|
if (partial.has(`${token.saga}|${token.step}`) || settling >= SETTLE_PER_POLL) {
|
|
@@ -427,9 +584,13 @@ export function overview(root, { me, params }) {
|
|
|
427
584
|
}
|
|
428
585
|
token.settling = true;
|
|
429
586
|
settling++;
|
|
587
|
+
settledByLine.set(token.saga, (settledByLine.get(token.saga) || 0) + 1);
|
|
430
588
|
settle(id);
|
|
431
589
|
}
|
|
590
|
+
finishNotes(finished, settledByLine);
|
|
432
591
|
sync();
|
|
592
|
+
map.replay(finished.replays);
|
|
593
|
+
if (finished.notes.length) map.setFinished(finished.notes, Math.max(NOTE_MIN_MS, pollMs()));
|
|
433
594
|
first = false;
|
|
434
595
|
}
|
|
435
596
|
|
|
@@ -458,6 +619,9 @@ export function overview(root, { me, params }) {
|
|
|
458
619
|
destroy() {
|
|
459
620
|
destroyed = true;
|
|
460
621
|
stops.forEach((stop) => stop());
|
|
622
|
+
offLive();
|
|
623
|
+
document.removeEventListener("visibilitychange", markAway);
|
|
624
|
+
reducedMotion.removeEventListener("change", describeKey);
|
|
461
625
|
map.destroy();
|
|
462
626
|
throughput.destroy();
|
|
463
627
|
}
|