@loadmap/cli 0.1.1 → 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.
- package/README.md +4 -1
- package/dist/bin.js +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +1 -1
- package/dist/{src-B81_QU8o.js → src-CCAswkjM.js} +924 -69
- package/dist/viewer/assets/index-CoSvJTeA.js +154 -0
- package/dist/viewer/index.html +1 -1
- package/package.json +4 -4
- package/dist/viewer/assets/index-CUjV4-m-.js +0 -153
|
@@ -12,6 +12,10 @@ function measured(value, unit, o) {
|
|
|
12
12
|
function estimated(value, unit, o) {
|
|
13
13
|
return capacity(value, unit, "estimated", o);
|
|
14
14
|
}
|
|
15
|
+
/** A limit someone set, such as a rate limiter's: exact, so it takes no ±. */
|
|
16
|
+
function configured(value, unit, source) {
|
|
17
|
+
return capacity(value, unit, "configured", source);
|
|
18
|
+
}
|
|
15
19
|
function capacity(value, unit, provenance, o) {
|
|
16
20
|
const { source, within } = typeof o === "string" ? {
|
|
17
21
|
source: o,
|
|
@@ -34,7 +38,7 @@ function boundOf(c, bound) {
|
|
|
34
38
|
}
|
|
35
39
|
// ../core/src/format.ts
|
|
36
40
|
/** A number for a reader: 1,234 / 12.3 / 0.4. */
|
|
37
|
-
function fmt(n) {
|
|
41
|
+
function fmt$1(n) {
|
|
38
42
|
if (!Number.isFinite(n)) return n > 0 ? "∞" : "—";
|
|
39
43
|
if (Math.abs(n) >= 1e3) return Math.round(n).toLocaleString("en-US");
|
|
40
44
|
return (Math.round(n * 10) / 10).toString();
|
|
@@ -51,7 +55,7 @@ function compact(n) {
|
|
|
51
55
|
/** A load multiple, rounded down so it never promises more than the model: 2.6× / 53×. */
|
|
52
56
|
function multiple(u, searchMax = 2e3) {
|
|
53
57
|
if (u == null) return "—";
|
|
54
|
-
if (!Number.isFinite(u)) return `beyond ${fmt(searchMax)}×`;
|
|
58
|
+
if (!Number.isFinite(u)) return `beyond ${fmt$1(searchMax)}×`;
|
|
55
59
|
return u >= 10 ? `${Math.floor(u)}×` : `${Math.floor(u * 10) / 10}×`;
|
|
56
60
|
}
|
|
57
61
|
/**
|
|
@@ -210,6 +214,19 @@ function workers(component, o) {
|
|
|
210
214
|
fill: o.fill ?? "round-robin"
|
|
211
215
|
};
|
|
212
216
|
}
|
|
217
|
+
function cache(o) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "cache",
|
|
220
|
+
...o
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function limit(component, o = {}) {
|
|
224
|
+
return {
|
|
225
|
+
kind: "limit",
|
|
226
|
+
component,
|
|
227
|
+
...o
|
|
228
|
+
};
|
|
229
|
+
}
|
|
213
230
|
function store(component, o = {}) {
|
|
214
231
|
return {
|
|
215
232
|
kind: "store",
|
|
@@ -229,11 +246,17 @@ function fanout(o) {
|
|
|
229
246
|
...o
|
|
230
247
|
};
|
|
231
248
|
}
|
|
249
|
+
/** The share a stage turns away when its component is past `full`: what is above it. */
|
|
250
|
+
function rejectShare(util, full) {
|
|
251
|
+
return util != null && util > full ? 1 - full / util : 0;
|
|
252
|
+
}
|
|
232
253
|
function dyn(v, ctx) {
|
|
233
254
|
return typeof v === "function" ? v(ctx) : v;
|
|
234
255
|
}
|
|
235
|
-
function resolveStage(id, s, ctx, flowIds) {
|
|
256
|
+
function resolveStage(id, s, ctx, flowIds, full) {
|
|
236
257
|
const label = s.label ?? id;
|
|
258
|
+
const util = (c) => c ? ctx.util[c] : null;
|
|
259
|
+
const rejects = rejectShare(util(s.kind === "sink" ? void 0 : s.component), full);
|
|
237
260
|
switch (s.kind) {
|
|
238
261
|
case "queue": return {
|
|
239
262
|
id,
|
|
@@ -242,15 +265,32 @@ function resolveStage(id, s, ctx, flowIds) {
|
|
|
242
265
|
component: s.component,
|
|
243
266
|
partitions: dyn(s.partitions, ctx)
|
|
244
267
|
};
|
|
245
|
-
case "workers":
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
268
|
+
case "workers": {
|
|
269
|
+
const perPod = dyn(s.perPod, ctx);
|
|
270
|
+
const base = {
|
|
271
|
+
id,
|
|
272
|
+
label,
|
|
273
|
+
kind: "workers",
|
|
274
|
+
component: s.component,
|
|
275
|
+
perPod,
|
|
276
|
+
fill: s.fill
|
|
277
|
+
};
|
|
278
|
+
if (!s.autoscale) {
|
|
279
|
+
const pods = dyn(s.pods ?? 0, ctx);
|
|
280
|
+
return {
|
|
281
|
+
...base,
|
|
282
|
+
pods,
|
|
283
|
+
running: pods
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const a = autoscaleOf(id, s.autoscale, ctx);
|
|
287
|
+
return {
|
|
288
|
+
...base,
|
|
289
|
+
pods: a.max,
|
|
290
|
+
running: running(a, util(s.component)),
|
|
291
|
+
autoscale: a
|
|
292
|
+
};
|
|
293
|
+
}
|
|
254
294
|
case "fanout": {
|
|
255
295
|
const to = dyn(s.to, ctx);
|
|
256
296
|
const perFlow = {};
|
|
@@ -260,26 +300,72 @@ function resolveStage(id, s, ctx, flowIds) {
|
|
|
260
300
|
label,
|
|
261
301
|
kind: "fanout",
|
|
262
302
|
component: s.component,
|
|
263
|
-
to: perFlow
|
|
303
|
+
to: perFlow,
|
|
304
|
+
rejects,
|
|
305
|
+
shed: rejects
|
|
264
306
|
};
|
|
265
307
|
}
|
|
308
|
+
case "cache": return {
|
|
309
|
+
id,
|
|
310
|
+
label,
|
|
311
|
+
kind: "cache",
|
|
312
|
+
component: s.component,
|
|
313
|
+
hit: hitOf(id, s, ctx),
|
|
314
|
+
rejects,
|
|
315
|
+
shed: rejects
|
|
316
|
+
};
|
|
266
317
|
case "sink": return {
|
|
267
318
|
id,
|
|
268
319
|
label,
|
|
269
320
|
kind: "sink"
|
|
270
321
|
};
|
|
322
|
+
case "store": return {
|
|
323
|
+
id,
|
|
324
|
+
label,
|
|
325
|
+
kind: "store",
|
|
326
|
+
component: s.component
|
|
327
|
+
};
|
|
271
328
|
default: return {
|
|
272
329
|
id,
|
|
273
330
|
label,
|
|
274
331
|
kind: s.kind,
|
|
275
|
-
component: s.component
|
|
332
|
+
component: s.component,
|
|
333
|
+
rejects,
|
|
334
|
+
shed: rejects
|
|
276
335
|
};
|
|
277
336
|
}
|
|
278
337
|
}
|
|
338
|
+
/** A cache's hit share, checked: a share outside 0 to 1 is a mistake, not a rate. */
|
|
339
|
+
function hitOf(id, s, ctx) {
|
|
340
|
+
const hit = dyn(s.hit, ctx);
|
|
341
|
+
if (!(hit >= 0 && hit <= 1)) throw new Error(`loadmap: cache "${id}" needs a hit share from 0 to 1, not ${hit}`);
|
|
342
|
+
return hit;
|
|
343
|
+
}
|
|
344
|
+
function autoscaleOf(id, a, ctx) {
|
|
345
|
+
const min = dyn(a.min, ctx);
|
|
346
|
+
const max = dyn(a.max, ctx);
|
|
347
|
+
const target = dyn(a.target, ctx);
|
|
348
|
+
if (!(Number.isInteger(min) && Number.isInteger(max) && min >= 1 && max >= min)) throw new Error(`loadmap: workers "${id}" autoscale needs whole numbers with 1 ≤ min ≤ max, not ${min} and ${max}`);
|
|
349
|
+
if (!(target > 0 && target <= 1)) throw new Error(`loadmap: workers "${id}" autoscale needs a target above 0 and at most 1, not ${target}`);
|
|
350
|
+
return {
|
|
351
|
+
min,
|
|
352
|
+
max,
|
|
353
|
+
target
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The pods an autoscaler runs: enough to hold each near its target. `util` is against the pool at max pods, so
|
|
358
|
+
* util × max is the work in pods' worth of capacity.
|
|
359
|
+
*/
|
|
360
|
+
function running(a, util) {
|
|
361
|
+
const need = Math.ceil((util ?? 0) * a.max / a.target - 1e-9);
|
|
362
|
+
return Math.min(a.max, Math.max(a.min, need));
|
|
363
|
+
}
|
|
279
364
|
// ../core/src/model.ts
|
|
280
365
|
const RESERVED$1 = [
|
|
281
366
|
"load",
|
|
282
367
|
"flows",
|
|
368
|
+
"at",
|
|
283
369
|
"demand",
|
|
284
370
|
"util",
|
|
285
371
|
"derived"
|
|
@@ -299,6 +385,11 @@ function defineModel(def) {
|
|
|
299
385
|
if (st.kind === "fanout" && i < route.length - 1) throw new Error(`loadmap: fan-out "${s}" must be the last stage of the route of "${f}"`);
|
|
300
386
|
});
|
|
301
387
|
}
|
|
388
|
+
for (const c of componentIds) if (!def.components[c].demand && !stageIds.some((s) => def.stages[s].component === c)) throw new Error(`loadmap: component "${c}" needs a demand: no stage uses it`);
|
|
389
|
+
for (const s of stageIds) {
|
|
390
|
+
const st = def.stages[s];
|
|
391
|
+
if (st.kind === "workers" && st.pods == null === (st.autoscale == null)) throw new Error(`loadmap: workers "${s}" needs pods or autoscale, one of them`);
|
|
392
|
+
}
|
|
302
393
|
const thresholds = {
|
|
303
394
|
target: def.thresholds?.target ?? .5,
|
|
304
395
|
full: def.thresholds?.full ?? 1
|
|
@@ -397,8 +488,17 @@ function defineModel(def) {
|
|
|
397
488
|
const stated = context(def, values);
|
|
398
489
|
const uncertain = componentIds.filter((c) => (stated.capacity(c).within ?? 0) > 0);
|
|
399
490
|
const components = {};
|
|
491
|
+
const seen = /* @__PURE__ */ new Map();
|
|
492
|
+
const utilOf = (bound, load) => {
|
|
493
|
+
const k = `${bound}|${load}`;
|
|
494
|
+
const hit = seen.get(k);
|
|
495
|
+
if (hit) return hit;
|
|
496
|
+
const u = utilAt(values, bound)(load);
|
|
497
|
+
seen.set(k, u);
|
|
498
|
+
return u;
|
|
499
|
+
};
|
|
400
500
|
for (const c of componentIds) {
|
|
401
|
-
const at = (bound) => (load) =>
|
|
501
|
+
const at = (bound) => (load) => utilOf(bound, load)[c];
|
|
402
502
|
const both = (th) => {
|
|
403
503
|
const x = crossing(at(), th, searchMax);
|
|
404
504
|
if (x == null) return {
|
|
@@ -485,7 +585,7 @@ function defineModel(def) {
|
|
|
485
585
|
...defaults(),
|
|
486
586
|
...partial
|
|
487
587
|
};
|
|
488
|
-
const { ctx, capacity, over, describe } = context(def, values);
|
|
588
|
+
const { ctx, capacity, over, describe, overload: overloadOf, retries, shed } = context(def, values);
|
|
489
589
|
const crossings = crossingsFor(values);
|
|
490
590
|
const components = {};
|
|
491
591
|
for (const c of componentIds) {
|
|
@@ -525,15 +625,24 @@ function defineModel(def) {
|
|
|
525
625
|
const flows = {};
|
|
526
626
|
for (const f of flowIds) flows[f] = ctx.flows[f];
|
|
527
627
|
const stages = {};
|
|
528
|
-
for (const s of stageIds)
|
|
628
|
+
for (const s of stageIds) {
|
|
629
|
+
const st = resolveStage(s, def.stages[s], ctx, flowIds, thresholds.full);
|
|
630
|
+
stages[s] = st.kind === "pass" || st.kind === "limit" || st.kind === "cache" ? {
|
|
631
|
+
...st,
|
|
632
|
+
shed: shed(s)
|
|
633
|
+
} : st;
|
|
634
|
+
}
|
|
529
635
|
for (const st of Object.values(stages)) if (st.kind === "fanout") for (const b of Object.values(st.to).flat()) {
|
|
530
636
|
const to = stages[b.stage];
|
|
531
637
|
if (!to || to.kind === "queue" || to.kind === "workers" || to.kind === "fanout") throw new Error(`loadmap: fan-out "${st.id}" branches to "${b.stage}", which is not a sink, store or pass stage`);
|
|
532
638
|
}
|
|
533
639
|
const overload = {};
|
|
534
|
-
for (const f of flowIds)
|
|
535
|
-
|
|
536
|
-
|
|
640
|
+
for (const f of flowIds) overload[f] = overloadOf(f);
|
|
641
|
+
const at = {};
|
|
642
|
+
for (const s of stageIds) {
|
|
643
|
+
const row = {};
|
|
644
|
+
for (const f of flowIds) row[f] = ctx.at[s][f];
|
|
645
|
+
at[s] = row;
|
|
537
646
|
}
|
|
538
647
|
const assumptions = [];
|
|
539
648
|
for (const c of componentIds) {
|
|
@@ -567,7 +676,9 @@ function defineModel(def) {
|
|
|
567
676
|
assumptions,
|
|
568
677
|
stages,
|
|
569
678
|
routes: def.routes,
|
|
679
|
+
at,
|
|
570
680
|
overload,
|
|
681
|
+
retry: retries(),
|
|
571
682
|
ctx
|
|
572
683
|
};
|
|
573
684
|
};
|
|
@@ -604,9 +715,10 @@ function defineModel(def) {
|
|
|
604
715
|
}
|
|
605
716
|
/**
|
|
606
717
|
* The model's numbers for one set of values. Every number is computed on first use and kept, so a model function
|
|
607
|
-
* may read any other; a value that needs itself throws, naming the loop.
|
|
718
|
+
* may read any other; a value that needs itself throws, naming the loop. Retries are the one loop that is solved:
|
|
719
|
+
* a retrying flow's attempts settle where the share its route turns away gives back the same attempts.
|
|
608
720
|
*/
|
|
609
|
-
function context(def, values, bound, pinned) {
|
|
721
|
+
function context(def, values, bound, pinned, fixed) {
|
|
610
722
|
const cache = /* @__PURE__ */ new Map();
|
|
611
723
|
const stack = [];
|
|
612
724
|
const memo = (key, f) => {
|
|
@@ -630,15 +742,205 @@ function context(def, values, bound, pinned) {
|
|
|
630
742
|
return Object.freeze(t);
|
|
631
743
|
};
|
|
632
744
|
const ctx = { ...values };
|
|
745
|
+
const full = def.thresholds?.full ?? 1;
|
|
633
746
|
const flowIds = Object.keys(def.flows);
|
|
634
747
|
const componentIds = Object.keys(def.components);
|
|
748
|
+
const stages = def.stages;
|
|
749
|
+
const stageIds = Object.keys(stages);
|
|
750
|
+
const routes = def.routes;
|
|
635
751
|
const capacity = (c) => memo(`capacity of ${c}`, () => {
|
|
636
752
|
const cap = def.components[c].capacity;
|
|
637
753
|
return typeof cap === "function" ? cap(ctx) : cap;
|
|
638
754
|
});
|
|
639
755
|
const enabled = (c) => memo(`${c} enabled`, () => def.components[c].when?.(ctx) ?? true);
|
|
640
|
-
|
|
641
|
-
|
|
756
|
+
const overload = (f) => memo(`overload of ${f}`, () => {
|
|
757
|
+
const o = def.flows[f].overload;
|
|
758
|
+
return (typeof o === "function" ? o(ctx) : o) ?? {};
|
|
759
|
+
});
|
|
760
|
+
const retryOf = (f) => {
|
|
761
|
+
const rule = def.flows[f].retry;
|
|
762
|
+
const r = typeof rule === "function" ? rule(values) : rule;
|
|
763
|
+
if (!r) return void 0;
|
|
764
|
+
if (!(Number.isInteger(r.times) && r.times >= 0)) throw new Error(`loadmap: flow "${f}" retries a whole number of times, not ${r.times}`);
|
|
765
|
+
if (r.budget != null && !(r.budget >= 0 && Number.isFinite(r.budget))) throw new Error(`loadmap: flow "${f}" needs a retry budget of 0 or more, not ${r.budget}`);
|
|
766
|
+
return r.times > 0 && r.budget !== 0 ? r : void 0;
|
|
767
|
+
};
|
|
768
|
+
const rate = (f) => memo(`rate of ${f}`, () => def.flows[f].rate(ctx));
|
|
769
|
+
ctx.flows = table(flowIds, (f) => memo(`flow ${f}`, () => {
|
|
770
|
+
if (fixed && Object.hasOwn(fixed, f)) return fixed[f];
|
|
771
|
+
return retryOf(f) ? retries()[f].attempts : def.flows[f].rate(ctx);
|
|
772
|
+
}));
|
|
773
|
+
/** The share a stage turns away: past its component's `full`, what is above it. */
|
|
774
|
+
const rejects = (s) => memo(`rejects at ${s}`, () => {
|
|
775
|
+
const st = stages[s];
|
|
776
|
+
if (!(st.kind === "pass" || st.kind === "limit" || st.kind === "cache" || st.kind === "fanout") || !st.component) return 0;
|
|
777
|
+
return rejectShare(ctx.util[st.component], full);
|
|
778
|
+
});
|
|
779
|
+
const hit = (s) => memo(`hit at ${s}`, () => hitOf(s, stages[s], ctx));
|
|
780
|
+
/** The share of a flow's units that go on past a stage: cache hits stay, and what a limiter turns away. */
|
|
781
|
+
const onward = (s) => {
|
|
782
|
+
const st = stages[s];
|
|
783
|
+
return st.kind === "cache" ? 1 - hit(s) : st.kind === "limit" ? 1 - rejects(s) : 1;
|
|
784
|
+
};
|
|
785
|
+
/** Copies per unit of a flow that a fan-out at t sends to s: 0 unless t is a fan-out with a branch there. */
|
|
786
|
+
const copies = (t, f, s) => {
|
|
787
|
+
const st = stages[t];
|
|
788
|
+
if (st.kind !== "fanout") return 0;
|
|
789
|
+
const to = memo(`branches of ${t}`, () => dyn(st.to, ctx));
|
|
790
|
+
let n = 0;
|
|
791
|
+
for (const b of Array.isArray(to) ? to : to[f] ?? []) if (b.stage === s) n += b.share;
|
|
792
|
+
return n;
|
|
793
|
+
};
|
|
794
|
+
const arriving = (s, f) => memo(`${f} at ${s}`, () => {
|
|
795
|
+
const route = routes[f] ?? [];
|
|
796
|
+
const last = route.findLastIndex((t) => t === s || copies(t, f, s) > 0);
|
|
797
|
+
let total = 0;
|
|
798
|
+
let r = ctx.flows[f];
|
|
799
|
+
for (const [i, t] of route.entries()) {
|
|
800
|
+
if (t === s) total += r;
|
|
801
|
+
total += r * copies(t, f, s);
|
|
802
|
+
if (i >= last) break;
|
|
803
|
+
r *= onward(t);
|
|
804
|
+
}
|
|
805
|
+
return total;
|
|
806
|
+
});
|
|
807
|
+
const inner = /* @__PURE__ */ new Map();
|
|
808
|
+
ctx.at = table(stageIds, (s) => {
|
|
809
|
+
const known = inner.get(s);
|
|
810
|
+
if (known) return known;
|
|
811
|
+
const t = table(flowIds, (f) => arriving(s, f));
|
|
812
|
+
inner.set(s, t);
|
|
813
|
+
return t;
|
|
814
|
+
});
|
|
815
|
+
/**
|
|
816
|
+
* The share of a flow's attempts turned away somewhere on its route; a cache hit is an answer, not a failure. A
|
|
817
|
+
* stage's rejections are counted against what is offered to it, so behind another stage that turns work away it
|
|
818
|
+
* passes at most what it can take (offered × (1 − rejects)), not a share of a share: the bottleneck decides.
|
|
819
|
+
*/
|
|
820
|
+
const failure = (f) => memo(`failures of ${f}`, () => {
|
|
821
|
+
let offered = 1;
|
|
822
|
+
let actual = 1;
|
|
823
|
+
let answered = 0;
|
|
824
|
+
for (const s of routes[f] ?? []) {
|
|
825
|
+
const r = stages[s].kind === "fanout" ? 0 : rejects(s);
|
|
826
|
+
if (r > 0) actual = Math.min(actual, offered * (1 - r));
|
|
827
|
+
if (stages[s].kind === "cache") {
|
|
828
|
+
answered += actual * hit(s);
|
|
829
|
+
actual *= 1 - hit(s);
|
|
830
|
+
}
|
|
831
|
+
offered *= onward(s);
|
|
832
|
+
}
|
|
833
|
+
return Math.max(0, 1 - answered - actual);
|
|
834
|
+
});
|
|
835
|
+
/**
|
|
836
|
+
* What actually reaches a stage, per second: like `at`, less what stages before it turned away. A stage takes up to
|
|
837
|
+
* what is offered to it × (1 − rejects), so behind another stage that turns work away the bottleneck decides.
|
|
838
|
+
*/
|
|
839
|
+
const actual = (s, f) => memo(`${f} reaching ${s}`, () => {
|
|
840
|
+
const route = routes[f] ?? [];
|
|
841
|
+
const last = route.findLastIndex((t) => t === s || copies(t, f, s) > 0);
|
|
842
|
+
let total = 0;
|
|
843
|
+
let r = ctx.flows[f];
|
|
844
|
+
for (const [i, t] of route.entries()) {
|
|
845
|
+
if (t === s) total += r;
|
|
846
|
+
total += r * copies(t, f, s) * (1 - rejects(t));
|
|
847
|
+
if (i >= last) break;
|
|
848
|
+
const cut = rejects(t);
|
|
849
|
+
if (cut > 0) r = Math.min(r, arriving(t, f) * (1 - cut));
|
|
850
|
+
if (stages[t].kind === "cache") r *= 1 - hit(t);
|
|
851
|
+
}
|
|
852
|
+
return total;
|
|
853
|
+
});
|
|
854
|
+
/** The share of what actually reaches a stage that it turns away: the one number the sim and the meters show. */
|
|
855
|
+
const shed = (s) => memo(`shed at ${s}`, () => {
|
|
856
|
+
const r = rejects(s);
|
|
857
|
+
if (!r) return 0;
|
|
858
|
+
let offered = 0;
|
|
859
|
+
let reaching = 0;
|
|
860
|
+
for (const f of flowIds) {
|
|
861
|
+
offered += arriving(s, f);
|
|
862
|
+
reaching += actual(s, f);
|
|
863
|
+
}
|
|
864
|
+
return reaching > 0 ? Math.max(0, 1 - offered * (1 - r) / reaching) : 0;
|
|
865
|
+
});
|
|
866
|
+
const retries = () => memo("retries", () => {
|
|
867
|
+
const ids = flowIds.filter((f) => retryOf(f));
|
|
868
|
+
const lam = {};
|
|
869
|
+
for (const f of ids) lam[f] = rate(f);
|
|
870
|
+
const most = (f) => {
|
|
871
|
+
const r = retryOf(f);
|
|
872
|
+
return Math.min(r.times, r.budget ?? Number.POSITIVE_INFINITY);
|
|
873
|
+
};
|
|
874
|
+
const attempts = (e) => {
|
|
875
|
+
const a = {};
|
|
876
|
+
for (const f of ids) a[f] = lam[f] * (1 + e[f]);
|
|
877
|
+
return a;
|
|
878
|
+
};
|
|
879
|
+
const wanted = (e) => {
|
|
880
|
+
const sub = context(def, values, bound, pinned, attempts(e));
|
|
881
|
+
const w = {};
|
|
882
|
+
const p = {};
|
|
883
|
+
for (const f of ids) {
|
|
884
|
+
p[f] = sub.failure(f);
|
|
885
|
+
w[f] = Math.min(excess(p[f], retryOf(f).times), most(f));
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
w,
|
|
889
|
+
p
|
|
890
|
+
};
|
|
891
|
+
};
|
|
892
|
+
let e = Object.fromEntries(ids.map((f) => [f, 0]));
|
|
893
|
+
for (let step = 0; step < 100; step++) {
|
|
894
|
+
const { w, p: failed } = wanted(e);
|
|
895
|
+
const d = {};
|
|
896
|
+
for (const f of ids) d[f] = Math.max(0, w[f] - e[f]);
|
|
897
|
+
const live = ids.filter((f) => lam[f] > 0 && d[f] > (step ? 1e-10 * (1 + e[f]) : 0));
|
|
898
|
+
if (!live.length) {
|
|
899
|
+
const a = attempts(e);
|
|
900
|
+
const out = {};
|
|
901
|
+
for (const f of ids) {
|
|
902
|
+
const l = lam[f];
|
|
903
|
+
const p = failed[f];
|
|
904
|
+
const times = retryOf(f).times;
|
|
905
|
+
const retried = p > 0 && excess(p, times) > e[f] * (1 + 1e-12) ? root((y) => excess(y, times), e[f], p) / p : 1;
|
|
906
|
+
out[f] = {
|
|
907
|
+
times,
|
|
908
|
+
rate: l,
|
|
909
|
+
attempts: a[f],
|
|
910
|
+
rejected: p,
|
|
911
|
+
retried,
|
|
912
|
+
failed: l > 0 ? 1 - (1 - p) * (1 + e[f]) : 0
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
const along = (t) => {
|
|
918
|
+
const y = { ...e };
|
|
919
|
+
for (const f of live) y[f] = e[f] + t * d[f];
|
|
920
|
+
return y;
|
|
921
|
+
};
|
|
922
|
+
const holds = (t) => {
|
|
923
|
+
const y = along(t);
|
|
924
|
+
const wy = wanted(y).w;
|
|
925
|
+
return Math.min(...live.map((f) => (wy[f] - y[f]) / d[f]));
|
|
926
|
+
};
|
|
927
|
+
const far = Math.min(...live.map((f) => (most(f) - e[f]) / d[f]));
|
|
928
|
+
if (far <= 1) e = along(far);
|
|
929
|
+
else {
|
|
930
|
+
const h1 = holds(1);
|
|
931
|
+
e = along(h1 > 0 ? settle(holds, 1, far, h1) : 1);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
throw new Error(`loadmap: the retries of ${joinAnd(ids.map((f) => `"${f}"`))} do not settle`);
|
|
935
|
+
});
|
|
936
|
+
const usedBy = (c) => stageIds.filter((s) => stages[s].component === c);
|
|
937
|
+
ctx.demand = table(componentIds, (c) => memo(`demand of ${c}`, () => {
|
|
938
|
+
const d = def.components[c].demand;
|
|
939
|
+
if (d) return d(ctx);
|
|
940
|
+
let total = 0;
|
|
941
|
+
for (const s of usedBy(c)) for (const f of flowIds) total += arriving(s, f);
|
|
942
|
+
return total;
|
|
943
|
+
}));
|
|
642
944
|
ctx.util = table(componentIds, (c) => memo(`utilization of ${c}`, () => {
|
|
643
945
|
if (!enabled(c)) return null;
|
|
644
946
|
const cap = boundOf(capacity(c), c === pinned ? void 0 : bound);
|
|
@@ -650,10 +952,55 @@ function context(def, values, bound, pinned) {
|
|
|
650
952
|
return {
|
|
651
953
|
ctx: Object.freeze(ctx),
|
|
652
954
|
capacity,
|
|
955
|
+
overload,
|
|
956
|
+
failure,
|
|
957
|
+
shed,
|
|
958
|
+
retries: () => flowIds.some((f) => retryOf(f)) ? retries() : {},
|
|
653
959
|
over: (c) => text(def.components[c].over) ?? "",
|
|
654
960
|
describe: (c) => text(def.components[c].describe)
|
|
655
961
|
};
|
|
656
962
|
}
|
|
963
|
+
/** y + y² + … + yⁿ: the retries per unit when each attempt fails with share y and n retries may follow. */
|
|
964
|
+
function excess(y, n) {
|
|
965
|
+
return y >= 1 ? n : y * (1 - y ** n) / (1 - y);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Where h, above 0 at lo and at most 0 at hi, reaches 0: regula falsi with the Illinois step, a few evaluations where
|
|
969
|
+
* bisection takes forty, and still bracketed when h has a kink (the knee where rejections start).
|
|
970
|
+
*/
|
|
971
|
+
function settle(h, lo, hi, hl = h(lo)) {
|
|
972
|
+
let hh = h(hi);
|
|
973
|
+
if (hh >= 0) return hi;
|
|
974
|
+
let side = 0;
|
|
975
|
+
let x = lo;
|
|
976
|
+
for (let k = 0; k < 100 && hi - lo > 1e-13 * hi; k++) {
|
|
977
|
+
x = (lo * hh - hi * hl) / (hh - hl);
|
|
978
|
+
const hx = h(x);
|
|
979
|
+
if (hx === 0) break;
|
|
980
|
+
if (hx > 0) {
|
|
981
|
+
lo = x;
|
|
982
|
+
hl = hx;
|
|
983
|
+
if (side === 1) hh /= 2;
|
|
984
|
+
side = 1;
|
|
985
|
+
} else {
|
|
986
|
+
hi = x;
|
|
987
|
+
hh = hx;
|
|
988
|
+
if (side === -1) hl /= 2;
|
|
989
|
+
side = -1;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return x;
|
|
993
|
+
}
|
|
994
|
+
/** The y in [0, hi] where a rising f(y) reaches `goal`. */
|
|
995
|
+
function root(f, goal, hi) {
|
|
996
|
+
let lo = 0;
|
|
997
|
+
for (let k = 0; k < 200 && hi - lo > 1e-15; k++) {
|
|
998
|
+
const mid = (lo + hi) / 2;
|
|
999
|
+
if (f(mid) < goal) lo = mid;
|
|
1000
|
+
else hi = mid;
|
|
1001
|
+
}
|
|
1002
|
+
return (lo + hi) / 2;
|
|
1003
|
+
}
|
|
657
1004
|
/**
|
|
658
1005
|
* The lowest load multiple at which `util(load)` reaches `th`. A coarse geometric scan finds the first bracket, so a
|
|
659
1006
|
* utilization that does not rise steadily with the load is not mis-bisected; bisection then narrows it.
|
|
@@ -688,13 +1035,13 @@ function reach(r, searchMax, when = () => null) {
|
|
|
688
1035
|
if (!r) return multiple(null);
|
|
689
1036
|
return `${multiples(r, searchMax)}${months(when(r.low), when(r.high))}`;
|
|
690
1037
|
}
|
|
691
|
-
/** " (capacity estimated ±30 %)"; nothing for a measured capacity without a
|
|
1038
|
+
/** " (capacity estimated ±30 %)"; nothing for a measured capacity without a ± or a configured one. */
|
|
692
1039
|
function sureness(c) {
|
|
693
|
-
if (!c || c.provenance
|
|
1040
|
+
if (!c || c.provenance !== "estimated" && !c.within) return "";
|
|
694
1041
|
return ` (capacity ${c.provenance}${c.within ? ` ±${pct(c.within)}` : ""})`;
|
|
695
1042
|
}
|
|
696
1043
|
function verdict(load, live, first, range, th, searchMax, when) {
|
|
697
|
-
const at = `At ${fmt(load)}×`;
|
|
1044
|
+
const at = `At ${fmt$1(load)}×`;
|
|
698
1045
|
const target = `${pct(th.target)} target`;
|
|
699
1046
|
if (!first) return {
|
|
700
1047
|
tier: "ok",
|
|
@@ -810,7 +1157,7 @@ function check$1(model, expectations = model.expectations) {
|
|
|
810
1157
|
});
|
|
811
1158
|
}
|
|
812
1159
|
const failed = claims.filter((x) => !x.ok);
|
|
813
|
-
const message = failed.length ? failed.map((x) => `expected at least ${fmt(x.min)}×, but ${x.text}`).join("; ") : claims.map((x) => x.text).join("; ");
|
|
1160
|
+
const message = failed.length ? failed.map((x) => `expected at least ${fmt$1(x.min)}×, but ${x.text}`).join("; ") : claims.map((x) => x.text).join("; ");
|
|
814
1161
|
return {
|
|
815
1162
|
expectation: e,
|
|
816
1163
|
ok: !failed.length,
|
|
@@ -1014,6 +1361,26 @@ function distance(a, b) {
|
|
|
1014
1361
|
function own(o, k) {
|
|
1015
1362
|
return Object.hasOwn(o, k) ? o[k] : void 0;
|
|
1016
1363
|
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Which argument of a statement fills which slot: by kind, in order, optional slots skipped. `bad` are the ones that
|
|
1366
|
+
* fit no slot, `missing` the first required slot left empty.
|
|
1367
|
+
*/
|
|
1368
|
+
function matchArgs(slots, args) {
|
|
1369
|
+
const open = [...slots];
|
|
1370
|
+
const filled = /* @__PURE__ */ new Map();
|
|
1371
|
+
const bad = [];
|
|
1372
|
+
for (const a of args) {
|
|
1373
|
+
const kind = a.t === "id" ? "id" : a.t === "str" || a.t === "tpl" ? "text" : a.t === "num" ? "number" : null;
|
|
1374
|
+
const i = open.findIndex((s) => s.kind === kind);
|
|
1375
|
+
if (kind == null || i < 0 || open.slice(0, i).some((s) => !s.optional)) bad.push(a);
|
|
1376
|
+
else filled.set(open.splice(0, i + 1).at(-1), a);
|
|
1377
|
+
}
|
|
1378
|
+
return {
|
|
1379
|
+
filled,
|
|
1380
|
+
bad,
|
|
1381
|
+
missing: open.find((s) => !s.optional)
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1017
1384
|
const STAGE_LABEL = [{
|
|
1018
1385
|
name: "id",
|
|
1019
1386
|
kind: "id",
|
|
@@ -1046,14 +1413,16 @@ const COMPONENT_PROPS = {
|
|
|
1046
1413
|
doc: "\"are past capacity\" instead of \"is\"."
|
|
1047
1414
|
},
|
|
1048
1415
|
demand: {
|
|
1416
|
+
inspector: "main",
|
|
1049
1417
|
value: "number",
|
|
1050
1418
|
scope: "ctx",
|
|
1051
|
-
doc: "What the component must handle, in its capacity's unit."
|
|
1419
|
+
doc: "What the component must handle, in its capacity's unit. Without one: the units per second reaching its stages."
|
|
1052
1420
|
},
|
|
1053
1421
|
capacity: {
|
|
1422
|
+
inspector: "main",
|
|
1054
1423
|
value: "capacity",
|
|
1055
1424
|
scope: "ctx",
|
|
1056
|
-
doc: "What it can handle: measured(value, unit, source) or estimated(value, unit, source)
|
|
1425
|
+
doc: "What it can handle: measured(value, unit, source) or estimated(value, unit, source), `value ± 30%` saying how sure it is; configured(value, unit, source) for a limit someone set."
|
|
1057
1426
|
},
|
|
1058
1427
|
when: {
|
|
1059
1428
|
value: "boolean",
|
|
@@ -1114,6 +1483,7 @@ const SCHEMA = {
|
|
|
1114
1483
|
args: [],
|
|
1115
1484
|
props: {
|
|
1116
1485
|
title: {
|
|
1486
|
+
inspector: "main",
|
|
1117
1487
|
value: "string",
|
|
1118
1488
|
scope: "const",
|
|
1119
1489
|
doc: "The heading above the diagram."
|
|
@@ -1172,6 +1542,7 @@ const SCHEMA = {
|
|
|
1172
1542
|
children: []
|
|
1173
1543
|
},
|
|
1174
1544
|
load: {
|
|
1545
|
+
inspector: "model",
|
|
1175
1546
|
doc: "The load, as a multiple of today. It drives the limits.",
|
|
1176
1547
|
example: "load \"Users, × today\" { max: 100; default: 10 }",
|
|
1177
1548
|
args: [{
|
|
@@ -1186,6 +1557,7 @@ const SCHEMA = {
|
|
|
1186
1557
|
doc: "The lowest multiple. Default 1."
|
|
1187
1558
|
},
|
|
1188
1559
|
max: {
|
|
1560
|
+
inspector: "main",
|
|
1189
1561
|
value: "number",
|
|
1190
1562
|
scope: "const",
|
|
1191
1563
|
doc: "The highest multiple on the slider.",
|
|
@@ -1197,6 +1569,7 @@ const SCHEMA = {
|
|
|
1197
1569
|
doc: "The slider step. Default 1."
|
|
1198
1570
|
},
|
|
1199
1571
|
default: {
|
|
1572
|
+
inspector: "main",
|
|
1200
1573
|
value: "number",
|
|
1201
1574
|
scope: "const",
|
|
1202
1575
|
doc: "Where the slider starts. Default the minimum."
|
|
@@ -1205,6 +1578,7 @@ const SCHEMA = {
|
|
|
1205
1578
|
children: []
|
|
1206
1579
|
},
|
|
1207
1580
|
param: {
|
|
1581
|
+
inspector: "model",
|
|
1208
1582
|
doc: "A control: options make a select, min and max a slider, a true or false default a switch.",
|
|
1209
1583
|
example: "param pods \"Worker pods\" { options: [2, 3, 4, 6]; default: 3 }",
|
|
1210
1584
|
args: [{
|
|
@@ -1217,26 +1591,31 @@ const SCHEMA = {
|
|
|
1217
1591
|
}],
|
|
1218
1592
|
props: {
|
|
1219
1593
|
options: {
|
|
1594
|
+
inspector: "main",
|
|
1220
1595
|
value: "options",
|
|
1221
1596
|
scope: "const",
|
|
1222
1597
|
doc: "The choices of a select: [2, 3, 4] or [\"a\", \"b\"]."
|
|
1223
1598
|
},
|
|
1224
1599
|
min: {
|
|
1600
|
+
inspector: "main",
|
|
1225
1601
|
value: "number",
|
|
1226
1602
|
scope: "const",
|
|
1227
1603
|
doc: "The lowest value of a slider."
|
|
1228
1604
|
},
|
|
1229
1605
|
max: {
|
|
1606
|
+
inspector: "main",
|
|
1230
1607
|
value: "number",
|
|
1231
1608
|
scope: "const",
|
|
1232
1609
|
doc: "The highest value of a slider."
|
|
1233
1610
|
},
|
|
1234
1611
|
step: {
|
|
1612
|
+
inspector: "main",
|
|
1235
1613
|
value: "number",
|
|
1236
1614
|
scope: "const",
|
|
1237
1615
|
doc: "The step of a slider. Default 1."
|
|
1238
1616
|
},
|
|
1239
1617
|
default: {
|
|
1618
|
+
inspector: "main",
|
|
1240
1619
|
value: "value",
|
|
1241
1620
|
scope: "const",
|
|
1242
1621
|
doc: "The starting value.",
|
|
@@ -1302,11 +1681,13 @@ const SCHEMA = {
|
|
|
1302
1681
|
}
|
|
1303
1682
|
},
|
|
1304
1683
|
growth: {
|
|
1684
|
+
inspector: "model",
|
|
1305
1685
|
doc: "How fast the load grows, compounded from asOf, where it is 1×: load multiples become dates.",
|
|
1306
1686
|
example: "growth { rate: 0.06; per: month; asOf: \"2026-11-01\" }",
|
|
1307
1687
|
args: [],
|
|
1308
1688
|
props: {
|
|
1309
1689
|
rate: {
|
|
1690
|
+
inspector: "main",
|
|
1310
1691
|
value: "number",
|
|
1311
1692
|
scope: "const",
|
|
1312
1693
|
required: true,
|
|
@@ -1395,6 +1776,7 @@ const SCHEMA = {
|
|
|
1395
1776
|
}
|
|
1396
1777
|
},
|
|
1397
1778
|
let: {
|
|
1779
|
+
inspector: "model",
|
|
1398
1780
|
doc: "A named formula other formulas can use by its name.",
|
|
1399
1781
|
example: "let perPosition = watchOnly ? 0.3 : 1.2",
|
|
1400
1782
|
args: [{
|
|
@@ -1406,6 +1788,7 @@ const SCHEMA = {
|
|
|
1406
1788
|
children: []
|
|
1407
1789
|
},
|
|
1408
1790
|
fit: {
|
|
1791
|
+
inspector: "model",
|
|
1409
1792
|
doc: "cost = a + b × x, fitted from measured points. Call it like a function: workerCores(rate).",
|
|
1410
1793
|
example: "fit workerCores \"Worker CPU per request\" { source: \"load test\"; points rate cores { 200 0.26 } }",
|
|
1411
1794
|
args: [{
|
|
@@ -1446,6 +1829,7 @@ const SCHEMA = {
|
|
|
1446
1829
|
rows: true
|
|
1447
1830
|
},
|
|
1448
1831
|
flow: {
|
|
1832
|
+
inspector: "model",
|
|
1449
1833
|
doc: "Something that moves through the system, with its rate and its route.",
|
|
1450
1834
|
example: "flow orders \"orders\" { unit: \"order\"; rate: 40 * load; route: gateway -> topic -> workers }",
|
|
1451
1835
|
args: [{
|
|
@@ -1463,6 +1847,7 @@ const SCHEMA = {
|
|
|
1463
1847
|
doc: "One unit of the flow, singular: \"order\"."
|
|
1464
1848
|
},
|
|
1465
1849
|
rate: {
|
|
1850
|
+
inspector: "main",
|
|
1466
1851
|
value: "number",
|
|
1467
1852
|
scope: "ctx",
|
|
1468
1853
|
doc: "Units per second.",
|
|
@@ -1492,7 +1877,11 @@ const SCHEMA = {
|
|
|
1492
1877
|
doc: "A theme colour (accent, alt, ok, warn, drop, muted) or a CSS colour in quotes."
|
|
1493
1878
|
}
|
|
1494
1879
|
},
|
|
1495
|
-
children: [
|
|
1880
|
+
children: [
|
|
1881
|
+
"drop",
|
|
1882
|
+
"merge",
|
|
1883
|
+
"retry"
|
|
1884
|
+
]
|
|
1496
1885
|
},
|
|
1497
1886
|
drop: {
|
|
1498
1887
|
doc: "Units older than olderThan simulated seconds are dropped before they are handled.",
|
|
@@ -1534,6 +1923,32 @@ const SCHEMA = {
|
|
|
1534
1923
|
},
|
|
1535
1924
|
children: []
|
|
1536
1925
|
},
|
|
1926
|
+
retry: {
|
|
1927
|
+
doc: "A unit turned away on the route (by a pass, limit or cache stage past capacity) is sent again, up to times more times. The attempts settle where the retries they cause are the ones assumed.",
|
|
1928
|
+
example: "retry { times: 3; budget: 0.1 }",
|
|
1929
|
+
args: [],
|
|
1930
|
+
props: {
|
|
1931
|
+
times: {
|
|
1932
|
+
inspector: "main",
|
|
1933
|
+
value: "number",
|
|
1934
|
+
scope: "values",
|
|
1935
|
+
doc: "Retries after the first attempt: a whole number. Reads only the parameters.",
|
|
1936
|
+
required: true
|
|
1937
|
+
},
|
|
1938
|
+
budget: {
|
|
1939
|
+
inspector: "main",
|
|
1940
|
+
value: "number",
|
|
1941
|
+
scope: "values",
|
|
1942
|
+
doc: "At most this share of the flow is added as retries: 0.1 adds at most 10 %. Without one, every retry is sent."
|
|
1943
|
+
},
|
|
1944
|
+
when: {
|
|
1945
|
+
value: "boolean",
|
|
1946
|
+
scope: "values",
|
|
1947
|
+
doc: "The rule applies only while this is true; it reads only parameters."
|
|
1948
|
+
}
|
|
1949
|
+
},
|
|
1950
|
+
children: []
|
|
1951
|
+
},
|
|
1537
1952
|
component: {
|
|
1538
1953
|
doc: "Something that can run out: its demand against its capacity. On a box, write these lines on the box.",
|
|
1539
1954
|
example: "component cache \"Cache\" { demand: flows.reads; capacity: estimated(9000, \"req/s\") }",
|
|
@@ -1568,6 +1983,8 @@ const SCHEMA = {
|
|
|
1568
1983
|
"store",
|
|
1569
1984
|
"sink",
|
|
1570
1985
|
"fanout",
|
|
1986
|
+
"cache",
|
|
1987
|
+
"limit",
|
|
1571
1988
|
"component"
|
|
1572
1989
|
]
|
|
1573
1990
|
},
|
|
@@ -1578,6 +1995,7 @@ const SCHEMA = {
|
|
|
1578
1995
|
props: {
|
|
1579
1996
|
sub: SUB,
|
|
1580
1997
|
members: {
|
|
1998
|
+
inspector: "main",
|
|
1581
1999
|
value: "number",
|
|
1582
2000
|
scope: "ctx",
|
|
1583
2001
|
doc: "How many members.",
|
|
@@ -1614,6 +2032,29 @@ const SCHEMA = {
|
|
|
1614
2032
|
props: { component: STAGE_COMPONENT },
|
|
1615
2033
|
children: []
|
|
1616
2034
|
},
|
|
2035
|
+
cache: {
|
|
2036
|
+
doc: "A share of the units (hit) is answered here and goes no further; the rest carry on to the next stage. Past capacity the share above 100 % is rejected.",
|
|
2037
|
+
example: "cache { hit: 0.8 }",
|
|
2038
|
+
args: STAGE_LABEL,
|
|
2039
|
+
props: {
|
|
2040
|
+
hit: {
|
|
2041
|
+
inspector: "main",
|
|
2042
|
+
value: "number",
|
|
2043
|
+
scope: "ctx",
|
|
2044
|
+
doc: "The share answered here, 0 to 1.",
|
|
2045
|
+
required: true
|
|
2046
|
+
},
|
|
2047
|
+
component: STAGE_COMPONENT
|
|
2048
|
+
},
|
|
2049
|
+
children: []
|
|
2050
|
+
},
|
|
2051
|
+
limit: {
|
|
2052
|
+
doc: "A rate limiter: its component's capacity is the limit. Above it the share over is turned away, and the stages after it see only what got through.",
|
|
2053
|
+
example: "limit",
|
|
2054
|
+
args: STAGE_LABEL,
|
|
2055
|
+
props: { component: STAGE_COMPONENT },
|
|
2056
|
+
children: []
|
|
2057
|
+
},
|
|
1617
2058
|
store: {
|
|
1618
2059
|
doc: "Where a flow ends: written to its component.",
|
|
1619
2060
|
example: "store",
|
|
@@ -1656,12 +2097,13 @@ const SCHEMA = {
|
|
|
1656
2097
|
args: STAGE_LABEL,
|
|
1657
2098
|
props: {
|
|
1658
2099
|
pods: {
|
|
2100
|
+
inspector: "main",
|
|
1659
2101
|
value: "number",
|
|
1660
2102
|
scope: "ctx",
|
|
1661
|
-
doc: "How many pods."
|
|
1662
|
-
required: true
|
|
2103
|
+
doc: "How many pods; an autoscaled pool has autoscale instead."
|
|
1663
2104
|
},
|
|
1664
2105
|
perPod: {
|
|
2106
|
+
inspector: "main",
|
|
1665
2107
|
value: "number",
|
|
1666
2108
|
scope: "ctx",
|
|
1667
2109
|
doc: "Workers per pod.",
|
|
@@ -1675,6 +2117,35 @@ const SCHEMA = {
|
|
|
1675
2117
|
},
|
|
1676
2118
|
component: STAGE_COMPONENT
|
|
1677
2119
|
},
|
|
2120
|
+
children: ["autoscale"]
|
|
2121
|
+
},
|
|
2122
|
+
autoscale: {
|
|
2123
|
+
doc: "Pods are added and removed to hold each running pod near target, between min and max. The component's capacity is the pool at max pods: it runs out when the pool cannot grow further.",
|
|
2124
|
+
example: "autoscale { min: 2; max: 10; target: 0.7 }",
|
|
2125
|
+
args: [],
|
|
2126
|
+
props: {
|
|
2127
|
+
min: {
|
|
2128
|
+
inspector: "main",
|
|
2129
|
+
value: "number",
|
|
2130
|
+
scope: "ctx",
|
|
2131
|
+
doc: "The fewest pods, at least 1.",
|
|
2132
|
+
required: true
|
|
2133
|
+
},
|
|
2134
|
+
max: {
|
|
2135
|
+
inspector: "main",
|
|
2136
|
+
value: "number",
|
|
2137
|
+
scope: "ctx",
|
|
2138
|
+
doc: "The most pods.",
|
|
2139
|
+
required: true
|
|
2140
|
+
},
|
|
2141
|
+
target: {
|
|
2142
|
+
inspector: "main",
|
|
2143
|
+
value: "number",
|
|
2144
|
+
scope: "ctx",
|
|
2145
|
+
doc: "The utilization each running pod is held at, above 0 and at most 1: 0.7 scales out above 70 %.",
|
|
2146
|
+
required: true
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
1678
2149
|
children: []
|
|
1679
2150
|
},
|
|
1680
2151
|
fanout: {
|
|
@@ -1686,6 +2157,7 @@ const SCHEMA = {
|
|
|
1686
2157
|
edges: "branches"
|
|
1687
2158
|
},
|
|
1688
2159
|
thresholds: {
|
|
2160
|
+
inspector: "model",
|
|
1689
2161
|
doc: "The utilization of the target and of \"runs out\".",
|
|
1690
2162
|
example: "thresholds { target: 0.5; full: 1 }",
|
|
1691
2163
|
args: [],
|
|
@@ -1728,7 +2200,9 @@ const STAGES = [
|
|
|
1728
2200
|
"sink",
|
|
1729
2201
|
"queue",
|
|
1730
2202
|
"workers",
|
|
1731
|
-
"fanout"
|
|
2203
|
+
"fanout",
|
|
2204
|
+
"cache",
|
|
2205
|
+
"limit"
|
|
1732
2206
|
];
|
|
1733
2207
|
// ../dsl/src/expr.ts
|
|
1734
2208
|
/** A formula failed while the model ran: where, and why. */
|
|
@@ -1800,7 +2274,7 @@ const BUILTINS = {
|
|
|
1800
2274
|
fmt: {
|
|
1801
2275
|
args: ["number"],
|
|
1802
2276
|
result: "string",
|
|
1803
|
-
fn: fmt
|
|
2277
|
+
fn: fmt$1
|
|
1804
2278
|
},
|
|
1805
2279
|
compact: {
|
|
1806
2280
|
args: ["number"],
|
|
@@ -1836,11 +2310,22 @@ const BUILTINS = {
|
|
|
1836
2310
|
min: 2,
|
|
1837
2311
|
result: "capacity",
|
|
1838
2312
|
fn: estimated
|
|
2313
|
+
},
|
|
2314
|
+
configured: {
|
|
2315
|
+
args: [
|
|
2316
|
+
"number",
|
|
2317
|
+
"string",
|
|
2318
|
+
"string"
|
|
2319
|
+
],
|
|
2320
|
+
min: 2,
|
|
2321
|
+
result: "capacity",
|
|
2322
|
+
fn: configured
|
|
1839
2323
|
}
|
|
1840
2324
|
};
|
|
1841
|
-
/** The tables a formula may read with a ".": flows.orders, demand.db, util.db, derived.x. */
|
|
2325
|
+
/** The tables a formula may read with a ".": flows.orders, at.db.orders, demand.db, util.db, derived.x. */
|
|
1842
2326
|
const TABLES = [
|
|
1843
2327
|
"flows",
|
|
2328
|
+
"at",
|
|
1844
2329
|
"demand",
|
|
1845
2330
|
"util",
|
|
1846
2331
|
"derived"
|
|
@@ -2005,14 +2490,15 @@ var Compiler$1 = class {
|
|
|
2005
2490
|
return bad;
|
|
2006
2491
|
}
|
|
2007
2492
|
example(table) {
|
|
2008
|
-
return [...table === "flows" ? this.names.flows : table === "derived" ? this.names.lets.keys() : this.names.components][0] ?? "x";
|
|
2493
|
+
return [...table === "flows" ? this.names.flows : table === "at" ? this.names.stages : table === "derived" ? this.names.lets.keys() : this.names.components][0] ?? "x";
|
|
2009
2494
|
}
|
|
2010
2495
|
member(e) {
|
|
2011
2496
|
const table = e.object.name;
|
|
2012
2497
|
const key = e.prop.name;
|
|
2498
|
+
if (table === "at") return this.at(e);
|
|
2013
2499
|
const pool = table === "flows" ? this.names.flows : table === "demand" || table === "util" ? this.names.components : table === "derived" ? this.names.lets.keys() : void 0;
|
|
2014
2500
|
if (!pool) {
|
|
2015
|
-
this.report.error(e.object, `"${table}" has no entries: only flows., demand., util. and derived. do`);
|
|
2501
|
+
this.report.error(e.object, `"${table}" has no entries: only flows., at., demand., util. and derived. do`);
|
|
2016
2502
|
return bad;
|
|
2017
2503
|
}
|
|
2018
2504
|
if (!(table === "derived" ? this.names.lets.has(key) : pool.has(key))) {
|
|
@@ -2027,6 +2513,26 @@ var Compiler$1 = class {
|
|
|
2027
2513
|
run: (c) => c[table][key]
|
|
2028
2514
|
};
|
|
2029
2515
|
}
|
|
2516
|
+
/** at.db: every flow reaching the stage, per second; at.db.orders: one flow. */
|
|
2517
|
+
at(e) {
|
|
2518
|
+
const stage = e.prop.name;
|
|
2519
|
+
if (!this.names.stages.has(stage)) {
|
|
2520
|
+
this.report.error(e.prop, `unknown stage "${stage}"`, didYouMean(stage, this.names.stages));
|
|
2521
|
+
return bad;
|
|
2522
|
+
}
|
|
2523
|
+
const flow = e.flow?.name;
|
|
2524
|
+
if (flow != null && !this.names.flows.has(flow)) {
|
|
2525
|
+
this.report.error(e.flow, `unknown flow "${flow}"`, didYouMean(flow, this.names.flows));
|
|
2526
|
+
return bad;
|
|
2527
|
+
}
|
|
2528
|
+
if (!this.canRead(e, flow == null ? `at.${stage}` : `at.${stage}.${flow}`)) return bad;
|
|
2529
|
+
const flows = [...this.names.flows];
|
|
2530
|
+
return {
|
|
2531
|
+
type: "number",
|
|
2532
|
+
constant: false,
|
|
2533
|
+
run: flow == null ? (c) => flows.reduce((a, f) => a + c.at[stage][f], 0) : (c) => c.at[stage][flow]
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2030
2536
|
call(e) {
|
|
2031
2537
|
const name = e.callee.name;
|
|
2032
2538
|
const pm = e.args[0]?.t === "pm" ? e.args[0] : null;
|
|
@@ -2180,8 +2686,11 @@ const PUNCT = [
|
|
|
2180
2686
|
/**
|
|
2181
2687
|
* Splits `source[from, to)` into tokens. Newlines end statements, except inside parentheses and brackets, so a long
|
|
2182
2688
|
* formula can wrap there. `#` starts a comment to the end of the line.
|
|
2689
|
+
*
|
|
2690
|
+
* With `trivia` (for the formatter, never the parser) every newline is a token, blank lines and wrapped formulas
|
|
2691
|
+
* included, and so is every comment.
|
|
2183
2692
|
*/
|
|
2184
|
-
function lex(source, report, from = 0, to = source.length) {
|
|
2693
|
+
function lex(source, report, from = 0, to = source.length, trivia = false) {
|
|
2185
2694
|
const out = [];
|
|
2186
2695
|
let depth = 0;
|
|
2187
2696
|
let i = from;
|
|
@@ -2189,7 +2698,7 @@ function lex(source, report, from = 0, to = source.length) {
|
|
|
2189
2698
|
while (i < to) {
|
|
2190
2699
|
const c = source[i];
|
|
2191
2700
|
if (c === "\n") {
|
|
2192
|
-
if (depth === 0 && out.at(-1)?.kind !== "newline") push({
|
|
2701
|
+
if (trivia || depth === 0 && out.at(-1)?.kind !== "newline") push({
|
|
2193
2702
|
kind: "newline",
|
|
2194
2703
|
text: "\n",
|
|
2195
2704
|
from: i,
|
|
@@ -2197,8 +2706,16 @@ function lex(source, report, from = 0, to = source.length) {
|
|
|
2197
2706
|
});
|
|
2198
2707
|
i++;
|
|
2199
2708
|
} else if (c === " " || c === " " || c === "\r") i++;
|
|
2200
|
-
else if (c === "#")
|
|
2201
|
-
|
|
2709
|
+
else if (c === "#") {
|
|
2710
|
+
const s = i;
|
|
2711
|
+
while (i < to && source[i] !== "\n") i++;
|
|
2712
|
+
if (trivia) push({
|
|
2713
|
+
kind: "comment",
|
|
2714
|
+
text: source.slice(s, i).trimEnd(),
|
|
2715
|
+
from: s,
|
|
2716
|
+
to: i
|
|
2717
|
+
});
|
|
2718
|
+
} else if (isIdStart(c)) {
|
|
2202
2719
|
const s = i;
|
|
2203
2720
|
while (i < to && isIdPart(source[i])) i++;
|
|
2204
2721
|
push({
|
|
@@ -2401,7 +2918,7 @@ const PREC = {
|
|
|
2401
2918
|
/** Thrown after a syntax error has been reported, to skip to the next statement. */
|
|
2402
2919
|
var Bail$1 = class {};
|
|
2403
2920
|
/** Parses a definition into statements. Never throws: errors go to the reporter, and parsing goes on at the next line. */
|
|
2404
|
-
function parse$
|
|
2921
|
+
function parse$2(source, report = new Reporter(source)) {
|
|
2405
2922
|
if (source.length > 2e5) {
|
|
2406
2923
|
report.error({
|
|
2407
2924
|
from: 0,
|
|
@@ -2771,6 +3288,19 @@ var Parser = class {
|
|
|
2771
3288
|
this.next();
|
|
2772
3289
|
const prop = this.ident("a name after \".\"");
|
|
2773
3290
|
if (!this.is("(")) {
|
|
3291
|
+
if (callee.name === "at" && this.is(".")) {
|
|
3292
|
+
this.next();
|
|
3293
|
+
const flow = this.ident("a flow after \"at.<stage>.\"");
|
|
3294
|
+
if (this.is(".")) this.fail(this.peek(), "at takes two names: at.db.orders");
|
|
3295
|
+
return {
|
|
3296
|
+
t: "member",
|
|
3297
|
+
object: callee,
|
|
3298
|
+
prop,
|
|
3299
|
+
flow,
|
|
3300
|
+
from: t.from,
|
|
3301
|
+
to: flow.to
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
2774
3304
|
if (this.is(".")) this.fail(this.peek(), "only one \".\" is allowed: flows.orders, util.db");
|
|
2775
3305
|
return {
|
|
2776
3306
|
t: "member",
|
|
@@ -2872,7 +3402,7 @@ function compile(source, o = {}) {
|
|
|
2872
3402
|
let definition;
|
|
2873
3403
|
let expectations = [];
|
|
2874
3404
|
try {
|
|
2875
|
-
doc = parse$
|
|
3405
|
+
doc = parse$2(source, report).doc;
|
|
2876
3406
|
definition = c.run(doc);
|
|
2877
3407
|
if (definition) expectations = check$1(definition.model).map((result, i) => {
|
|
2878
3408
|
const at = c.expectSpans[i];
|
|
@@ -2989,6 +3519,7 @@ var Compiler = class {
|
|
|
2989
3519
|
params: paramTypes,
|
|
2990
3520
|
lets: this.lets,
|
|
2991
3521
|
flows: new Set(flowNames.keys()),
|
|
3522
|
+
stages: new Set(stageNames.keys()),
|
|
2992
3523
|
components: new Set(componentNames.keys()),
|
|
2993
3524
|
fits: this.fits,
|
|
2994
3525
|
functions: this.functions
|
|
@@ -3074,6 +3605,8 @@ var Compiler = class {
|
|
|
3074
3605
|
if (priority != null) def.priority = priority;
|
|
3075
3606
|
const overload = this.overload(b);
|
|
3076
3607
|
if (overload) def.overload = overload;
|
|
3608
|
+
const retry = this.retry(b);
|
|
3609
|
+
if (retry) def.retry = retry;
|
|
3077
3610
|
flowDefs[id.name] = def;
|
|
3078
3611
|
const route = this.prop(b, "route");
|
|
3079
3612
|
const stages = [];
|
|
@@ -3109,6 +3642,8 @@ var Compiler = class {
|
|
|
3109
3642
|
if (def) stageDefs[s.id] = def;
|
|
3110
3643
|
}
|
|
3111
3644
|
for (const [id, s] of stageOf) if (!reached.has(id) && s.block.keyword !== "fanout") this.report.warning(s.block.args.id ?? s.block.at, `no flow passes the stage "${id}"`);
|
|
3645
|
+
const used = new Set(Object.values(stageDefs).map((d) => d.component));
|
|
3646
|
+
for (const [id, c] of Object.entries(componentDefs)) if (!c.demand && !used.has(id)) this.report.error(componentNames.get(id), `the component "${id}" needs "demand: …": no stage uses it`, COMPONENT_PROPS.demand.doc);
|
|
3112
3647
|
const calibration = {};
|
|
3113
3648
|
for (const f of fits) {
|
|
3114
3649
|
const fit = f.args.id && this.fits.get(f.args.id.name);
|
|
@@ -3272,16 +3807,9 @@ var Compiler = class {
|
|
|
3272
3807
|
return b;
|
|
3273
3808
|
}
|
|
3274
3809
|
args(b, d) {
|
|
3275
|
-
const
|
|
3276
|
-
for (const a of
|
|
3277
|
-
|
|
3278
|
-
const i = slots.findIndex((s) => s.kind === kind);
|
|
3279
|
-
if (kind == null || i < 0 || slots.slice(0, i).some((s) => !s.optional)) {
|
|
3280
|
-
this.report.error(a, `unexpected ${this.slice(a)} after "${b.keyword}"`, usage(b.keyword));
|
|
3281
|
-
continue;
|
|
3282
|
-
}
|
|
3283
|
-
const slot = slots[i];
|
|
3284
|
-
slots.splice(0, i + 1);
|
|
3810
|
+
const { filled, bad, missing } = matchArgs(b.spec.args, d.args);
|
|
3811
|
+
for (const a of bad) this.report.error(a, `unexpected ${this.slice(a)} after "${b.keyword}"`, usage(b.keyword));
|
|
3812
|
+
for (const [slot, a] of filled) {
|
|
3285
3813
|
if (a.t === "tpl" && !slot.dynamic && !a.parts.length) {
|
|
3286
3814
|
b.args[slot.name] = {
|
|
3287
3815
|
t: "str",
|
|
@@ -3299,7 +3827,6 @@ var Compiler = class {
|
|
|
3299
3827
|
};
|
|
3300
3828
|
else b.args[slot.name] = a;
|
|
3301
3829
|
}
|
|
3302
|
-
const missing = slots.find((s) => !s.optional);
|
|
3303
3830
|
if (missing) this.report.error(b.at, `"${b.keyword}" needs its ${missing.name}`, usage(b.keyword));
|
|
3304
3831
|
if (b.spec.init && !d.init) this.report.error(b.at, `"${b.keyword}" needs "= formula"`, usage(b.keyword));
|
|
3305
3832
|
if (!b.spec.init && d.init) this.report.error(d.init, `"${b.keyword}" takes no "="`);
|
|
@@ -3706,13 +4233,16 @@ var Compiler = class {
|
|
|
3706
4233
|
if (!rules.length) return void 0;
|
|
3707
4234
|
const seen = /* @__PURE__ */ new Set();
|
|
3708
4235
|
const parts = [];
|
|
4236
|
+
const props = {
|
|
4237
|
+
drop: ["olderThan", "reason"],
|
|
4238
|
+
merge: ["share", "reason"]
|
|
4239
|
+
};
|
|
3709
4240
|
for (const r of rules) {
|
|
3710
4241
|
const key = r.keyword;
|
|
3711
4242
|
if (seen.has(key)) this.report.error(r.at, `"${key}" is set twice in this flow`);
|
|
3712
4243
|
seen.add(key);
|
|
3713
4244
|
const fields = [];
|
|
3714
|
-
const
|
|
3715
|
-
for (const k of [first, "reason"]) {
|
|
4245
|
+
for (const k of props[key]) {
|
|
3716
4246
|
const v = this.dynProp(r, k, k === "reason" ? "string" : "number");
|
|
3717
4247
|
if (v !== void 0) fields.push([k, v]);
|
|
3718
4248
|
}
|
|
@@ -3736,6 +4266,24 @@ var Compiler = class {
|
|
|
3736
4266
|
};
|
|
3737
4267
|
return fixed ? at(void 0) : at;
|
|
3738
4268
|
}
|
|
4269
|
+
/** A flow's retry rule: it reads only the load and the parameters, since retries change every other number. */
|
|
4270
|
+
retry(b) {
|
|
4271
|
+
const r = b.children.filter((c) => c.keyword === "retry");
|
|
4272
|
+
if (!r.length) return void 0;
|
|
4273
|
+
if (r.length > 1) this.report.error(r[1].at, "\"retry\" is set twice in this flow");
|
|
4274
|
+
const rule = r[0];
|
|
4275
|
+
const times = this.dynProp(rule, "times", "number");
|
|
4276
|
+
const budget = this.dynProp(rule, "budget", "number");
|
|
4277
|
+
const w = this.prop(rule, "when");
|
|
4278
|
+
const when = w && placed(w.value, this.formula(w.value, "values").run);
|
|
4279
|
+
const at = (v) => {
|
|
4280
|
+
if (when && !when(v)) return void 0;
|
|
4281
|
+
const out = { times: (typeof times === "function" ? times(v) : times) ?? 0 };
|
|
4282
|
+
if (budget !== void 0) out.budget = typeof budget === "function" ? budget(v) : budget;
|
|
4283
|
+
return out;
|
|
4284
|
+
};
|
|
4285
|
+
return !when && typeof times !== "function" && typeof budget !== "function" ? at(void 0) : at;
|
|
4286
|
+
}
|
|
3739
4287
|
component(b, box) {
|
|
3740
4288
|
const o = {};
|
|
3741
4289
|
const name = this.constProp(b, "name") ?? this.text(b.args.label) ?? this.text(box?.args.label);
|
|
@@ -3758,16 +4306,12 @@ var Compiler = class {
|
|
|
3758
4306
|
const prop = this.prop(b, k);
|
|
3759
4307
|
if (!prop) continue;
|
|
3760
4308
|
const check = k === "capacity" ? (v) => {
|
|
3761
|
-
if (!isCapacity(v)) throw new Error("a capacity comes from measured(…) or
|
|
4309
|
+
if (!isCapacity(v)) throw new Error("a capacity comes from measured(…), estimated(…) or configured(…)");
|
|
3762
4310
|
} : void 0;
|
|
3763
4311
|
o[k] = this.dyn(prop.value, want, "ctx", check);
|
|
3764
4312
|
}
|
|
3765
4313
|
const at = box ? box.args.id ?? box.at : b.at;
|
|
3766
|
-
for (const k of [
|
|
3767
|
-
"demand",
|
|
3768
|
-
"capacity",
|
|
3769
|
-
"over"
|
|
3770
|
-
]) if (!(k in o)) this.report.error(at, `the component needs "${k}: …"`, COMPONENT_PROPS[k].doc);
|
|
4314
|
+
for (const k of ["capacity", "over"]) if (!(k in o)) this.report.error(at, `the component needs "${k}: …"`, COMPONENT_PROPS[k].doc);
|
|
3771
4315
|
return o;
|
|
3772
4316
|
}
|
|
3773
4317
|
stage(b, box, components, resolve, flows, reached) {
|
|
@@ -3785,18 +4329,46 @@ var Compiler = class {
|
|
|
3785
4329
|
};
|
|
3786
4330
|
switch (b.keyword) {
|
|
3787
4331
|
case "pass": return component ? pass(component, base) : needs();
|
|
4332
|
+
case "limit": return component ? limit(component, base) : needs();
|
|
4333
|
+
case "cache": {
|
|
4334
|
+
const hit = this.dynProp(b, "hit", "number");
|
|
4335
|
+
return cache({
|
|
4336
|
+
...base,
|
|
4337
|
+
hit: hit ?? 0,
|
|
4338
|
+
...component ? { component } : {}
|
|
4339
|
+
});
|
|
4340
|
+
}
|
|
3788
4341
|
case "store": return component ? store(component, base) : needs();
|
|
3789
4342
|
case "sink": return sink(base);
|
|
3790
4343
|
case "workers": {
|
|
3791
4344
|
if (!component) return needs();
|
|
3792
|
-
const pods = this.dynProp(b, "pods", "number", this.count("pods"));
|
|
3793
4345
|
const perPod = this.dynProp(b, "perPod", "number", this.count("perPod"));
|
|
3794
4346
|
const fill = this.constProp(b, "fill");
|
|
3795
|
-
|
|
4347
|
+
const rest = {
|
|
3796
4348
|
...base,
|
|
3797
|
-
pods: pods ?? 0,
|
|
3798
4349
|
perPod: perPod ?? 0,
|
|
3799
4350
|
...fill ? { fill } : {}
|
|
4351
|
+
};
|
|
4352
|
+
const scale = b.children.find((c) => c.keyword === "autoscale");
|
|
4353
|
+
if (scale) {
|
|
4354
|
+
if (this.prop(b, "pods")) this.report.error(this.prop(b, "pods").key, "an autoscaled pool has no fixed pods", "leave out pods");
|
|
4355
|
+
const min = this.dynProp(scale, "min", "number") ?? 1;
|
|
4356
|
+
const max = this.dynProp(scale, "max", "number", this.count("pods")) ?? 1;
|
|
4357
|
+
const target = this.dynProp(scale, "target", "number") ?? 1;
|
|
4358
|
+
return workers(component, {
|
|
4359
|
+
...rest,
|
|
4360
|
+
autoscale: {
|
|
4361
|
+
min,
|
|
4362
|
+
max,
|
|
4363
|
+
target
|
|
4364
|
+
}
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
const pods = this.dynProp(b, "pods", "number", this.count("pods"));
|
|
4368
|
+
if (!this.prop(b, "pods")) this.report.error(b.at, "a workers stage needs pods, or autoscale", "pods: 3, or autoscale { min: 2; max: 10; target: 0.7 }");
|
|
4369
|
+
return workers(component, {
|
|
4370
|
+
...rest,
|
|
4371
|
+
pods: pods ?? 0
|
|
3800
4372
|
});
|
|
3801
4373
|
}
|
|
3802
4374
|
case "queue": {
|
|
@@ -3946,6 +4518,200 @@ function where(keyword) {
|
|
|
3946
4518
|
const parents = Object.entries(SCHEMA).filter(([, s]) => s.children.includes(keyword)).map(([k]) => k === "" ? "the top level" : k);
|
|
3947
4519
|
return parents.length ? `it goes in ${parents.join(" or ")}` : void 0;
|
|
3948
4520
|
}
|
|
4521
|
+
// ../dsl/src/ast.ts
|
|
4522
|
+
/** The formulas directly inside a formula, in order; a template's parts only when asked (they are its own text). */
|
|
4523
|
+
function childrenOf(e, templates = false) {
|
|
4524
|
+
switch (e.t) {
|
|
4525
|
+
case "unary": return [e.arg];
|
|
4526
|
+
case "pm": return [e.value, e.within];
|
|
4527
|
+
case "cond": return [
|
|
4528
|
+
e.test,
|
|
4529
|
+
e.then,
|
|
4530
|
+
e.else
|
|
4531
|
+
];
|
|
4532
|
+
case "bin": return [e.left, e.right];
|
|
4533
|
+
case "call": return e.args;
|
|
4534
|
+
case "array": return e.items;
|
|
4535
|
+
case "tpl": return templates ? e.parts : [];
|
|
4536
|
+
default: return [];
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
/**
|
|
4540
|
+
* Lays a definition out the one way: two spaces a block, a statement a line, at most one blank line, one space
|
|
4541
|
+
* around operators. It works on the tokens, so numbers, texts, templates and parentheses stay exactly as written and
|
|
4542
|
+
* a formula never changes. A block written on one line stays on one line while it fits. A text with a syntax error is
|
|
4543
|
+
* left alone.
|
|
4544
|
+
*/
|
|
4545
|
+
function format(source) {
|
|
4546
|
+
const { doc, report } = parse$2(source);
|
|
4547
|
+
if (report.failed) return {
|
|
4548
|
+
ok: false,
|
|
4549
|
+
text: source,
|
|
4550
|
+
diagnostics: report.list
|
|
4551
|
+
};
|
|
4552
|
+
const toks = lex(source, new Reporter(source), 0, source.length, true).filter((t) => t.kind !== "eof");
|
|
4553
|
+
const marks = marksOf(doc.body, toks);
|
|
4554
|
+
const lines = new Reporter(source);
|
|
4555
|
+
const match = /* @__PURE__ */ new Map();
|
|
4556
|
+
const open = [];
|
|
4557
|
+
for (let i = 0; i < toks.length; i++) if (isPunct(toks[i], "{")) open.push(i);
|
|
4558
|
+
else if (isPunct(toks[i], "}")) match.set(open.pop(), i);
|
|
4559
|
+
const empty = /* @__PURE__ */ new Set();
|
|
4560
|
+
const written = /* @__PURE__ */ new Set();
|
|
4561
|
+
for (const [i, j] of match) if (toks.slice(i + 1, j).every((t) => t.kind === "newline")) empty.add(i);
|
|
4562
|
+
else if (lines.position(toks[i].from).line !== lines.position(toks[j].from).line) written.add(i);
|
|
4563
|
+
const index = new Map(toks.map((t, i) => [t, i]));
|
|
4564
|
+
const split = /* @__PURE__ */ new Set();
|
|
4565
|
+
const oneLine = (t) => {
|
|
4566
|
+
const i = index.get(t);
|
|
4567
|
+
return isPunct(t, "{") && !empty.has(i) && !written.has(i) && !split.has(i);
|
|
4568
|
+
};
|
|
4569
|
+
for (;;) {
|
|
4570
|
+
const out = layout(toks, match, (i) => split.has(i) || written.has(i), empty);
|
|
4571
|
+
const text = out.map((l) => l.toks.length ? " ".repeat(l.depth) + join$1(l.toks, marks) : "");
|
|
4572
|
+
const long = out.find((l, n) => [...text[n]].length > 120 && l.toks.some(oneLine));
|
|
4573
|
+
if (!long) return {
|
|
4574
|
+
ok: true,
|
|
4575
|
+
text: text.length ? `${text.join("\n")}\n` : "",
|
|
4576
|
+
diagnostics: []
|
|
4577
|
+
};
|
|
4578
|
+
split.add(index.get(long.toks.find(oneLine)));
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
/** The tokens in lines with their depth. `multi` tells a block laid out a statement a line. */
|
|
4582
|
+
function layout(toks, match, multi, empty) {
|
|
4583
|
+
const out = [];
|
|
4584
|
+
let cur = [];
|
|
4585
|
+
let depth = 0;
|
|
4586
|
+
let lineDepth = 0;
|
|
4587
|
+
let newlines = 0;
|
|
4588
|
+
let start = true;
|
|
4589
|
+
const blocks = [true];
|
|
4590
|
+
const end = () => {
|
|
4591
|
+
if (cur.length) out.push({
|
|
4592
|
+
depth: lineDepth,
|
|
4593
|
+
toks: cur
|
|
4594
|
+
});
|
|
4595
|
+
cur = [];
|
|
4596
|
+
};
|
|
4597
|
+
const add = (t) => {
|
|
4598
|
+
if (!cur.length) {
|
|
4599
|
+
if (newlines > 1 && !start && !isPunct(t, "}")) out.push({
|
|
4600
|
+
depth: 0,
|
|
4601
|
+
toks: []
|
|
4602
|
+
});
|
|
4603
|
+
lineDepth = depth - (isPunct(t, "}") || isPunct(t, ")") || isPunct(t, "]") ? 1 : 0);
|
|
4604
|
+
}
|
|
4605
|
+
newlines = 0;
|
|
4606
|
+
start = false;
|
|
4607
|
+
cur.push(t);
|
|
4608
|
+
};
|
|
4609
|
+
for (let i = 0; i < toks.length; i++) {
|
|
4610
|
+
const t = toks[i];
|
|
4611
|
+
if (t.kind === "newline") {
|
|
4612
|
+
end();
|
|
4613
|
+
newlines++;
|
|
4614
|
+
} else if (isPunct(t, ";")) {
|
|
4615
|
+
if (blocks.at(-1)) {
|
|
4616
|
+
if (toks[i + 1]?.kind !== "comment") end();
|
|
4617
|
+
} else {
|
|
4618
|
+
const next = toks[i + 1];
|
|
4619
|
+
const last = cur.at(-1);
|
|
4620
|
+
if (!(!next || next.kind === "newline" || next.kind === "comment" || isPunct(next, "}") || !last || isPunct(last, "{") || isPunct(last, ";"))) add(t);
|
|
4621
|
+
}
|
|
4622
|
+
} else if (isPunct(t, "{")) {
|
|
4623
|
+
if (empty.has(i)) {
|
|
4624
|
+
add(t);
|
|
4625
|
+
i = match.get(i);
|
|
4626
|
+
add(toks[i]);
|
|
4627
|
+
continue;
|
|
4628
|
+
}
|
|
4629
|
+
const m = multi(i);
|
|
4630
|
+
add(t);
|
|
4631
|
+
depth++;
|
|
4632
|
+
blocks.push(m);
|
|
4633
|
+
if (m) {
|
|
4634
|
+
if (toks[i + 1]?.kind === "comment") add(toks[++i]);
|
|
4635
|
+
end();
|
|
4636
|
+
start = true;
|
|
4637
|
+
}
|
|
4638
|
+
} else if (isPunct(t, "}")) {
|
|
4639
|
+
if (blocks.pop()) end();
|
|
4640
|
+
add(t);
|
|
4641
|
+
depth--;
|
|
4642
|
+
} else if (isPunct(t, "(") || isPunct(t, "[")) {
|
|
4643
|
+
add(t);
|
|
4644
|
+
depth++;
|
|
4645
|
+
} else if (isPunct(t, ")") || isPunct(t, "]")) {
|
|
4646
|
+
add(t);
|
|
4647
|
+
depth--;
|
|
4648
|
+
} else add(t);
|
|
4649
|
+
}
|
|
4650
|
+
end();
|
|
4651
|
+
return out;
|
|
4652
|
+
}
|
|
4653
|
+
function marksOf(body, toks) {
|
|
4654
|
+
const m = {
|
|
4655
|
+
unary: /* @__PURE__ */ new Set(),
|
|
4656
|
+
percent: /* @__PURE__ */ new Set(),
|
|
4657
|
+
ternary: /* @__PURE__ */ new Set()
|
|
4658
|
+
};
|
|
4659
|
+
const after = (at) => {
|
|
4660
|
+
let lo = 0;
|
|
4661
|
+
let hi = toks.length;
|
|
4662
|
+
while (lo < hi) {
|
|
4663
|
+
const mid = lo + hi >> 1;
|
|
4664
|
+
if (toks[mid].from < at) lo = mid + 1;
|
|
4665
|
+
else hi = mid;
|
|
4666
|
+
}
|
|
4667
|
+
return lo;
|
|
4668
|
+
};
|
|
4669
|
+
const find = (from, text) => {
|
|
4670
|
+
for (let i = after(from); i < toks.length; i++) if (isPunct(toks[i], text)) return toks[i].from;
|
|
4671
|
+
return -1;
|
|
4672
|
+
};
|
|
4673
|
+
const expr = (e) => {
|
|
4674
|
+
if (!e) return;
|
|
4675
|
+
if (e.t === "unary") {
|
|
4676
|
+
let at = -1;
|
|
4677
|
+
for (let i = after(e.from); i < toks.length && toks[i].from < e.arg.from; i++) if (isPunct(toks[i], e.op)) at = toks[i].from;
|
|
4678
|
+
m.unary.add(at);
|
|
4679
|
+
} else if (e.t === "pm" && e.percent) m.percent.add(find(e.within.to, "%"));
|
|
4680
|
+
else if (e.t === "cond") m.ternary.add(find(e.then.to, ":"));
|
|
4681
|
+
for (const k of childrenOf(e)) expr(k);
|
|
4682
|
+
};
|
|
4683
|
+
const stmt = (s) => {
|
|
4684
|
+
if (s.kind === "row") s.values.forEach(expr);
|
|
4685
|
+
else if (s.kind === "prop") expr(s.value);
|
|
4686
|
+
else {
|
|
4687
|
+
if (s.kind === "decl") {
|
|
4688
|
+
s.args.forEach(expr);
|
|
4689
|
+
expr(s.init);
|
|
4690
|
+
} else expr(s.value);
|
|
4691
|
+
s.body?.forEach(stmt);
|
|
4692
|
+
}
|
|
4693
|
+
};
|
|
4694
|
+
body.forEach(stmt);
|
|
4695
|
+
return m;
|
|
4696
|
+
}
|
|
4697
|
+
function join$1(toks, m) {
|
|
4698
|
+
let s = toks[0].text;
|
|
4699
|
+
for (let i = 1; i < toks.length; i++) s += gap(toks[i - 1], toks[i], m) + toks[i].text;
|
|
4700
|
+
return s;
|
|
4701
|
+
}
|
|
4702
|
+
function gap(a, b, m) {
|
|
4703
|
+
const A = a.kind === "punct" ? a.text : "";
|
|
4704
|
+
const B = b.kind === "punct" ? b.text : "";
|
|
4705
|
+
if (A === "{" && B === "}") return "";
|
|
4706
|
+
if (A === "(" || A === "[" || A === ".") return "";
|
|
4707
|
+
if ((A === "-" || A === "+" || A === "!") && m.unary.has(a.from)) return "";
|
|
4708
|
+
if (B === ")" || B === "]" || B === "," || B === ";" || B === ".") return "";
|
|
4709
|
+
if (B === "%" && m.percent.has(b.from)) return "";
|
|
4710
|
+
if (B === ":" && !m.ternary.has(b.from)) return "";
|
|
4711
|
+
if (B === "(" && a.kind === "ident") return "";
|
|
4712
|
+
return " ";
|
|
4713
|
+
}
|
|
4714
|
+
const isPunct = (t, text) => t.kind === "punct" && t.text === text;
|
|
3949
4715
|
// src/check.ts
|
|
3950
4716
|
/**
|
|
3951
4717
|
* `loadmap check`: compiles each definition and tests its expect lines. Resolves to the exit code: 1 when a
|
|
@@ -3955,7 +4721,7 @@ async function check(argv, o = {}) {
|
|
|
3955
4721
|
const log = o.log ?? console.log;
|
|
3956
4722
|
let parsed;
|
|
3957
4723
|
try {
|
|
3958
|
-
parsed = parse(argv, o.env ?? process.env);
|
|
4724
|
+
parsed = parse$1(argv, o.env ?? process.env);
|
|
3959
4725
|
} catch (err) {
|
|
3960
4726
|
log(err.message);
|
|
3961
4727
|
return 2;
|
|
@@ -3984,7 +4750,7 @@ async function check(argv, o = {}) {
|
|
|
3984
4750
|
}
|
|
3985
4751
|
return failed ? 1 : 0;
|
|
3986
4752
|
}
|
|
3987
|
-
function parse(argv, env) {
|
|
4753
|
+
function parse$1(argv, env) {
|
|
3988
4754
|
const { values, positionals } = parseArgs({
|
|
3989
4755
|
args: argv,
|
|
3990
4756
|
allowPositionals: true,
|
|
@@ -4019,8 +4785,90 @@ function github(d, file, source) {
|
|
|
4019
4785
|
}
|
|
4020
4786
|
const data = (s) => s.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
4021
4787
|
const prop = (s) => data(s).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
4788
|
+
// src/fmt.ts
|
|
4789
|
+
/**
|
|
4790
|
+
* `loadmap fmt`: lays each definition out the one way (`format()` of @loadmap/dsl), in place. With `--check` it only
|
|
4791
|
+
* names the files that are not laid out so. `-` reads standard input and writes standard output. Resolves to 1 when a
|
|
4792
|
+
* file has a syntax error, cannot be read, or (with --check) needs formatting; 2 for a usage mistake.
|
|
4793
|
+
*/
|
|
4794
|
+
async function fmt(argv, o = {}) {
|
|
4795
|
+
const log = o.log ?? console.log;
|
|
4796
|
+
let parsed;
|
|
4797
|
+
try {
|
|
4798
|
+
parsed = parse(argv);
|
|
4799
|
+
} catch (err) {
|
|
4800
|
+
log(err.message);
|
|
4801
|
+
return 2;
|
|
4802
|
+
}
|
|
4803
|
+
if (parsed.stdin) {
|
|
4804
|
+
const source = await (o.read ?? readStdin)();
|
|
4805
|
+
const r = format(source);
|
|
4806
|
+
if (!r.ok) for (const d of r.diagnostics) log(formatDiagnostic(d, source, "<stdin>"));
|
|
4807
|
+
else (o.write ?? ((s) => process.stdout.write(s)))(r.text);
|
|
4808
|
+
return r.ok ? 0 : 1;
|
|
4809
|
+
}
|
|
4810
|
+
let failed = false;
|
|
4811
|
+
let changed = 0;
|
|
4812
|
+
let skipped = 0;
|
|
4813
|
+
for (const file of parsed.files) {
|
|
4814
|
+
let source;
|
|
4815
|
+
try {
|
|
4816
|
+
source = await readFile(file, "utf8");
|
|
4817
|
+
} catch (err) {
|
|
4818
|
+
log(`${file}: cannot read it (${err.code ?? err.message})`);
|
|
4819
|
+
failed = true;
|
|
4820
|
+
skipped++;
|
|
4821
|
+
continue;
|
|
4822
|
+
}
|
|
4823
|
+
const r = format(source);
|
|
4824
|
+
if (!r.ok) {
|
|
4825
|
+
for (const d of r.diagnostics) log(formatDiagnostic(d, source, file));
|
|
4826
|
+
log(`${file}: left as it is, it has a syntax error`);
|
|
4827
|
+
failed = true;
|
|
4828
|
+
skipped++;
|
|
4829
|
+
} else if (r.text !== source) {
|
|
4830
|
+
changed++;
|
|
4831
|
+
if (parsed.check) {
|
|
4832
|
+
log(`${file}: not laid out by loadmap fmt`);
|
|
4833
|
+
failed = true;
|
|
4834
|
+
} else {
|
|
4835
|
+
await writeFile(file, r.text);
|
|
4836
|
+
log(`${file}: formatted`);
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
const n = parsed.files.length;
|
|
4841
|
+
const files = (k) => `${k} ${k === 1 ? "file" : "files"}`;
|
|
4842
|
+
const left = skipped ? `; ${skipped} left out (unreadable or a syntax error)` : "";
|
|
4843
|
+
let summary = `${changed} of ${files(n)} formatted`;
|
|
4844
|
+
if (parsed.check && changed) summary = `${changed} of ${files(n)} need loadmap fmt`;
|
|
4845
|
+
else if (parsed.check) summary = skipped ? `${n - skipped} of ${files(n)} laid out already` : `${files(n)} laid out already`;
|
|
4846
|
+
log(summary + left);
|
|
4847
|
+
return failed ? 1 : 0;
|
|
4848
|
+
}
|
|
4849
|
+
function parse(argv) {
|
|
4850
|
+
const { values, positionals } = parseArgs({
|
|
4851
|
+
args: argv,
|
|
4852
|
+
allowPositionals: true,
|
|
4853
|
+
options: { check: { type: "boolean" } }
|
|
4854
|
+
});
|
|
4855
|
+
if (!positionals.length) throw new Error("loadmap fmt needs one or more .loadmap files, or - for standard input");
|
|
4856
|
+
const stdin = positionals.includes("-");
|
|
4857
|
+
if (stdin && (positionals.length > 1 || values.check)) throw new Error("loadmap fmt - reads standard input alone, without files or --check");
|
|
4858
|
+
return {
|
|
4859
|
+
files: positionals,
|
|
4860
|
+
check: !!values.check,
|
|
4861
|
+
stdin
|
|
4862
|
+
};
|
|
4863
|
+
}
|
|
4864
|
+
async function readStdin() {
|
|
4865
|
+
const chunks = [];
|
|
4866
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
4867
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
4868
|
+
}
|
|
4022
4869
|
// src/index.ts
|
|
4023
4870
|
const USAGE = `loadmap check <file.loadmap>… [--format text|github]
|
|
4871
|
+
loadmap fmt <file.loadmap>… [--check] | loadmap fmt -
|
|
4024
4872
|
loadmap record <url | file.loadmap> --out <file.gif|.webm|.mp4|.png|.svg> [options]
|
|
4025
4873
|
|
|
4026
4874
|
check compiles each definition and tests its expect lines on the pessimistic edge (every capacity at its low
|
|
@@ -4028,6 +4876,12 @@ bound). It exits 1 when a definition has an error or an expectation fails, 0 whe
|
|
|
4028
4876
|
|
|
4029
4877
|
--format <f> text, or github to add annotations on the lines (the default under GitHub Actions)
|
|
4030
4878
|
|
|
4879
|
+
fmt lays each definition out the one way, in place: two spaces a block, a statement a line, one space around
|
|
4880
|
+
operators; numbers, texts and formulas stay as written. A file with a syntax error is left alone (exit 1).
|
|
4881
|
+
|
|
4882
|
+
--check change nothing; exit 1 when a file is not laid out so (for CI)
|
|
4883
|
+
- read standard input, write standard output
|
|
4884
|
+
|
|
4031
4885
|
record opens the page in headless Chromium, finds the loadmap it exposes (expose() from @loadmap/export) and
|
|
4032
4886
|
records it on a fixed clock, so the same options give the same file. A .loadmap file is drawn by the viewer that
|
|
4033
4887
|
comes with the command.
|
|
@@ -4051,6 +4905,7 @@ comes with the command.
|
|
|
4051
4905
|
async function main(argv, log = console.log) {
|
|
4052
4906
|
const [command, ...rest] = argv;
|
|
4053
4907
|
if (command === "check") return check(rest, { log });
|
|
4908
|
+
if (command === "fmt") return fmt(rest, { log });
|
|
4054
4909
|
if (command !== "record") {
|
|
4055
4910
|
log(USAGE);
|
|
4056
4911
|
return command === "--help" || command === "-h" ? 0 : 2;
|
|
@@ -4254,4 +5109,4 @@ async function recordPage(a, format) {
|
|
|
4254
5109
|
await browser.close();
|
|
4255
5110
|
}
|
|
4256
5111
|
}
|
|
4257
|
-
export {
|
|
5112
|
+
export { fmt as a, record as i, main as n, check as o, parseRecord as r, github as s, USAGE as t };
|