lepus-saga 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/Rakefile +8 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/lib/lepus/saga/configuration.rb +48 -0
- data/lib/lepus/saga/control.rb +27 -0
- data/lib/lepus/saga/definition.rb +123 -0
- data/lib/lepus/saga/execution_behaviour.rb +58 -0
- data/lib/lepus/saga/executor.rb +282 -0
- data/lib/lepus/saga/executor_consumer.rb +38 -0
- data/lib/lepus/saga/instrumentation.rb +41 -0
- data/lib/lepus/saga/log_subscriber.rb +42 -0
- data/lib/lepus/saga/queue_depth.rb +28 -0
- data/lib/lepus/saga/resume_stuck_job.rb +19 -0
- data/lib/lepus/saga/retry_policy.rb +19 -0
- data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
- data/lib/lepus/saga/rspec/helpers.rb +22 -0
- data/lib/lepus/saga/rspec/query_examples.rb +378 -0
- data/lib/lepus/saga/rspec/store_examples.rb +514 -0
- data/lib/lepus/saga/rspec.rb +6 -0
- data/lib/lepus/saga/step.rb +9 -0
- data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
- data/lib/lepus/saga/step_producer.rb +37 -0
- data/lib/lepus/saga/stores/base.rb +305 -0
- data/lib/lepus/saga/stores/memory.rb +375 -0
- data/lib/lepus/saga/stores.rb +40 -0
- data/lib/lepus/saga/testing.rb +35 -0
- data/lib/lepus/saga/version.rb +7 -0
- data/lib/lepus/saga/web/api.rb +326 -0
- data/lib/lepus/saga/web/configuration.rb +49 -0
- data/lib/lepus/saga/web/guard.rb +38 -0
- data/lib/lepus/saga/web/serializer.rb +59 -0
- data/lib/lepus/saga/web/static.rb +108 -0
- data/lib/lepus/saga/web.rb +61 -0
- data/lib/lepus/saga.rb +212 -0
- data/lib/lepus-saga.rb +3 -0
- data/mise.toml +2 -0
- data/web/assets/app.css +913 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +50 -0
- data/web/assets/js/dom.js +84 -0
- data/web/assets/js/format.js +79 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +143 -0
- data/web/assets/js/map.js +639 -0
- data/web/assets/js/range.js +19 -0
- data/web/assets/js/sagas.js +18 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/throughput.js +262 -0
- data/web/assets/js/views/execution.js +426 -0
- data/web/assets/js/views/executions.js +165 -0
- data/web/assets/js/views/overview.js +465 -0
- data/web/index.html +63 -0
- metadata +323 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { h, s, clear } from "./dom.js";
|
|
2
|
+
import { count, hourMinute } from "./format.js";
|
|
3
|
+
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
const TICK_STEPS = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880].map((minutes) => minutes * 60_000);
|
|
6
|
+
const weekdayName = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
|
7
|
+
const weekday = { format: (date) => `${weekdayName.format(date)} ${date.getDate()}` };
|
|
8
|
+
const OUTCOMES = [
|
|
9
|
+
["completed", "Completed", "p-done"],
|
|
10
|
+
["compensated", "Compensated", "p-back"],
|
|
11
|
+
["failed", "Failed", "p-stop"]
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function niceTop(value) {
|
|
15
|
+
if (value <= 4) return 4;
|
|
16
|
+
const magnitude = 10 ** Math.floor(Math.log10(value));
|
|
17
|
+
for (const m of [1, 2, 3, 4, 5, 6, 8, 10]) if (m * magnitude >= value) return m * magnitude;
|
|
18
|
+
return 10 * magnitude;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function label(time) {
|
|
22
|
+
const date = new Date(time);
|
|
23
|
+
return date.getHours() === 0 && date.getMinutes() === 0 ? weekday.format(date) : hourMinute(date);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
|
|
27
|
+
|
|
28
|
+
function timeTicks(start, end, px) {
|
|
29
|
+
const span = end - start;
|
|
30
|
+
const step = TICK_STEPS.find((ms) => (ms / span) * px >= 92) || TICK_STEPS[TICK_STEPS.length - 1];
|
|
31
|
+
const date = new Date(start);
|
|
32
|
+
date.setSeconds(0, 0);
|
|
33
|
+
if (step >= DAY) {
|
|
34
|
+
date.setHours(24, 0, 0, 0);
|
|
35
|
+
} else {
|
|
36
|
+
const minutes = step / 60_000;
|
|
37
|
+
const mod = (date.getHours() * 60 + date.getMinutes()) % minutes;
|
|
38
|
+
if (mod) date.setMinutes(date.getMinutes() + minutes - mod);
|
|
39
|
+
}
|
|
40
|
+
const ticks = [];
|
|
41
|
+
while (date.getTime() <= end) {
|
|
42
|
+
ticks.push(date.getTime());
|
|
43
|
+
if (step >= DAY) date.setDate(date.getDate() + step / DAY);
|
|
44
|
+
else date.setMinutes(date.getMinutes() + step / 60_000);
|
|
45
|
+
}
|
|
46
|
+
return ticks;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalize(rows) {
|
|
50
|
+
return rows.map((row) => ({
|
|
51
|
+
t: Date.parse(row.t),
|
|
52
|
+
started: row.started || 0,
|
|
53
|
+
completed: row.completed || 0,
|
|
54
|
+
compensated: row.compensated || 0,
|
|
55
|
+
failed: (row.failed || 0) + (row.compensation_failed || 0)
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Started and finished executions per bucket for the selected window: the
|
|
60
|
+
// finished ones stacked by outcome, the started ones as a stepped line.
|
|
61
|
+
export class Throughput {
|
|
62
|
+
constructor(host) {
|
|
63
|
+
this.host = host;
|
|
64
|
+
this.totals = h("dl", { class: "tp-totals" });
|
|
65
|
+
this.chart = h("div", { class: "tp-chart" });
|
|
66
|
+
this.readout = h("p", { class: "tp-readout", "aria-hidden": "true" });
|
|
67
|
+
this.tableBody = h("tbody");
|
|
68
|
+
this.caption = h("caption", { class: "visually-hidden" });
|
|
69
|
+
host.append(
|
|
70
|
+
this.totals,
|
|
71
|
+
this.chart,
|
|
72
|
+
h("div", { class: "tp-foot" },
|
|
73
|
+
this.readout,
|
|
74
|
+
h("details", { class: "tp-table" },
|
|
75
|
+
h("summary", {}, "Show as table"),
|
|
76
|
+
h("div", { class: "tp-scroll" },
|
|
77
|
+
h("table", {},
|
|
78
|
+
this.caption,
|
|
79
|
+
h("thead", {}, h("tr", {},
|
|
80
|
+
h("th", { scope: "col" }, "From"),
|
|
81
|
+
h("th", { scope: "col" }, "Started"),
|
|
82
|
+
OUTCOMES.map(([, name]) => h("th", { scope: "col" }, name)))),
|
|
83
|
+
this.tableBody))))
|
|
84
|
+
);
|
|
85
|
+
this.observer = new ResizeObserver(() => {
|
|
86
|
+
const width = Math.round(this.chart.clientWidth);
|
|
87
|
+
if (width && width !== this.width && this.data) this.draw();
|
|
88
|
+
});
|
|
89
|
+
this.observer.observe(this.chart);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
destroy() {
|
|
93
|
+
this.observer.disconnect();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
set(series, range) {
|
|
97
|
+
const rows = normalize(series.rows || []);
|
|
98
|
+
const bucket = (series.bucket || range.bucket) * 1000;
|
|
99
|
+
const to = Date.parse(series.to) || Date.now();
|
|
100
|
+
const from = Date.parse(series.from) || to - range.ms;
|
|
101
|
+
const start = rows.length ? rows[0].t : from;
|
|
102
|
+
const end = rows.length ? rows[rows.length - 1].t + bucket : to;
|
|
103
|
+
this.data = {
|
|
104
|
+
rows, bucket, range, start, end,
|
|
105
|
+
partial: (row) => row.t < from || row.t + bucket > to,
|
|
106
|
+
when: (time) => (sameDay(start, end - 1) ? hourMinute(time) : `${weekday.format(new Date(time))}, ${hourMinute(time)}`)
|
|
107
|
+
};
|
|
108
|
+
this.draw();
|
|
109
|
+
this.drawTotals();
|
|
110
|
+
this.drawTable();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
sums() {
|
|
114
|
+
const sums = { started: 0, completed: 0, compensated: 0, failed: 0 };
|
|
115
|
+
for (const row of this.data.rows) for (const key in sums) sums[key] += row[key];
|
|
116
|
+
return sums;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
drawTotals() {
|
|
120
|
+
const sums = this.sums();
|
|
121
|
+
const item = (key, name, value) => h("div", {}, h("dt", {}, h("i", { class: `k-${key}` }), name), h("dd", {}, count(value)));
|
|
122
|
+
clear(this.totals).append(
|
|
123
|
+
item("start", "Started", sums.started),
|
|
124
|
+
...OUTCOMES.map(([key, name, cls]) => item(cls.slice(2), name, sums[key])));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
drawTable() {
|
|
128
|
+
const { rows, range, when } = this.data;
|
|
129
|
+
this.caption.textContent = `Executions started and finished per ${range.per} in ${range.phrase}`;
|
|
130
|
+
clear(this.tableBody);
|
|
131
|
+
for (const row of [...rows].reverse()) {
|
|
132
|
+
this.tableBody.append(h("tr", {},
|
|
133
|
+
h("th", { scope: "row" }, when(row.t)),
|
|
134
|
+
h("td", {}, count(row.started)),
|
|
135
|
+
OUTCOMES.map(([key]) => h("td", {}, count(row[key])))));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
draw() {
|
|
140
|
+
const { rows, bucket, range, start, end, partial, when } = this.data;
|
|
141
|
+
const width = Math.max(280, Math.round(this.chart.clientWidth) || 800);
|
|
142
|
+
this.width = width;
|
|
143
|
+
const compact = width < 600;
|
|
144
|
+
const height = compact ? 170 : 210;
|
|
145
|
+
const m = { top: 28, right: 4, bottom: 26, left: 34 };
|
|
146
|
+
const plotW = width - m.left - m.right;
|
|
147
|
+
const plotH = height - m.top - m.bottom;
|
|
148
|
+
const x = (time) => m.left + ((time - start) / (end - start)) * plotW;
|
|
149
|
+
const finished = (row) => row.completed + row.compensated + row.failed;
|
|
150
|
+
let peak = 0;
|
|
151
|
+
let peakIndex = -1;
|
|
152
|
+
let ceiling = 0;
|
|
153
|
+
const complete = rows.some((row) => !partial(row));
|
|
154
|
+
rows.forEach((row, index) => {
|
|
155
|
+
if (complete && partial(row)) return;
|
|
156
|
+
const total = finished(row);
|
|
157
|
+
if (total > peak) { peak = total; peakIndex = index; }
|
|
158
|
+
ceiling = Math.max(ceiling, total, row.started);
|
|
159
|
+
});
|
|
160
|
+
const top = niceTop(ceiling);
|
|
161
|
+
const y = (value) => m.top + plotH - (Math.min(value, top) / top) * plotH;
|
|
162
|
+
const sums = this.sums();
|
|
163
|
+
|
|
164
|
+
const svg = s("svg", {
|
|
165
|
+
viewBox: `0 0 ${width} ${height}`, width, height, role: "img",
|
|
166
|
+
"aria-label": `In ${range.phrase}: ${count(sums.started)} started, ${count(sums.completed)} completed, ${count(sums.compensated)} compensated and ${count(sums.failed)} failed.` +
|
|
167
|
+
(peak ? ` Busiest full ${range.per} began at ${when(rows[peakIndex].t)} with ${count(peak)} finished.` : "")
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const value of [0, top / 2, top]) {
|
|
171
|
+
const gy = Math.round(y(value)) + 0.5;
|
|
172
|
+
svg.append(
|
|
173
|
+
s("line", { class: value ? "p-grid" : "p-rule", x1: m.left, x2: width - m.right, y1: gy, y2: gy }),
|
|
174
|
+
s("text", { class: "p-axis", x: m.left - 8, y: gy + 4, "text-anchor": "end" }, count(value)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const highlight = s("rect", { class: "p-hover", x: 0, y: m.top, width: 0, height: plotH, visibility: "hidden" });
|
|
178
|
+
svg.append(highlight);
|
|
179
|
+
|
|
180
|
+
const slot = (bucket / (end - start)) * plotW;
|
|
181
|
+
const gap = Math.min(3, slot * 0.25);
|
|
182
|
+
const bars = s("g");
|
|
183
|
+
rows.forEach((row) => {
|
|
184
|
+
const bx = x(row.t) + gap / 2;
|
|
185
|
+
const bw = Math.max(1, slot - gap);
|
|
186
|
+
const group = s("g", { class: partial(row) ? "is-partial" : null });
|
|
187
|
+
let base = y(0);
|
|
188
|
+
for (const [key, , cls] of OUTCOMES) {
|
|
189
|
+
if (!row[key] || base <= m.top) continue;
|
|
190
|
+
const hgt = Math.min(base - m.top, Math.max(1.5, y(0) - y(row[key])));
|
|
191
|
+
const sep = cls === "p-done" ? 0 : 1;
|
|
192
|
+
group.append(s("rect", { class: cls, x: bx, y: base - hgt, width: bw, height: Math.max(1, hgt - sep) }));
|
|
193
|
+
if (cls === "p-done") group.append(s("rect", { class: "p-done-edge", x: bx, y: base - hgt, width: bw, height: Math.min(1.5, hgt) }));
|
|
194
|
+
base -= hgt;
|
|
195
|
+
}
|
|
196
|
+
if (finished(row) > top) {
|
|
197
|
+
group.append(s("path", { class: "p-break", d: `M${bx - 1} ${m.top + 9}L${bx + bw + 1} ${m.top + 5}M${bx - 1} ${m.top + 14}L${bx + bw + 1} ${m.top + 10}` }));
|
|
198
|
+
}
|
|
199
|
+
bars.append(group);
|
|
200
|
+
});
|
|
201
|
+
svg.append(bars);
|
|
202
|
+
|
|
203
|
+
if (rows.length) {
|
|
204
|
+
let d = `M${x(rows[0].t)} ${y(rows[0].started)}`;
|
|
205
|
+
rows.forEach((row, index) => {
|
|
206
|
+
if (index) d += `V${y(row.started)}`;
|
|
207
|
+
d += `H${x(row.t) + slot}`;
|
|
208
|
+
});
|
|
209
|
+
svg.append(s("path", { class: "p-start", d }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const ticks = timeTicks(start, end, plotW);
|
|
213
|
+
for (const tick of ticks) {
|
|
214
|
+
const text = label(tick);
|
|
215
|
+
const tx = x(tick);
|
|
216
|
+
const half = text.length * 3.4;
|
|
217
|
+
if (tx - half < m.left - 6 || tx + half > width) continue;
|
|
218
|
+
svg.append(
|
|
219
|
+
s("line", { class: "p-tick", x1: tx, x2: tx, y1: y(0), y2: y(0) + 4 }),
|
|
220
|
+
s("text", { class: "p-axis", x: tx, y: height - 6, "text-anchor": "middle" }, text));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (peak) {
|
|
224
|
+
const row = rows[peakIndex];
|
|
225
|
+
const cx = x(row.t) + slot / 2;
|
|
226
|
+
const text = compact ? `Peak ${count(peak)}, ${when(row.t)}` : `Busiest: ${count(peak)} finished, ${when(row.t)}`;
|
|
227
|
+
const textW = text.length * 6.3;
|
|
228
|
+
const preferEnd = cx > m.left + plotW * 0.66;
|
|
229
|
+
const tx = Math.max(0, Math.min(width - textW, preferEnd ? cx - 6 - textW : cx + 6));
|
|
230
|
+
svg.append(
|
|
231
|
+
s("line", { class: "p-lead", x1: cx, x2: cx, y1: 16, y2: Math.max(18, y(peak) - 4) }),
|
|
232
|
+
s("text", { class: "p-note", x: tx, y: 12 }, text));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const hit = s("rect", { class: "p-hit", x: m.left, y: 0, width: plotW, height });
|
|
236
|
+
const show = (event) => {
|
|
237
|
+
const box = svg.getBoundingClientRect();
|
|
238
|
+
const px = ((event.clientX - box.left) / box.width) * width;
|
|
239
|
+
const index = Math.floor(((px - m.left) / plotW) * rows.length);
|
|
240
|
+
const row = rows[index];
|
|
241
|
+
if (!row) return hide();
|
|
242
|
+
highlight.setAttribute("x", x(row.t));
|
|
243
|
+
highlight.setAttribute("width", slot);
|
|
244
|
+
highlight.setAttribute("visibility", "visible");
|
|
245
|
+
const part = partial(row) ? " (partial)" : "";
|
|
246
|
+
clear(this.readout).append(
|
|
247
|
+
h("b", {}, `${when(row.t)} to ${hourMinute(row.t + bucket)}${part}: `),
|
|
248
|
+
`${count(row.started)} started, ${count(row.completed)} completed, ${count(row.compensated)} compensated, ${count(row.failed)} failed`);
|
|
249
|
+
};
|
|
250
|
+
const hide = () => {
|
|
251
|
+
highlight.setAttribute("visibility", "hidden");
|
|
252
|
+
this.readout.textContent = "Point at a bar to read its counts.";
|
|
253
|
+
};
|
|
254
|
+
hit.addEventListener("pointermove", show);
|
|
255
|
+
hit.addEventListener("pointerdown", show);
|
|
256
|
+
hit.addEventListener("pointerleave", hide);
|
|
257
|
+
svg.append(hit);
|
|
258
|
+
hide();
|
|
259
|
+
|
|
260
|
+
clear(this.chart).append(svg);
|
|
261
|
+
}
|
|
262
|
+
}
|