@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.
@@ -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": return {
246
- id,
247
- label,
248
- kind: "workers",
249
- component: s.component,
250
- pods: dyn(s.pods, ctx),
251
- perPod: dyn(s.perPod, ctx),
252
- fill: s.fill
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) => utilAt(values, bound)(load)[c];
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) stages[s] = resolveStage(s, def.stages[s], ctx, flowIds);
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
- const o = def.flows[f].overload;
536
- overload[f] = (typeof o === "function" ? o(ctx) : o) ?? {};
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
- ctx.flows = table(flowIds, (f) => memo(`flow ${f}`, () => def.flows[f].rate(ctx)));
641
- ctx.demand = table(componentIds, (c) => memo(`demand of ${c}`, () => def.components[c].demand(ctx)));
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 === "measured" && !c.within) return "";
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); `value ± 30%` says how sure it is."
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: ["drop", "merge"]
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 === "#") while (i < to && source[i] !== "\n") i++;
2201
- else if (isIdStart(c)) {
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$1(source, report = new Reporter(source)) {
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$1(source, report).doc;
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 slots = [...b.spec.args];
3276
- for (const a of d.args) {
3277
- const kind = a.t === "id" ? "id" : a.t === "str" || a.t === "tpl" ? "text" : a.t === "num" ? "number" : null;
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 first = key === "drop" ? "olderThan" : "share";
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 estimated(…)");
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
- return workers(component, {
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 { check as a, record as i, main as n, github as o, parseRecord as r, USAGE as t };
5112
+ export { fmt as a, record as i, main as n, check as o, parseRecord as r, github as s, USAGE as t };