@loadmap/cli 0.1.1 → 0.2.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,
@@ -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,9 +1035,9 @@ 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) {
@@ -1048,12 +1395,12 @@ const COMPONENT_PROPS = {
1048
1395
  demand: {
1049
1396
  value: "number",
1050
1397
  scope: "ctx",
1051
- doc: "What the component must handle, in its capacity's unit."
1398
+ doc: "What the component must handle, in its capacity's unit. Without one: the units per second reaching its stages."
1052
1399
  },
1053
1400
  capacity: {
1054
1401
  value: "capacity",
1055
1402
  scope: "ctx",
1056
- doc: "What it can handle: measured(value, unit, source) or estimated(value, unit, source); `value ± 30%` says how sure it is."
1403
+ 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
1404
  },
1058
1405
  when: {
1059
1406
  value: "boolean",
@@ -1492,7 +1839,11 @@ const SCHEMA = {
1492
1839
  doc: "A theme colour (accent, alt, ok, warn, drop, muted) or a CSS colour in quotes."
1493
1840
  }
1494
1841
  },
1495
- children: ["drop", "merge"]
1842
+ children: [
1843
+ "drop",
1844
+ "merge",
1845
+ "retry"
1846
+ ]
1496
1847
  },
1497
1848
  drop: {
1498
1849
  doc: "Units older than olderThan simulated seconds are dropped before they are handled.",
@@ -1534,6 +1885,30 @@ const SCHEMA = {
1534
1885
  },
1535
1886
  children: []
1536
1887
  },
1888
+ retry: {
1889
+ 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.",
1890
+ example: "retry { times: 3; budget: 0.1 }",
1891
+ args: [],
1892
+ props: {
1893
+ times: {
1894
+ value: "number",
1895
+ scope: "values",
1896
+ doc: "Retries after the first attempt: a whole number. Reads only the parameters.",
1897
+ required: true
1898
+ },
1899
+ budget: {
1900
+ value: "number",
1901
+ scope: "values",
1902
+ doc: "At most this share of the flow is added as retries: 0.1 adds at most 10 %. Without one, every retry is sent."
1903
+ },
1904
+ when: {
1905
+ value: "boolean",
1906
+ scope: "values",
1907
+ doc: "The rule applies only while this is true; it reads only parameters."
1908
+ }
1909
+ },
1910
+ children: []
1911
+ },
1537
1912
  component: {
1538
1913
  doc: "Something that can run out: its demand against its capacity. On a box, write these lines on the box.",
1539
1914
  example: "component cache \"Cache\" { demand: flows.reads; capacity: estimated(9000, \"req/s\") }",
@@ -1568,6 +1943,8 @@ const SCHEMA = {
1568
1943
  "store",
1569
1944
  "sink",
1570
1945
  "fanout",
1946
+ "cache",
1947
+ "limit",
1571
1948
  "component"
1572
1949
  ]
1573
1950
  },
@@ -1614,6 +1991,28 @@ const SCHEMA = {
1614
1991
  props: { component: STAGE_COMPONENT },
1615
1992
  children: []
1616
1993
  },
1994
+ cache: {
1995
+ 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.",
1996
+ example: "cache { hit: 0.8 }",
1997
+ args: STAGE_LABEL,
1998
+ props: {
1999
+ hit: {
2000
+ value: "number",
2001
+ scope: "ctx",
2002
+ doc: "The share answered here, 0 to 1.",
2003
+ required: true
2004
+ },
2005
+ component: STAGE_COMPONENT
2006
+ },
2007
+ children: []
2008
+ },
2009
+ limit: {
2010
+ 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.",
2011
+ example: "limit",
2012
+ args: STAGE_LABEL,
2013
+ props: { component: STAGE_COMPONENT },
2014
+ children: []
2015
+ },
1617
2016
  store: {
1618
2017
  doc: "Where a flow ends: written to its component.",
1619
2018
  example: "store",
@@ -1658,8 +2057,7 @@ const SCHEMA = {
1658
2057
  pods: {
1659
2058
  value: "number",
1660
2059
  scope: "ctx",
1661
- doc: "How many pods.",
1662
- required: true
2060
+ doc: "How many pods; an autoscaled pool has autoscale instead."
1663
2061
  },
1664
2062
  perPod: {
1665
2063
  value: "number",
@@ -1675,6 +2073,32 @@ const SCHEMA = {
1675
2073
  },
1676
2074
  component: STAGE_COMPONENT
1677
2075
  },
2076
+ children: ["autoscale"]
2077
+ },
2078
+ autoscale: {
2079
+ 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.",
2080
+ example: "autoscale { min: 2; max: 10; target: 0.7 }",
2081
+ args: [],
2082
+ props: {
2083
+ min: {
2084
+ value: "number",
2085
+ scope: "ctx",
2086
+ doc: "The fewest pods, at least 1.",
2087
+ required: true
2088
+ },
2089
+ max: {
2090
+ value: "number",
2091
+ scope: "ctx",
2092
+ doc: "The most pods.",
2093
+ required: true
2094
+ },
2095
+ target: {
2096
+ value: "number",
2097
+ scope: "ctx",
2098
+ doc: "The utilization each running pod is held at, above 0 and at most 1: 0.7 scales out above 70 %.",
2099
+ required: true
2100
+ }
2101
+ },
1678
2102
  children: []
1679
2103
  },
1680
2104
  fanout: {
@@ -1728,7 +2152,9 @@ const STAGES = [
1728
2152
  "sink",
1729
2153
  "queue",
1730
2154
  "workers",
1731
- "fanout"
2155
+ "fanout",
2156
+ "cache",
2157
+ "limit"
1732
2158
  ];
1733
2159
  // ../dsl/src/expr.ts
1734
2160
  /** A formula failed while the model ran: where, and why. */
@@ -1836,11 +2262,22 @@ const BUILTINS = {
1836
2262
  min: 2,
1837
2263
  result: "capacity",
1838
2264
  fn: estimated
2265
+ },
2266
+ configured: {
2267
+ args: [
2268
+ "number",
2269
+ "string",
2270
+ "string"
2271
+ ],
2272
+ min: 2,
2273
+ result: "capacity",
2274
+ fn: configured
1839
2275
  }
1840
2276
  };
1841
- /** The tables a formula may read with a ".": flows.orders, demand.db, util.db, derived.x. */
2277
+ /** The tables a formula may read with a ".": flows.orders, at.db.orders, demand.db, util.db, derived.x. */
1842
2278
  const TABLES = [
1843
2279
  "flows",
2280
+ "at",
1844
2281
  "demand",
1845
2282
  "util",
1846
2283
  "derived"
@@ -2005,14 +2442,15 @@ var Compiler$1 = class {
2005
2442
  return bad;
2006
2443
  }
2007
2444
  example(table) {
2008
- return [...table === "flows" ? this.names.flows : table === "derived" ? this.names.lets.keys() : this.names.components][0] ?? "x";
2445
+ return [...table === "flows" ? this.names.flows : table === "at" ? this.names.stages : table === "derived" ? this.names.lets.keys() : this.names.components][0] ?? "x";
2009
2446
  }
2010
2447
  member(e) {
2011
2448
  const table = e.object.name;
2012
2449
  const key = e.prop.name;
2450
+ if (table === "at") return this.at(e);
2013
2451
  const pool = table === "flows" ? this.names.flows : table === "demand" || table === "util" ? this.names.components : table === "derived" ? this.names.lets.keys() : void 0;
2014
2452
  if (!pool) {
2015
- this.report.error(e.object, `"${table}" has no entries: only flows., demand., util. and derived. do`);
2453
+ this.report.error(e.object, `"${table}" has no entries: only flows., at., demand., util. and derived. do`);
2016
2454
  return bad;
2017
2455
  }
2018
2456
  if (!(table === "derived" ? this.names.lets.has(key) : pool.has(key))) {
@@ -2027,6 +2465,26 @@ var Compiler$1 = class {
2027
2465
  run: (c) => c[table][key]
2028
2466
  };
2029
2467
  }
2468
+ /** at.db: every flow reaching the stage, per second; at.db.orders: one flow. */
2469
+ at(e) {
2470
+ const stage = e.prop.name;
2471
+ if (!this.names.stages.has(stage)) {
2472
+ this.report.error(e.prop, `unknown stage "${stage}"`, didYouMean(stage, this.names.stages));
2473
+ return bad;
2474
+ }
2475
+ const flow = e.flow?.name;
2476
+ if (flow != null && !this.names.flows.has(flow)) {
2477
+ this.report.error(e.flow, `unknown flow "${flow}"`, didYouMean(flow, this.names.flows));
2478
+ return bad;
2479
+ }
2480
+ if (!this.canRead(e, flow == null ? `at.${stage}` : `at.${stage}.${flow}`)) return bad;
2481
+ const flows = [...this.names.flows];
2482
+ return {
2483
+ type: "number",
2484
+ constant: false,
2485
+ run: flow == null ? (c) => flows.reduce((a, f) => a + c.at[stage][f], 0) : (c) => c.at[stage][flow]
2486
+ };
2487
+ }
2030
2488
  call(e) {
2031
2489
  const name = e.callee.name;
2032
2490
  const pm = e.args[0]?.t === "pm" ? e.args[0] : null;
@@ -2771,6 +3229,19 @@ var Parser = class {
2771
3229
  this.next();
2772
3230
  const prop = this.ident("a name after \".\"");
2773
3231
  if (!this.is("(")) {
3232
+ if (callee.name === "at" && this.is(".")) {
3233
+ this.next();
3234
+ const flow = this.ident("a flow after \"at.<stage>.\"");
3235
+ if (this.is(".")) this.fail(this.peek(), "at takes two names: at.db.orders");
3236
+ return {
3237
+ t: "member",
3238
+ object: callee,
3239
+ prop,
3240
+ flow,
3241
+ from: t.from,
3242
+ to: flow.to
3243
+ };
3244
+ }
2774
3245
  if (this.is(".")) this.fail(this.peek(), "only one \".\" is allowed: flows.orders, util.db");
2775
3246
  return {
2776
3247
  t: "member",
@@ -2989,6 +3460,7 @@ var Compiler = class {
2989
3460
  params: paramTypes,
2990
3461
  lets: this.lets,
2991
3462
  flows: new Set(flowNames.keys()),
3463
+ stages: new Set(stageNames.keys()),
2992
3464
  components: new Set(componentNames.keys()),
2993
3465
  fits: this.fits,
2994
3466
  functions: this.functions
@@ -3074,6 +3546,8 @@ var Compiler = class {
3074
3546
  if (priority != null) def.priority = priority;
3075
3547
  const overload = this.overload(b);
3076
3548
  if (overload) def.overload = overload;
3549
+ const retry = this.retry(b);
3550
+ if (retry) def.retry = retry;
3077
3551
  flowDefs[id.name] = def;
3078
3552
  const route = this.prop(b, "route");
3079
3553
  const stages = [];
@@ -3109,6 +3583,8 @@ var Compiler = class {
3109
3583
  if (def) stageDefs[s.id] = def;
3110
3584
  }
3111
3585
  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}"`);
3586
+ const used = new Set(Object.values(stageDefs).map((d) => d.component));
3587
+ 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
3588
  const calibration = {};
3113
3589
  for (const f of fits) {
3114
3590
  const fit = f.args.id && this.fits.get(f.args.id.name);
@@ -3706,13 +4182,16 @@ var Compiler = class {
3706
4182
  if (!rules.length) return void 0;
3707
4183
  const seen = /* @__PURE__ */ new Set();
3708
4184
  const parts = [];
4185
+ const props = {
4186
+ drop: ["olderThan", "reason"],
4187
+ merge: ["share", "reason"]
4188
+ };
3709
4189
  for (const r of rules) {
3710
4190
  const key = r.keyword;
3711
4191
  if (seen.has(key)) this.report.error(r.at, `"${key}" is set twice in this flow`);
3712
4192
  seen.add(key);
3713
4193
  const fields = [];
3714
- const first = key === "drop" ? "olderThan" : "share";
3715
- for (const k of [first, "reason"]) {
4194
+ for (const k of props[key]) {
3716
4195
  const v = this.dynProp(r, k, k === "reason" ? "string" : "number");
3717
4196
  if (v !== void 0) fields.push([k, v]);
3718
4197
  }
@@ -3736,6 +4215,24 @@ var Compiler = class {
3736
4215
  };
3737
4216
  return fixed ? at(void 0) : at;
3738
4217
  }
4218
+ /** A flow's retry rule: it reads only the load and the parameters, since retries change every other number. */
4219
+ retry(b) {
4220
+ const r = b.children.filter((c) => c.keyword === "retry");
4221
+ if (!r.length) return void 0;
4222
+ if (r.length > 1) this.report.error(r[1].at, "\"retry\" is set twice in this flow");
4223
+ const rule = r[0];
4224
+ const times = this.dynProp(rule, "times", "number");
4225
+ const budget = this.dynProp(rule, "budget", "number");
4226
+ const w = this.prop(rule, "when");
4227
+ const when = w && placed(w.value, this.formula(w.value, "values").run);
4228
+ const at = (v) => {
4229
+ if (when && !when(v)) return void 0;
4230
+ const out = { times: (typeof times === "function" ? times(v) : times) ?? 0 };
4231
+ if (budget !== void 0) out.budget = typeof budget === "function" ? budget(v) : budget;
4232
+ return out;
4233
+ };
4234
+ return !when && typeof times !== "function" && typeof budget !== "function" ? at(void 0) : at;
4235
+ }
3739
4236
  component(b, box) {
3740
4237
  const o = {};
3741
4238
  const name = this.constProp(b, "name") ?? this.text(b.args.label) ?? this.text(box?.args.label);
@@ -3758,16 +4255,12 @@ var Compiler = class {
3758
4255
  const prop = this.prop(b, k);
3759
4256
  if (!prop) continue;
3760
4257
  const check = k === "capacity" ? (v) => {
3761
- if (!isCapacity(v)) throw new Error("a capacity comes from measured(…) or estimated(…)");
4258
+ if (!isCapacity(v)) throw new Error("a capacity comes from measured(…), estimated(…) or configured(…)");
3762
4259
  } : void 0;
3763
4260
  o[k] = this.dyn(prop.value, want, "ctx", check);
3764
4261
  }
3765
4262
  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);
4263
+ for (const k of ["capacity", "over"]) if (!(k in o)) this.report.error(at, `the component needs "${k}: …"`, COMPONENT_PROPS[k].doc);
3771
4264
  return o;
3772
4265
  }
3773
4266
  stage(b, box, components, resolve, flows, reached) {
@@ -3785,18 +4278,46 @@ var Compiler = class {
3785
4278
  };
3786
4279
  switch (b.keyword) {
3787
4280
  case "pass": return component ? pass(component, base) : needs();
4281
+ case "limit": return component ? limit(component, base) : needs();
4282
+ case "cache": {
4283
+ const hit = this.dynProp(b, "hit", "number");
4284
+ return cache({
4285
+ ...base,
4286
+ hit: hit ?? 0,
4287
+ ...component ? { component } : {}
4288
+ });
4289
+ }
3788
4290
  case "store": return component ? store(component, base) : needs();
3789
4291
  case "sink": return sink(base);
3790
4292
  case "workers": {
3791
4293
  if (!component) return needs();
3792
- const pods = this.dynProp(b, "pods", "number", this.count("pods"));
3793
4294
  const perPod = this.dynProp(b, "perPod", "number", this.count("perPod"));
3794
4295
  const fill = this.constProp(b, "fill");
3795
- return workers(component, {
4296
+ const rest = {
3796
4297
  ...base,
3797
- pods: pods ?? 0,
3798
4298
  perPod: perPod ?? 0,
3799
4299
  ...fill ? { fill } : {}
4300
+ };
4301
+ const scale = b.children.find((c) => c.keyword === "autoscale");
4302
+ if (scale) {
4303
+ if (this.prop(b, "pods")) this.report.error(this.prop(b, "pods").key, "an autoscaled pool has no fixed pods", "leave out pods");
4304
+ const min = this.dynProp(scale, "min", "number") ?? 1;
4305
+ const max = this.dynProp(scale, "max", "number", this.count("pods")) ?? 1;
4306
+ const target = this.dynProp(scale, "target", "number") ?? 1;
4307
+ return workers(component, {
4308
+ ...rest,
4309
+ autoscale: {
4310
+ min,
4311
+ max,
4312
+ target
4313
+ }
4314
+ });
4315
+ }
4316
+ const pods = this.dynProp(b, "pods", "number", this.count("pods"));
4317
+ 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 }");
4318
+ return workers(component, {
4319
+ ...rest,
4320
+ pods: pods ?? 0
3800
4321
  });
3801
4322
  }
3802
4323
  case "queue": {