@loadmap/sim 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -2
- package/dist/index.d.ts +100 -0
- package/dist/index.js +441 -0
- package/package.json +34 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaime Rojas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loadmap/sim
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The fluid queue simulation behind [loadmap](https://jaim3r0jas.github.io/loadmap/)'s moving dots: priorities, drop
|
|
4
|
+
rules, workers filling up, seeded randomness so a recording is the same every time. It never has capacity numbers of
|
|
5
|
+
its own: a stage drains what its flows carry at the load where its component is full. Used by `@loadmap/react`; you
|
|
6
|
+
rarely need it directly. MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Evaluation, Model } from "@loadmap/core";
|
|
2
|
+
interface Fill {
|
|
3
|
+
/** rank[i]: the order in which worker i takes work. */
|
|
4
|
+
rank: readonly number[];
|
|
5
|
+
/** Small per-worker weights around 1, so equal shares do not look machine-made. */
|
|
6
|
+
jitter: readonly number[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The order workers take work. Round-robin: one worker of each pod in turn, a shuffled one inside each pod, so load
|
|
10
|
+
* spreads evenly across pods instead of filling pod 0 top to bottom. In-order: pod 0 first.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fillOrder(pods: number, perPod: number, fill: 'round-robin' | 'in-order', seed?: number): Fill;
|
|
13
|
+
/** How busy worker i is (0–1) when the pool as a whole is `util` busy. */
|
|
14
|
+
export declare function busyOf(i: number, f: Fill, util: number): number;
|
|
15
|
+
/** mulberry32: a small seeded generator, so a scenario plays the same way every time (export depends on it). */
|
|
16
|
+
export declare function rng(seed: number): () => number;
|
|
17
|
+
interface SimOptions {
|
|
18
|
+
seed?: number;
|
|
19
|
+
/** Simulated seconds per real second. Default 10. */
|
|
20
|
+
speed?: number;
|
|
21
|
+
/** About how many dots a second all flows together should spawn; sets the dot scale. Default 28. */
|
|
22
|
+
dotTarget?: number;
|
|
23
|
+
/** Every flow with any rate spawns at least this many dots a second, so a small flow stays visible. Default 1. */
|
|
24
|
+
minDots?: number;
|
|
25
|
+
/** And at most this many per flow. Default 40. */
|
|
26
|
+
maxDots?: number;
|
|
27
|
+
/** Real seconds a dot takes from one stage to the next. Default 0.45. */
|
|
28
|
+
legSeconds?: number;
|
|
29
|
+
/** Most dots alive at once. Default 800. */
|
|
30
|
+
maxParticles?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Where a dot is or is heading: a stage (null: the flow's source), and a cell inside it (partition or worker). */
|
|
33
|
+
interface Place {
|
|
34
|
+
stage: string | null;
|
|
35
|
+
cell?: number;
|
|
36
|
+
}
|
|
37
|
+
interface Particle {
|
|
38
|
+
id: number;
|
|
39
|
+
flow: string;
|
|
40
|
+
/** Index into the flow's route of the stage it is heading to or at. */
|
|
41
|
+
leg: number;
|
|
42
|
+
phase: 'move' | 'hold' | 'fade';
|
|
43
|
+
/** Real seconds into the current phase, and its length. */
|
|
44
|
+
t: number;
|
|
45
|
+
dur: number;
|
|
46
|
+
from: Place;
|
|
47
|
+
to: Place;
|
|
48
|
+
/** Set on a copy made at a fan-out: the branch stage it is heading to. */
|
|
49
|
+
branch?: string;
|
|
50
|
+
/** superseded: merged into a newer unit of its key; unread: a fan-out that made no copy. */
|
|
51
|
+
outcome?: 'delivered' | 'dropped' | 'rejected' | 'superseded' | 'unread';
|
|
52
|
+
}
|
|
53
|
+
interface QueueState {
|
|
54
|
+
/** Units waiting. */
|
|
55
|
+
backlog: number;
|
|
56
|
+
/** Age of the oldest unit waiting, in simulated seconds: what a consumer-lag dashboard shows. */
|
|
57
|
+
lag: number;
|
|
58
|
+
/** Units per second arriving, served, dropped and superseded by a merge (smoothed). */
|
|
59
|
+
arriving: number;
|
|
60
|
+
served: number;
|
|
61
|
+
dropped: number;
|
|
62
|
+
merged: number;
|
|
63
|
+
/** A merge rule is at work: more than a second of arrivals waits. */
|
|
64
|
+
merging: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A fluid model of the queues, driven by the model's utilization, plus dots sampled from it.
|
|
68
|
+
*
|
|
69
|
+
* Each queue stage is drained by the next stage on each flow's route, at that stage's capacity: what its flows carry
|
|
70
|
+
* at the load where its component runs out (the limits' "full at N×"), so a queue grows exactly when the load passes
|
|
71
|
+
* the point the bars show. Flows share that capacity by priority (1 first), across every queue feeding the stage.
|
|
72
|
+
* Backlog is kept as arrival cohorts, so the lag is the real age of the oldest unit and a drop rule can remove
|
|
73
|
+
* exactly the units older than its limit.
|
|
74
|
+
*/
|
|
75
|
+
export declare class Sim {
|
|
76
|
+
#private;
|
|
77
|
+
readonly speed: number;
|
|
78
|
+
time: number;
|
|
79
|
+
particles: Particle[];
|
|
80
|
+
dotScale: number;
|
|
81
|
+
constructor(model: Model, ev: Evaluation, o?: SimOptions);
|
|
82
|
+
/**
|
|
83
|
+
* New values or a new evaluation: rates and capacities change, backlog stays. A new shape (partitions, workers)
|
|
84
|
+
* keeps every dot whose partition or worker still exists, so switching designs does not empty the picture.
|
|
85
|
+
*/
|
|
86
|
+
update(ev: Evaluation): void;
|
|
87
|
+
/** Advance by `dtReal` real seconds: the queues by dtReal × speed, the dots by dtReal. */
|
|
88
|
+
step(dtReal: number, o?: {
|
|
89
|
+
particles?: boolean;
|
|
90
|
+
}): void;
|
|
91
|
+
queue(stage: string, flow: string): QueueState;
|
|
92
|
+
/** How busy a stage is, 0–1, smoothed: for a workers stage, the share of its workers at work. */
|
|
93
|
+
busy(stage: string): number;
|
|
94
|
+
/** The share a pass stage (arrivals) or a fan-out (copies) rejects because its component is past capacity. */
|
|
95
|
+
shed(stage: string): number;
|
|
96
|
+
/** How busy worker `i` of a workers stage is, 0–1. */
|
|
97
|
+
slotBusy(stage: string, i: number): number;
|
|
98
|
+
}
|
|
99
|
+
export declare function createSim(model: Model, ev: Evaluation, o?: SimOptions): Sim;
|
|
100
|
+
export type { Fill, Particle, Place, QueueState, SimOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { niceStep } from "@loadmap/core/format";
|
|
2
|
+
// src/rng.ts
|
|
3
|
+
/** mulberry32: a small seeded generator, so a scenario plays the same way every time (export depends on it). */
|
|
4
|
+
function rng(seed) {
|
|
5
|
+
let a = seed >>> 0;
|
|
6
|
+
return () => {
|
|
7
|
+
a = a + 1831565813 >>> 0;
|
|
8
|
+
let t = a;
|
|
9
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
10
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
11
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// src/fill.ts
|
|
15
|
+
/** Busy workers run at this share before the next one takes work, so a half-busy pool shows a spread, not a block. */
|
|
16
|
+
const SPILL = .7;
|
|
17
|
+
/**
|
|
18
|
+
* The order workers take work. Round-robin: one worker of each pod in turn, a shuffled one inside each pod, so load
|
|
19
|
+
* spreads evenly across pods instead of filling pod 0 top to bottom. In-order: pod 0 first.
|
|
20
|
+
*/
|
|
21
|
+
function fillOrder(pods, perPod, fill, seed = 1) {
|
|
22
|
+
const n = pods * perPod;
|
|
23
|
+
const rank = new Array(n);
|
|
24
|
+
const r = rng(seed);
|
|
25
|
+
for (let p = 0; p < pods; p++) {
|
|
26
|
+
const perm = Array.from({ length: perPod }, (_, t) => t);
|
|
27
|
+
for (let t = perPod - 1; t > 0; t--) {
|
|
28
|
+
const j = Math.floor(r() * (t + 1));
|
|
29
|
+
[perm[t], perm[j]] = [perm[j], perm[t]];
|
|
30
|
+
}
|
|
31
|
+
perm.forEach((t, slot) => {
|
|
32
|
+
rank[p * perPod + t] = fill === "round-robin" ? slot * pods + p : p * perPod + slot;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const w = Array.from({ length: n }, () => .92 + r() * .16);
|
|
36
|
+
const mean = w.reduce((a, b) => a + b, 0) / (n || 1);
|
|
37
|
+
return {
|
|
38
|
+
rank,
|
|
39
|
+
jitter: w.map((x) => x / mean)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** How busy worker i is (0–1) when the pool as a whole is `util` busy. */
|
|
43
|
+
function busyOf(i, f, util) {
|
|
44
|
+
const n = f.rank.length;
|
|
45
|
+
const total = Math.min(1, Math.max(0, util)) * n;
|
|
46
|
+
const k = f.rank[i];
|
|
47
|
+
let b;
|
|
48
|
+
if (total <= SPILL * n) {
|
|
49
|
+
const full = Math.floor(total / SPILL);
|
|
50
|
+
b = k < full ? SPILL : k === full ? total - full * SPILL : 0;
|
|
51
|
+
} else b = SPILL + (total - SPILL * n) / n;
|
|
52
|
+
return Math.min(1, Math.max(0, b * (f.jitter[i] ?? 1)));
|
|
53
|
+
}
|
|
54
|
+
// src/sim.ts
|
|
55
|
+
const emptyQueue = () => ({
|
|
56
|
+
backlog: 0,
|
|
57
|
+
lag: 0,
|
|
58
|
+
arriving: 0,
|
|
59
|
+
served: 0,
|
|
60
|
+
dropped: 0,
|
|
61
|
+
merged: 0,
|
|
62
|
+
merging: false
|
|
63
|
+
});
|
|
64
|
+
const MERGE_WINDOW = 1;
|
|
65
|
+
const MAX_COHORTS = 2e3;
|
|
66
|
+
const TAU = 3;
|
|
67
|
+
/**
|
|
68
|
+
* A fluid model of the queues, driven by the model's utilization, plus dots sampled from it.
|
|
69
|
+
*
|
|
70
|
+
* Each queue stage is drained by the next stage on each flow's route, at that stage's capacity: what its flows carry
|
|
71
|
+
* at the load where its component runs out (the limits' "full at N×"), so a queue grows exactly when the load passes
|
|
72
|
+
* the point the bars show. Flows share that capacity by priority (1 first), across every queue feeding the stage.
|
|
73
|
+
* Backlog is kept as arrival cohorts, so the lag is the real age of the oldest unit and a drop rule can remove
|
|
74
|
+
* exactly the units older than its limit.
|
|
75
|
+
*/
|
|
76
|
+
var Sim = class {
|
|
77
|
+
speed;
|
|
78
|
+
time = 0;
|
|
79
|
+
particles = [];
|
|
80
|
+
dotScale = 1;
|
|
81
|
+
#o;
|
|
82
|
+
#r;
|
|
83
|
+
#model;
|
|
84
|
+
#ev;
|
|
85
|
+
#shape = "";
|
|
86
|
+
#queues = /* @__PURE__ */ new Map();
|
|
87
|
+
#busy = /* @__PURE__ */ new Map();
|
|
88
|
+
#fills = /* @__PURE__ */ new Map();
|
|
89
|
+
#shed = /* @__PURE__ */ new Map();
|
|
90
|
+
#inflow = /* @__PURE__ */ new Map();
|
|
91
|
+
#capacity = /* @__PURE__ */ new Map();
|
|
92
|
+
#spawn = /* @__PURE__ */ new Map();
|
|
93
|
+
#nextId = 1;
|
|
94
|
+
constructor(model, ev, o = {}) {
|
|
95
|
+
this.#o = {
|
|
96
|
+
seed: 1,
|
|
97
|
+
speed: 10,
|
|
98
|
+
dotTarget: 28,
|
|
99
|
+
minDots: 1,
|
|
100
|
+
maxDots: 40,
|
|
101
|
+
legSeconds: .45,
|
|
102
|
+
maxParticles: 800,
|
|
103
|
+
...o
|
|
104
|
+
};
|
|
105
|
+
this.speed = this.#o.speed;
|
|
106
|
+
this.#r = rng(this.#o.seed);
|
|
107
|
+
this.#model = model;
|
|
108
|
+
this.update(ev);
|
|
109
|
+
for (const s of Object.values(ev.stages)) if (s.kind === "workers") this.#busy.set(s.id, this.#steadyBusy(s.id));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* New values or a new evaluation: rates and capacities change, backlog stays. A new shape (partitions, workers)
|
|
113
|
+
* keeps every dot whose partition or worker still exists, so switching designs does not empty the picture.
|
|
114
|
+
*/
|
|
115
|
+
update(ev) {
|
|
116
|
+
this.#ev = ev;
|
|
117
|
+
const shape = JSON.stringify(Object.values(ev.stages).map((st) => st.kind === "queue" ? [st.id, st.partitions] : st.kind === "workers" ? [
|
|
118
|
+
st.id,
|
|
119
|
+
st.pods,
|
|
120
|
+
st.perPod,
|
|
121
|
+
st.fill
|
|
122
|
+
] : [st.id, st.kind]));
|
|
123
|
+
if (shape !== this.#shape) {
|
|
124
|
+
this.#shape = shape;
|
|
125
|
+
const fits = (at, flow) => {
|
|
126
|
+
if (at.stage == null) return true;
|
|
127
|
+
const st = ev.stages[at.stage];
|
|
128
|
+
if (!st) return false;
|
|
129
|
+
if (at.cell == null) return true;
|
|
130
|
+
if (st.kind === "queue") return at.cell < (st.partitions[flow] ?? 0);
|
|
131
|
+
if (st.kind === "workers") return at.cell < st.pods * st.perPod;
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
this.particles = this.particles.filter((d) => fits(d.from, d.flow) && fits(d.to, d.flow));
|
|
135
|
+
for (const s of Object.values(ev.stages)) if (s.kind === "workers") this.#fills.set(s.id, fillOrder(s.pods, s.perPod, s.fill, this.#o.seed));
|
|
136
|
+
}
|
|
137
|
+
const full = this.#model.thresholds.full;
|
|
138
|
+
this.#shed.clear();
|
|
139
|
+
this.#inflow.clear();
|
|
140
|
+
for (const s of Object.values(ev.stages)) {
|
|
141
|
+
const u = (s.kind === "pass" || s.kind === "fanout") && s.component ? this.#util(s.component) : null;
|
|
142
|
+
this.#shed.set(s.id, u != null && u > full ? 1 - full / u : 0);
|
|
143
|
+
}
|
|
144
|
+
for (const f of this.#model.flowIds) {
|
|
145
|
+
let rate = ev.flows[f] ?? 0;
|
|
146
|
+
for (const st of ev.routes[f] ?? []) {
|
|
147
|
+
this.#inflow.set(`${st}/${f}`, rate);
|
|
148
|
+
rate *= 1 - (this.#shed.get(st) ?? 0);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.#capacity.clear();
|
|
152
|
+
const routedThrough = (s) => this.#model.flowIds.filter((f) => ev.routes[f]?.includes(s));
|
|
153
|
+
for (const s of Object.values(ev.stages)) {
|
|
154
|
+
const c = s.component ? ev.components[s.component] : void 0;
|
|
155
|
+
let cap = Number.POSITIVE_INFINITY;
|
|
156
|
+
if (c && c.util != null && c.full != null && Number.isFinite(c.full)) {
|
|
157
|
+
if (c.full > 0) {
|
|
158
|
+
const at = this.#model.evaluate({
|
|
159
|
+
...ev.values,
|
|
160
|
+
load: c.full
|
|
161
|
+
});
|
|
162
|
+
cap = routedThrough(s.id).reduce((a, f) => a + (at.flows[f] ?? 0), 0);
|
|
163
|
+
} else {
|
|
164
|
+
const now = routedThrough(s.id).reduce((a, f) => a + (this.#inflow.get(`${s.id}/${f}`) ?? 0), 0);
|
|
165
|
+
cap = c.util > 0 ? now * full / c.util : 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.#capacity.set(s.id, cap);
|
|
169
|
+
}
|
|
170
|
+
const total = this.#model.flowIds.reduce((a, f) => a + (ev.flows[f] ?? 0), 0);
|
|
171
|
+
this.dotScale = Math.max(1, niceStep(total / this.#o.dotTarget));
|
|
172
|
+
}
|
|
173
|
+
/** Advance by `dtReal` real seconds: the queues by dtReal × speed, the dots by dtReal. */
|
|
174
|
+
step(dtReal, o = {}) {
|
|
175
|
+
if (!(dtReal > 0)) return;
|
|
176
|
+
const dt = dtReal * this.speed;
|
|
177
|
+
this.time += dt;
|
|
178
|
+
this.#stepQueues(dt);
|
|
179
|
+
if (o.particles !== false) {
|
|
180
|
+
this.#spawnDots(dtReal);
|
|
181
|
+
this.#moveDots(dtReal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
queue(stage, flow) {
|
|
185
|
+
return this.#queues.get(stage)?.get(flow)?.s ?? emptyQueue();
|
|
186
|
+
}
|
|
187
|
+
/** How busy a stage is, 0–1, smoothed: for a workers stage, the share of its workers at work. */
|
|
188
|
+
busy(stage) {
|
|
189
|
+
return this.#busy.get(stage) ?? this.#steadyBusy(stage);
|
|
190
|
+
}
|
|
191
|
+
/** The share a pass stage (arrivals) or a fan-out (copies) rejects because its component is past capacity. */
|
|
192
|
+
shed(stage) {
|
|
193
|
+
return this.#shed.get(stage) ?? 0;
|
|
194
|
+
}
|
|
195
|
+
/** How busy worker `i` of a workers stage is, 0–1. */
|
|
196
|
+
slotBusy(stage, i) {
|
|
197
|
+
const f = this.#fills.get(stage);
|
|
198
|
+
return f ? busyOf(i, f, this.busy(stage)) : 0;
|
|
199
|
+
}
|
|
200
|
+
#util(component) {
|
|
201
|
+
return this.#ev.components[component]?.util ?? null;
|
|
202
|
+
}
|
|
203
|
+
#steadyBusy(stage) {
|
|
204
|
+
const s = this.#ev.stages[stage];
|
|
205
|
+
const u = s?.component ? this.#util(s.component) : null;
|
|
206
|
+
return Math.min(1, (u ?? 0) / this.#model.thresholds.full);
|
|
207
|
+
}
|
|
208
|
+
#stepQueues(dt) {
|
|
209
|
+
const ev = this.#ev;
|
|
210
|
+
const entries = [];
|
|
211
|
+
for (const q of Object.values(ev.stages)) {
|
|
212
|
+
if (q.kind !== "queue") continue;
|
|
213
|
+
const qs = this.#queues.get(q.id) ?? /* @__PURE__ */ new Map();
|
|
214
|
+
this.#queues.set(q.id, qs);
|
|
215
|
+
for (const f of this.#model.flowIds) {
|
|
216
|
+
const route = ev.routes[f] ?? [];
|
|
217
|
+
if (!route.includes(q.id)) continue;
|
|
218
|
+
const fq = qs.get(f) ?? {
|
|
219
|
+
cohorts: [],
|
|
220
|
+
s: emptyQueue()
|
|
221
|
+
};
|
|
222
|
+
qs.set(f, fq);
|
|
223
|
+
const inflow = this.#inflow.get(`${q.id}/${f}`) ?? 0;
|
|
224
|
+
const share = Math.min(.95, Math.max(0, ev.overload[f]?.merge?.share ?? 0));
|
|
225
|
+
fq.s.merging = share > 0 && backlog(fq.cohorts) > inflow * 1;
|
|
226
|
+
const n = inflow * dt;
|
|
227
|
+
const last = fq.cohorts.at(-1);
|
|
228
|
+
if (last && this.time - last.t < MERGE_WINDOW) last.n += n;
|
|
229
|
+
else if (n > 0) fq.cohorts.push({
|
|
230
|
+
t: this.time,
|
|
231
|
+
n
|
|
232
|
+
});
|
|
233
|
+
if (fq.cohorts.length > MAX_COHORTS) fq.cohorts = mergePairs(fq.cohorts);
|
|
234
|
+
const drainer = route[route.indexOf(q.id) + 1] ?? "∞";
|
|
235
|
+
const k = fq.s.merging ? 1 / (1 - share) : 1;
|
|
236
|
+
entries.push({
|
|
237
|
+
q: q.id,
|
|
238
|
+
f,
|
|
239
|
+
fq,
|
|
240
|
+
drainer,
|
|
241
|
+
prio: this.#model.def.flows[f]?.priority ?? 1,
|
|
242
|
+
k
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const taken = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const d of new Set(entries.map((e) => e.drainer))) {
|
|
248
|
+
const feeding = entries.filter((e) => e.drainer === d);
|
|
249
|
+
let avail = d === "∞" ? Number.POSITIVE_INFINITY : (this.#capacity.get(d) ?? Number.POSITIVE_INFINITY) * dt;
|
|
250
|
+
for (const pr of [...new Set(feeding.map((e) => e.prio))].sort((a, b) => a - b)) {
|
|
251
|
+
const group = feeding.filter((e) => e.prio === pr);
|
|
252
|
+
const want = group.reduce((a, e) => a + backlog(e.fq.cohorts) / e.k, 0);
|
|
253
|
+
let took = 0;
|
|
254
|
+
for (const e of group) {
|
|
255
|
+
const b = backlog(e.fq.cohorts);
|
|
256
|
+
const removed = Number.isFinite(avail) ? want > 0 ? Math.min(b, avail * b / want) : 0 : b;
|
|
257
|
+
const used = removed / e.k;
|
|
258
|
+
serveOldest(e.fq.cohorts, removed);
|
|
259
|
+
took += used;
|
|
260
|
+
e.fq.s.served = ema(e.fq.s.served, used / dt, dt);
|
|
261
|
+
e.fq.s.merged = ema(e.fq.s.merged, (removed - used) / dt, dt);
|
|
262
|
+
}
|
|
263
|
+
avail = Math.max(0, avail - took);
|
|
264
|
+
taken.set(d, (taken.get(d) ?? 0) + took);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const { q, f, fq } of entries) {
|
|
268
|
+
let dropped = 0;
|
|
269
|
+
const rule = ev.overload[f]?.drop;
|
|
270
|
+
if (rule) while (fq.cohorts.length && this.time - fq.cohorts[0].t > rule.olderThan) dropped += fq.cohorts.shift().n;
|
|
271
|
+
const inflow = this.#inflow.get(`${q}/${f}`) ?? 0;
|
|
272
|
+
if (backlog(fq.cohorts) < 1e-9 * Math.max(1, inflow)) fq.cohorts = [];
|
|
273
|
+
fq.s.backlog = backlog(fq.cohorts);
|
|
274
|
+
fq.s.lag = fq.cohorts.length ? this.time - fq.cohorts[0].t : 0;
|
|
275
|
+
fq.s.arriving = ema(fq.s.arriving, inflow, dt);
|
|
276
|
+
fq.s.dropped = ema(fq.s.dropped, dropped / dt, dt);
|
|
277
|
+
}
|
|
278
|
+
for (const s of Object.values(ev.stages)) {
|
|
279
|
+
if (s.kind !== "workers") continue;
|
|
280
|
+
const cap = this.#capacity.get(s.id) ?? Number.POSITIVE_INFINITY;
|
|
281
|
+
const now = taken.has(s.id) && Number.isFinite(cap) && cap > 0 ? taken.get(s.id) / (cap * dt) : this.#steadyBusy(s.id);
|
|
282
|
+
this.#busy.set(s.id, ema(this.#busy.get(s.id) ?? now, Math.min(1, now), dt));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
#spawnDots(dtReal) {
|
|
286
|
+
const { minDots, maxDots, legSeconds, maxParticles } = this.#o;
|
|
287
|
+
for (const f of this.#model.flowIds) {
|
|
288
|
+
const rate = this.#ev.flows[f] ?? 0;
|
|
289
|
+
if (rate <= 0) continue;
|
|
290
|
+
const perSec = Math.min(maxDots, Math.max(minDots, rate / this.dotScale));
|
|
291
|
+
let acc = (this.#spawn.get(f) ?? 0) + perSec * dtReal;
|
|
292
|
+
while (acc >= 1) {
|
|
293
|
+
acc -= 1;
|
|
294
|
+
if (this.particles.length >= maxParticles) continue;
|
|
295
|
+
const first = this.#ev.routes[f][0];
|
|
296
|
+
this.particles.push({
|
|
297
|
+
id: this.#nextId++,
|
|
298
|
+
flow: f,
|
|
299
|
+
leg: 0,
|
|
300
|
+
phase: "move",
|
|
301
|
+
t: 0,
|
|
302
|
+
dur: legSeconds,
|
|
303
|
+
from: { stage: null },
|
|
304
|
+
to: this.#place(first, f)
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
this.#spawn.set(f, acc);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
#moveDots(dtReal) {
|
|
311
|
+
const ev = this.#ev;
|
|
312
|
+
const fade = (d, outcome, dur) => {
|
|
313
|
+
d.phase = "fade";
|
|
314
|
+
d.outcome = outcome;
|
|
315
|
+
d.t = 0;
|
|
316
|
+
d.dur = dur;
|
|
317
|
+
d.from = d.to;
|
|
318
|
+
};
|
|
319
|
+
const advance = (d) => {
|
|
320
|
+
const route = ev.routes[d.flow];
|
|
321
|
+
if (d.leg + 1 >= route.length) return fade(d, "delivered", .35);
|
|
322
|
+
d.leg++;
|
|
323
|
+
d.phase = "move";
|
|
324
|
+
d.t = 0;
|
|
325
|
+
d.dur = this.#o.legSeconds;
|
|
326
|
+
d.from = d.to;
|
|
327
|
+
d.to = this.#place(route[d.leg], d.flow);
|
|
328
|
+
};
|
|
329
|
+
const born = [];
|
|
330
|
+
for (const d of this.particles) {
|
|
331
|
+
d.t += dtReal;
|
|
332
|
+
if (d.t < d.dur) continue;
|
|
333
|
+
const stage = ev.stages[d.to.stage ?? ""];
|
|
334
|
+
if (d.phase === "fade" || !stage) {
|
|
335
|
+
d.phase = "fade";
|
|
336
|
+
d.outcome ??= "delivered";
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (d.phase === "move" && d.branch) fade(d, this.#r() < this.shed(d.from.stage ?? "") || stage.kind === "pass" && this.#r() < this.shed(stage.id) ? "rejected" : "delivered", .35);
|
|
340
|
+
else if (d.phase === "move" && stage.kind === "fanout") {
|
|
341
|
+
let made = 0;
|
|
342
|
+
for (const b of stage.to[d.flow] ?? []) {
|
|
343
|
+
const copies = Math.floor(b.share) + (this.#r() < b.share % 1 ? 1 : 0);
|
|
344
|
+
for (let i = 0; i < copies; i++, made++) if (this.particles.length + born.length < this.#o.maxParticles) born.push({
|
|
345
|
+
id: this.#nextId++,
|
|
346
|
+
flow: d.flow,
|
|
347
|
+
leg: d.leg,
|
|
348
|
+
phase: "move",
|
|
349
|
+
t: 0,
|
|
350
|
+
dur: this.#o.legSeconds,
|
|
351
|
+
from: d.to,
|
|
352
|
+
to: { stage: b.stage },
|
|
353
|
+
branch: b.stage
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (made) fade(d, "delivered", 0);
|
|
357
|
+
else fade(d, "unread", .6);
|
|
358
|
+
} else if (d.phase === "move") {
|
|
359
|
+
if (stage.kind === "pass" && this.#r() < this.shed(stage.id)) fade(d, "rejected", .6);
|
|
360
|
+
else if (stage.kind === "queue") {
|
|
361
|
+
const lag = this.queue(stage.id, d.flow).lag;
|
|
362
|
+
d.phase = "hold";
|
|
363
|
+
d.t = 0;
|
|
364
|
+
d.dur = .12 + Math.min(lag, 120) / 120 * 2.6;
|
|
365
|
+
} else if (stage.kind === "workers") {
|
|
366
|
+
d.phase = "hold";
|
|
367
|
+
d.t = 0;
|
|
368
|
+
d.dur = .15;
|
|
369
|
+
} else advance(d);
|
|
370
|
+
} else if (d.phase === "hold") {
|
|
371
|
+
const q = stage.kind === "queue" ? this.queue(stage.id, d.flow) : null;
|
|
372
|
+
const dropShare = q && q.arriving > 0 ? Math.min(1, q.dropped / q.arriving) : 0;
|
|
373
|
+
const mergeShare = q?.merging ? ev.overload[d.flow]?.merge?.share ?? 0 : 0;
|
|
374
|
+
if (this.#r() < mergeShare) fade(d, "superseded", .6);
|
|
375
|
+
else if (this.#r() < dropShare) fade(d, "dropped", .6);
|
|
376
|
+
else advance(d);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
this.particles = this.particles.filter((d) => !(d.phase === "fade" && d.t >= d.dur)).concat(born);
|
|
380
|
+
}
|
|
381
|
+
#place(stage, flow) {
|
|
382
|
+
const s = this.#ev.stages[stage];
|
|
383
|
+
if (s?.kind === "queue") {
|
|
384
|
+
const n = s.partitions[flow] ?? 0;
|
|
385
|
+
return n > 0 ? {
|
|
386
|
+
stage,
|
|
387
|
+
cell: Math.floor(this.#r() * n)
|
|
388
|
+
} : { stage };
|
|
389
|
+
}
|
|
390
|
+
if (s?.kind === "workers") return {
|
|
391
|
+
stage,
|
|
392
|
+
cell: this.#pickWorker(s.id, s.pods * s.perPod)
|
|
393
|
+
};
|
|
394
|
+
return { stage };
|
|
395
|
+
}
|
|
396
|
+
/** A worker chosen in proportion to how busy it is, so dots land where the fill shows work. */
|
|
397
|
+
#pickWorker(stage, n) {
|
|
398
|
+
let total = 0;
|
|
399
|
+
for (let i = 0; i < n; i++) total += this.slotBusy(stage, i);
|
|
400
|
+
if (total <= .001) return Math.floor(this.#r() * n);
|
|
401
|
+
let r = this.#r() * total;
|
|
402
|
+
for (let i = 0; i < n; i++) {
|
|
403
|
+
r -= this.slotBusy(stage, i);
|
|
404
|
+
if (r <= 0) return i;
|
|
405
|
+
}
|
|
406
|
+
return n - 1;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function createSim(model, ev, o) {
|
|
410
|
+
return new Sim(model, ev, o);
|
|
411
|
+
}
|
|
412
|
+
const backlog = (c) => c.reduce((a, x) => a + x.n, 0);
|
|
413
|
+
function serveOldest(c, amount) {
|
|
414
|
+
let left = amount;
|
|
415
|
+
while (left > 0 && c.length) {
|
|
416
|
+
const head = c[0];
|
|
417
|
+
if (head.n <= left) {
|
|
418
|
+
left -= head.n;
|
|
419
|
+
c.shift();
|
|
420
|
+
} else {
|
|
421
|
+
head.n -= left;
|
|
422
|
+
left = 0;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function mergePairs(c) {
|
|
427
|
+
const out = [];
|
|
428
|
+
for (let i = 0; i < c.length; i += 2) {
|
|
429
|
+
const a = c[i];
|
|
430
|
+
const b = c[i + 1];
|
|
431
|
+
out.push(b ? {
|
|
432
|
+
t: a.t,
|
|
433
|
+
n: a.n + b.n
|
|
434
|
+
} : a);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
function ema(prev, now, dt) {
|
|
439
|
+
return prev + (now - prev) * Math.min(1, dt / TAU);
|
|
440
|
+
}
|
|
441
|
+
export { Sim, busyOf, createSim, fillOrder, rng };
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmap/sim",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The fluid queue simulation behind loadmap's moving dots: priorities, drop rules, worker fill, seeded randomness.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"loadmap",
|
|
7
|
+
"simulation",
|
|
8
|
+
"queue"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Jaime Rojas",
|
|
12
|
+
"homepage": "https://jaim3r0jas.github.io/loadmap/",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/jaim3r0jas/loadmap.git",
|
|
16
|
+
"directory": "packages/sim"
|
|
17
|
+
},
|
|
18
|
+
"bugs": "https://github.com/jaim3r0jas/loadmap/issues",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@loadmap/core": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsdown"
|
|
36
|
+
}
|
|
6
37
|
}
|