@edgehero/pi-dispatch 3.1.0 → 4.0.1
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/.env.example +34 -0
- package/package.json +6 -2
- package/src/backend-local.mjs +69 -0
- package/src/backend-podman.mjs +44 -13
- package/src/config.mjs +38 -0
- package/src/container-spec.mjs +70 -7
- package/src/cpu-reserve.mjs +344 -0
- package/src/daemon-facts.mjs +58 -0
- package/src/docker-run.mjs +83 -6
- package/src/doctor.mjs +599 -16
- package/src/host-budget.mjs +736 -0
- package/src/index.mjs +237 -65
- package/src/job-size.mjs +286 -0
- package/src/job-user.mjs +66 -5
- package/src/live-probes.mjs +150 -25
- package/src/prepare.mjs +7 -3
- package/src/processor.mjs +91 -14
- package/src/provider-steering.mjs +1 -0
- package/src/run-container.mjs +62 -8
- package/src/run-history.mjs +204 -117
- package/src/sandbox-store.mjs +5 -1
- package/src/sandbox.mjs +48 -7
- package/src/scoped-limits.mjs +94 -10
- package/src/size-records.mjs +80 -0
- package/src/size-suggest.mjs +441 -0
- package/src/start.mjs +133 -9
- package/src/triggers.mjs +2 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A size suggestion for one project (issue #596, phase 3, DES-SIZE-SUGGESTIONS): what memory and CPUs its jobs
|
|
3
|
+
* should be given, read from what its recent runs used. ONE pure function, `suggestSize`, which doctor, the panel's
|
|
4
|
+
* PROJECTS view, the `dispatch_limit_edit` preview and the insights page all call, so the four can never disagree.
|
|
5
|
+
*
|
|
6
|
+
* A LEAF beside `job-size.mjs`, importing nothing else of this project's: the admin bundle inlines it, and the insights
|
|
7
|
+
* page builder refuses every worker import, so its caller computes the suggestion and hands the page the result.
|
|
8
|
+
*
|
|
9
|
+
* WHAT IT READS. Run records (INT-RUN-HISTORY-FILE-CONTRACT) of the project, from the last `SUGGEST_WINDOW_DAYS`
|
|
10
|
+
* days, newest first, at most `SUGGEST_WINDOW_RUNS` of them, and only those that carry BOTH `resources` (what the
|
|
11
|
+
* container used, issue #596 phase 0) and `size` (what it was given, phase 1). A record from before either is ignored,
|
|
12
|
+
* never guessed.
|
|
13
|
+
*
|
|
14
|
+
* THE NUMBERS ARE UNTRUSTED. `resources` is produced inside the job's container, which runs code the job controls, so
|
|
15
|
+
* every number is judged again here whatever the worker judged when it wrote the record: a field that is not a safe
|
|
16
|
+
* non-negative integer is IGNORED (that record leaves that dimension), and a value past what the container could hold
|
|
17
|
+
* is CLAMPED to it. The suggestion is NEVER applied by anything: every surface names the call, and an operator confirms.
|
|
18
|
+
*
|
|
19
|
+
* TWO MEASURED FACTS SHAPE THE RULES (the review gate of this phase, in the lab):
|
|
20
|
+
* - page cache alone drives `memory.peak` to exactly the limit with no OOM kill (a 512m container reading 1.2 GB of
|
|
21
|
+
* files peaked at 512m, oom_kill 0). A peak AT the limit therefore says nothing about need, so it never raises:
|
|
22
|
+
* an earlier rule that raised on it walked an I/O-heavy project up step by step to the budget. Only a confirmed
|
|
23
|
+
* OOM (the record's reason `oom-killed`, which the WORKER decides, not the job) raises memory.
|
|
24
|
+
* - a job's throttled time is the same at 1024 and 4096 CPU shares, because `--cpus` is the host's CPU ceiling for
|
|
25
|
+
* every job, not the job's size (the size is its weight). Raising a job's cpus never reduces its throttling, so
|
|
26
|
+
* throttling never raises CPUs; it is shown as a fact about the host.
|
|
27
|
+
*
|
|
28
|
+
* WHICH RUNS COUNT: in each dimension the runs given at least the CURRENT size decide (a run at a smaller size that
|
|
29
|
+
* was cut off says the old size was too small, which is no longer the question). The floors of a lowering read EVERY
|
|
30
|
+
* run of the window, whatever its size: a lowering must never undo a raise an OOM caused, nor drop below what the
|
|
31
|
+
* heaviest run used.
|
|
32
|
+
*
|
|
33
|
+
* MEMORY, in this order (the first that applies decides):
|
|
34
|
+
* 1. an `oom-killed` run at the current size or larger: raise to 1.5x the larger of the current size and the
|
|
35
|
+
* largest size in the window that was OOM-killed (one step; that run no longer counts once the raise is applied);
|
|
36
|
+
* 2. fewer than `SUGGEST_MIN_SAMPLES` runs: not enough runs;
|
|
37
|
+
* 3. the target 1.25x the p95 peak at most 0.75x the current size: lower to it, but never below 1.25x the window's
|
|
38
|
+
* largest peak (peak / 0.8), 1.5x the largest OOM-killed size in the window, nor the 512m floor;
|
|
39
|
+
* 4. otherwise it fits.
|
|
40
|
+
* A FACT (no call) rides along when runs at the limit were also under real memory pressure (`memFullUsec` above 1%
|
|
41
|
+
* of the wall time): information, never an instruction.
|
|
42
|
+
* A size is rounded UP to a step: 256m up to 2g, 512m up to 8g, then 1g.
|
|
43
|
+
* CPUs: fewer than `SUGGEST_MIN_SAMPLES` runs is not enough runs; the p95 cores used (CPU time over wall time) below
|
|
44
|
+
* 0.4x the current cpus lowers to 1.25x that p95, never below 1.25x the window's largest cores used, nor 0.25;
|
|
45
|
+
* otherwise it fits. A FACT (no call) rides along when the median run was throttled more than 25% of its wall time.
|
|
46
|
+
* The wall time is the record's pickup-to-end span, which includes the clone, so cores used read slightly LOW.
|
|
47
|
+
*
|
|
48
|
+
* THE CAP. A raise never goes past `cap` (this host's budget per dimension, or where that is off or unknown the host's
|
|
49
|
+
* memory and CPU count; `hostCap`, `fleetCap`). A project with a `hostShare` is capped at its SHARE of each integer
|
|
50
|
+
* budget, floor(budget x hostShare / 100), since the worker refuses a job above it (`job-size-exceeds-share`): a cap
|
|
51
|
+
* at the whole budget would offer a call to a size this project could never run at. Where the cap binds the
|
|
52
|
+
* suggestion is the cap and says the project's runs need more than this host offers; where the size already is the
|
|
53
|
+
* cap or the largest size there is, it suggests nothing and says so; where no cap is known, a raise offers no call at
|
|
54
|
+
* all, only the fact, and `capMissing` says why (`MEMORY_CAP_MISSING`). Nothing here ever advises growing a host's
|
|
55
|
+
* budget: the budget is what the host promised everyone else.
|
|
56
|
+
*
|
|
57
|
+
* Every boundary is decided in integers (BigInt where a product can pass 2^53), so "exactly 0.75x" and "exactly 1%"
|
|
58
|
+
* land on the side the rule says, on every host.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { JOB_CPUS_CEILING_CENTI, JOB_CPUS_FLOOR_CENTI, JOB_MEMORY_CEILING_MIB, JOB_MEMORY_FLOOR_MIB, formatCpus, formatMemory, recordedJobSize } from "./job-size.mjs";
|
|
62
|
+
|
|
63
|
+
/** How far back a suggestion reads: 30 days. */
|
|
64
|
+
export const SUGGEST_WINDOW_DAYS = 30;
|
|
65
|
+
/** How many runs a suggestion reads at most, newest first: 50. The window is whichever of the two is fewer runs. */
|
|
66
|
+
export const SUGGEST_WINDOW_RUNS = 50;
|
|
67
|
+
/** Fewer runs than this in a dimension and only an OOM decides it. */
|
|
68
|
+
export const SUGGEST_MIN_SAMPLES = 10;
|
|
69
|
+
/**
|
|
70
|
+
* How far a record's `endedAt` may lie past `now` and still count: 5 minutes, ordinary skew between hosts. A copy of
|
|
71
|
+
* run-history.mjs `RECORD_CLOCK_SKEW_MS` (this module imports nothing heavy); a test holds the two equal.
|
|
72
|
+
*/
|
|
73
|
+
export const SUGGEST_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
74
|
+
/** The longest wall time a record may claim and still give a CPU sample: 7 days. A longer span is no job's. */
|
|
75
|
+
export const SUGGEST_MAX_WALL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
/** Why a memory suggestion is what it is. */
|
|
78
|
+
export const MEMORY_REASONS = Object.freeze(["oom-killed", "not-enough-runs", "oversized", "fits"]);
|
|
79
|
+
/** Why a CPU suggestion is what it is. There is no raise: see the header. */
|
|
80
|
+
export const CPU_REASONS = Object.freeze(["not-enough-runs", "underused", "fits"]);
|
|
81
|
+
/**
|
|
82
|
+
* Why a memory raise suggests less than it wanted, or nothing: `cap` (the cap bound; the suggestion IS the cap),
|
|
83
|
+
* `largest` (the size already is the cap or the largest size there is), `no-cap` (no cap is known: no call at all).
|
|
84
|
+
*/
|
|
85
|
+
export const MEMORY_HELD = Object.freeze(["cap", "largest", "no-cap"]);
|
|
86
|
+
/**
|
|
87
|
+
* Why no cap is known, where a raise is held `no-cap` across live hosts (`hosts`): `unread` (no live host's budget in
|
|
88
|
+
* this dimension was read as a number, and not every one is `off`: none published, or none read here), `none-holds` (budgets were read, but no live host's
|
|
89
|
+
* budget holds the project's size in the other dimension), `off` (every live host's budget is `off` in this dimension,
|
|
90
|
+
* so the panel cannot know how much it holds). Null for a single host's cap (doctor), whose own words say why.
|
|
91
|
+
*/
|
|
92
|
+
export const MEMORY_CAP_MISSING = Object.freeze(["unread", "none-holds", "off"]);
|
|
93
|
+
/** The facts a suggestion may carry with no call: memory `pressure`, CPU `ceiling`. */
|
|
94
|
+
export const SIZE_FACTS = Object.freeze(["pressure", "ceiling"]);
|
|
95
|
+
|
|
96
|
+
const MIB = 1024 * 1024;
|
|
97
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
/** A memory size rounded UP to its step: 256m up to 2g, 512m up to 8g, then 1g; at least the floor, at most the ceiling. */
|
|
100
|
+
export function roundMemoryUp(memMiB) {
|
|
101
|
+
const v = Math.max(JOB_MEMORY_FLOOR_MIB, Math.ceil(memMiB));
|
|
102
|
+
const step = v <= 2048 ? 256 : v <= 8192 ? 512 : 1024;
|
|
103
|
+
return Math.min(JOB_MEMORY_CEILING_MIB, Math.ceil(v / step) * step);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A CPU size in hundredths rounded UP to a 0.25 step; at least 0.25, at most the ceiling. */
|
|
107
|
+
export function roundCpusUp(cpuCenti) {
|
|
108
|
+
const v = Math.max(JOB_CPUS_FLOOR_CENTI, Math.ceil(cpuCenti));
|
|
109
|
+
return Math.min(JOB_CPUS_CEILING_CENTI, Math.ceil(v / 25) * 25);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The nearest-rank percentile of a sorted array: the value at rank ceil(p/100 x n). */
|
|
113
|
+
function rank(sorted, pct) {
|
|
114
|
+
return sorted[Math.max(0, Math.ceil((pct * sorted.length) / 100) - 1)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One record as a suggestion reads it, or null when it is not evidence: `{ at, size, memPeak, oom, wallUsec, cpuUsec,
|
|
121
|
+
* throttledUsec, memFullUsec }`. `memPeak` (bytes, clamped to the record's own limit) is null when absent or not a
|
|
122
|
+
* count; `wallUsec` is null unless the span is readable; `cpuUsec` and `throttledUsec` are null unless both can be read
|
|
123
|
+
* with a wall time (CPU time clamped to the CPU ceiling over the wall, throttled time to the wall); `memFullUsec` is
|
|
124
|
+
* null unless it can be read with a wall time.
|
|
125
|
+
*/
|
|
126
|
+
function evidenceOf(record, project, nowMs) {
|
|
127
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
|
|
128
|
+
if (record.project !== project) return null;
|
|
129
|
+
const size = recordedJobSize(record.size);
|
|
130
|
+
const r = record.resources;
|
|
131
|
+
if (size === null || r === null || typeof r !== "object") return null; // an array carries no named key, so no evidence
|
|
132
|
+
const at = typeof record.endedAt === "string" ? Date.parse(record.endedAt) : NaN;
|
|
133
|
+
if (!Number.isFinite(at) || at > nowMs + SUGGEST_CLOCK_SKEW_MS || at < nowMs - SUGGEST_WINDOW_DAYS * DAY_MS) return null;
|
|
134
|
+
const limitBytes = size.memMiB * MIB;
|
|
135
|
+
const memPeak = isCount(r.memPeak) ? Math.min(r.memPeak, limitBytes) : null;
|
|
136
|
+
const start = typeof record.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
|
|
137
|
+
const wallMs = Number.isFinite(start) ? at - start : NaN;
|
|
138
|
+
const wallUsec = Number.isSafeInteger(wallMs) && wallMs > 0 && wallMs <= SUGGEST_MAX_WALL_MS ? wallMs * 1000 : null;
|
|
139
|
+
let cpu = { cpuUsec: null, throttledUsec: null };
|
|
140
|
+
if (wallUsec !== null && isCount(r.cpuUsec) && isCount(r.throttledUsec)) {
|
|
141
|
+
const cpuCap = (wallUsec * JOB_CPUS_CEILING_CENTI) / 100;
|
|
142
|
+
cpu = { cpuUsec: Math.min(r.cpuUsec, cpuCap), throttledUsec: Math.min(r.throttledUsec, wallUsec) };
|
|
143
|
+
}
|
|
144
|
+
// unclamped: it is only ever compared (exactly, in BigInt) with 1% of the wall, and a stall past the wall is above it
|
|
145
|
+
const memFullUsec = wallUsec !== null && isCount(r.memFullUsec) ? r.memFullUsec : null;
|
|
146
|
+
if (memPeak === null && cpu.cpuUsec === null) return null;
|
|
147
|
+
return { at, size, memPeak, oom: record.reason === "oom-killed", wallUsec, ...cpu, memFullUsec };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** a x b > c x d, exactly. */
|
|
151
|
+
const productAbove = (a, b, c, d) => BigInt(a) * BigInt(b) > BigInt(c) * BigInt(d);
|
|
152
|
+
/** 1.25 x bytes in MiB, rounded up: ceil(5 x bytes / (4 x MiB)). Equally, bytes / 0.8. */
|
|
153
|
+
const quarterMoreMiB = (bytes) => Math.ceil((5 * bytes) / (4 * MIB));
|
|
154
|
+
/** 1.25 x the cores of a CPU sample, in hundredths, rounded up: ceil(cpuUsec x 125 / wall). */
|
|
155
|
+
const quarterMoreCenti = (e) => Number((BigInt(e.cpuUsec) * 125n + BigInt(e.wallUsec) - 1n) / BigInt(e.wallUsec));
|
|
156
|
+
|
|
157
|
+
/** A raise held to the cap: `{ suggested, held }`. `wanted` is already rounded and at most the largest size. */
|
|
158
|
+
function heldToCap(wanted, current, cap) {
|
|
159
|
+
if (wanted <= current) return { suggested: null, held: "largest" }; // the largest size there is
|
|
160
|
+
if (!Number.isSafeInteger(cap)) return { suggested: null, held: "no-cap" };
|
|
161
|
+
if (cap <= current) return { suggested: null, held: "largest" };
|
|
162
|
+
if (wanted > cap) return { suggested: cap, held: "cap" };
|
|
163
|
+
return { suggested: wanted, held: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function suggestMemory(runs, current, cap) {
|
|
167
|
+
const measured = runs.filter((e) => e.memPeak !== null);
|
|
168
|
+
const relevant = measured.filter((e) => e.size.memMiB >= current);
|
|
169
|
+
const samples = relevant.length;
|
|
170
|
+
const p95 = samples > 0 ? rank(relevant.map((e) => e.memPeak).sort((a, b) => a - b), 95) : null;
|
|
171
|
+
const ooms = relevant.filter((e) => e.oom).length;
|
|
172
|
+
const maxPeak = measured.length > 0 ? Math.max(...measured.map((e) => e.memPeak)) : null;
|
|
173
|
+
const oomSizes = measured.filter((e) => e.oom).map((e) => e.size.memMiB);
|
|
174
|
+
const largestOom = oomSizes.length > 0 ? Math.max(...oomSizes) : null;
|
|
175
|
+
// at the limit (90% of it or more: peak x 10 >= limit x 9) AND stalled for memory more than 1% of the wall.
|
|
176
|
+
const pressured = relevant.filter((e) => !productAbove(e.size.memMiB * MIB, 9, e.memPeak, 10) && e.memFullUsec !== null && productAbove(e.memFullUsec, 100, e.wallUsec, 1)).length;
|
|
177
|
+
const evidence = { samples, p95MiB: p95 === null ? null : Math.ceil(p95 / MIB), maxPeakMiB: maxPeak === null ? null : Math.ceil(maxPeak / MIB), ooms, largestOomMiB: largestOom, pressured };
|
|
178
|
+
const fact = pressured > 0 ? "pressure" : null;
|
|
179
|
+
const out = (reason, suggested, more = {}) => ({ current, suggested: suggested === null || suggested === current ? null : suggested, reason, evidence, fact, held: null, wanted: null, ...more });
|
|
180
|
+
if (ooms > 0) {
|
|
181
|
+
const wanted = roundMemoryUp(Math.ceil((Math.max(current, largestOom) * 3) / 2));
|
|
182
|
+
const { suggested, held } = heldToCap(wanted, current, cap);
|
|
183
|
+
return out("oom-killed", suggested, { held, wanted });
|
|
184
|
+
}
|
|
185
|
+
if (samples < SUGGEST_MIN_SAMPLES) return out("not-enough-runs", null);
|
|
186
|
+
// lower only when 1.25 x p95 <= 0.75 x current, that is 5 x p95 <= 3 x current (in bytes).
|
|
187
|
+
if (!productAbove(5, p95, 3, current * MIB)) {
|
|
188
|
+
const floor = Math.max(roundMemoryUp(quarterMoreMiB(maxPeak)), largestOom === null ? 0 : roundMemoryUp(Math.ceil((largestOom * 3) / 2)));
|
|
189
|
+
const lower = Math.max(roundMemoryUp(quarterMoreMiB(p95)), floor);
|
|
190
|
+
return lower < current ? out("oversized", lower) : out("fits", null);
|
|
191
|
+
}
|
|
192
|
+
return out("fits", null);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function suggestCpus(runs, current) {
|
|
196
|
+
const measured = runs.filter((e) => e.cpuUsec !== null);
|
|
197
|
+
const relevant = measured.filter((e) => e.size.cpuCenti >= current);
|
|
198
|
+
const samples = relevant.length;
|
|
199
|
+
// ordered by the exact fraction, never a float: a/b < c/d <=> a x d < c x b.
|
|
200
|
+
const byFraction = (num) => (x, y) => (productAbove(num(y), x.wallUsec, num(x), y.wallUsec) ? -1 : productAbove(num(x), y.wallUsec, num(y), x.wallUsec) ? 1 : 0);
|
|
201
|
+
const throttle = samples > 0 ? rank([...relevant].sort(byFraction((e) => e.throttledUsec)), 50) : null;
|
|
202
|
+
const busy = samples > 0 ? rank([...relevant].sort(byFraction((e) => e.cpuUsec)), 95) : null;
|
|
203
|
+
const busiest = measured.length > 0 ? [...measured].sort(byFraction((e) => e.cpuUsec)).at(-1) : null;
|
|
204
|
+
const coresOf = (e) => (e === null ? null : Math.ceil((e.cpuUsec * 100) / e.wallUsec));
|
|
205
|
+
const evidence = {
|
|
206
|
+
samples,
|
|
207
|
+
p95CoresCenti: coresOf(busy),
|
|
208
|
+
maxCoresCenti: coresOf(busiest),
|
|
209
|
+
throttledPct: throttle === null ? null : Math.round((throttle.throttledUsec * 100) / throttle.wallUsec),
|
|
210
|
+
};
|
|
211
|
+
const out = (reason, suggested, fact = null) => ({ current, suggested: suggested === null || suggested === current ? null : suggested, reason, evidence, fact, held: null, wanted: null });
|
|
212
|
+
if (samples < SUGGEST_MIN_SAMPLES) return out("not-enough-runs", null);
|
|
213
|
+
// throttled more than 25% of the wall time (throttled x 4 > wall): a fact about the host's ceiling, never a call.
|
|
214
|
+
const fact = productAbove(throttle.throttledUsec, 4, throttle.wallUsec, 1) ? "ceiling" : null;
|
|
215
|
+
// p95 cores below 0.4 x cpus: cpuUsec / wall < 0.4 x current / 100, that is cpuUsec x 1000 < 4 x current x wall.
|
|
216
|
+
if (productAbove(4 * current, busy.wallUsec, busy.cpuUsec, 1000)) {
|
|
217
|
+
const lower = Math.max(roundCpusUp(quarterMoreCenti(busy)), roundCpusUp(quarterMoreCenti(busiest)));
|
|
218
|
+
return lower < current ? out("underused", lower, fact) : out("fits", null, fact);
|
|
219
|
+
}
|
|
220
|
+
return out("fits", null, fact);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The runs a suggestion reads, judged: the project's evidence in the window, newest first, at most SUGGEST_WINDOW_RUNS. */
|
|
224
|
+
function windowRuns(project, records, nowMs) {
|
|
225
|
+
return (Array.isArray(records) ? records : [])
|
|
226
|
+
.map((r) => evidenceOf(r, project, nowMs))
|
|
227
|
+
.filter((e) => e !== null)
|
|
228
|
+
.sort((a, b) => b.at - a.at)
|
|
229
|
+
.slice(0, SUGGEST_WINDOW_RUNS);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The memory peaks a suggestion reads, for a chart of peak against size over time (the insights page): `[{ at, peakMiB,
|
|
234
|
+
* sizeMiB, oom }]`, OLDEST first, the same runs `suggestSize` reads and judged the same way (a peak clamped to its run's
|
|
235
|
+
* size, rounded up to whole MiB), so the chart shows exactly the evidence. Runs with no readable peak are left out. Pure.
|
|
236
|
+
*/
|
|
237
|
+
export function peakSeries({ project, records = [], now }) {
|
|
238
|
+
const nowMs = now instanceof Date ? now.getTime() : now;
|
|
239
|
+
if (!Number.isFinite(nowMs)) throw new TypeError("peakSeries needs `now` (millis or a Date)");
|
|
240
|
+
return windowRuns(project, records, nowMs)
|
|
241
|
+
.filter((e) => e.memPeak !== null)
|
|
242
|
+
.reverse()
|
|
243
|
+
.map((e) => ({ at: e.at, peakMiB: Math.ceil(e.memPeak / MIB), sizeMiB: e.size.memMiB, oom: e.oom }));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const capDim = (v) => (Number.isSafeInteger(v) && v > 0 ? v : null);
|
|
247
|
+
const isShare = (share) => Number.isSafeInteger(share) && share > 0 && share <= 100;
|
|
248
|
+
/**
|
|
249
|
+
* A budget dimension as a project with `share` may use it: floor(budget x share / 100) for an integer budget (the
|
|
250
|
+
* worker's own `largestFit`; a job above it is refused), the budget itself without a share, and `off` or unknown as
|
|
251
|
+
* they are (a share of no number refuses nothing).
|
|
252
|
+
*/
|
|
253
|
+
const shareOf = (budget, share) => (Number.isSafeInteger(budget) && isShare(share) ? Math.floor((budget * share) / 100) : budget);
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The cap of one host (doctor's): per dimension its budget where that is a number (the project's `share` of it when the
|
|
257
|
+
* project has a `hostShare`), else (budget `off` or unknown) the host's own total (`{ memMiB, cpuCenti }`, the
|
|
258
|
+
* runtime's memory and CPU count), else null: no cap known.
|
|
259
|
+
*/
|
|
260
|
+
export function hostCap(budget, total, share = null) {
|
|
261
|
+
return { memMiB: capDim(shareOf(budget?.memMiB, share)) ?? capDim(total?.memMiB), cpuCenti: capDim(shareOf(budget?.cpuCenti, share)) ?? capDim(total?.cpuCenti) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The cap across live hosts (the panel's and the insights page's), judged per host on its OWN pair of budgets: in each
|
|
266
|
+
* dimension, the largest budget of a host whose OTHER dimension holds the project's current size (`off` and unknown
|
|
267
|
+
* hold anything), each budget taken as the project's `share` of it where the project has a `hostShare`. A host that publishes `off` or nothing in a dimension gives no number there (the panel cannot read
|
|
268
|
+
* a host's memory or CPU count), so where no host gives one the cap is null and a raise offers no call. The largest
|
|
269
|
+
* per dimension across DIFFERENT hosts would be a pair no host has.
|
|
270
|
+
*/
|
|
271
|
+
export function fleetCap(budgets, current, share = null) {
|
|
272
|
+
const { memMiB, cpuCenti } = fleetCapWhy(budgets, current, share);
|
|
273
|
+
return { memMiB: memMiB.cap, cpuCenti: cpuCenti.cap };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** `fleetCap` with, per dimension, why no cap is known: `{ memMiB: { cap, missing }, cpuCenti: { cap, missing } }`. */
|
|
277
|
+
function fleetCapWhy(budgets, current, share) {
|
|
278
|
+
const list = (Array.isArray(budgets) ? budgets : []).filter((b) => b !== null && typeof b === "object");
|
|
279
|
+
const holds = (v, need) => !Number.isSafeInteger(v) || shareOf(v, share) >= need;
|
|
280
|
+
const best = (key, other, need) => {
|
|
281
|
+
const known = list.filter((b) => holds(b[other], need)).map((b) => capDim(shareOf(b[key], share))).filter((v) => v !== null);
|
|
282
|
+
if (known.length > 0) return { cap: Math.max(...known), missing: null };
|
|
283
|
+
if (list.some((b) => capDim(b[key]) !== null)) return { cap: null, missing: "none-holds" };
|
|
284
|
+
return { cap: null, missing: list.length > 0 && list.every((b) => b[key] === Infinity) ? "off" : "unread" };
|
|
285
|
+
};
|
|
286
|
+
return { memMiB: best("memMiB", "cpuCenti", current?.cpuCenti), cpuCenti: best("cpuCenti", "memMiB", current?.memMiB) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* How one host's admission refuses `pair` for ever, per dimension, or null when it may start there some day: the
|
|
291
|
+
* worker's own `neverFits` (host-budget.mjs) restated, since this module imports nothing heavy, and held equal to it
|
|
292
|
+
* over a grid by size-suggest.test.mjs. A dimension above the whole budget is `host`; only when neither is, a dimension
|
|
293
|
+
* above the project's `share` of it is `share`. `off` (Infinity) and unknown (null) refuse nothing.
|
|
294
|
+
*/
|
|
295
|
+
function refusedOn(pair, budget, share) {
|
|
296
|
+
const over = (k, pct) => Number.isSafeInteger(budget[k]) && BigInt(pair[k]) * 100n > BigInt(pct) * BigInt(budget[k]);
|
|
297
|
+
const host = { memMiB: over("memMiB", 100), cpuCenti: over("cpuCenti", 100) };
|
|
298
|
+
if (host.memMiB || host.cpuCenti) return { memMiB: host.memMiB ? "host" : null, cpuCenti: host.cpuCenti ? "host" : null };
|
|
299
|
+
if (share === null || share === undefined) return null;
|
|
300
|
+
const part = { memMiB: over("memMiB", share), cpuCenti: over("cpuCenti", share) };
|
|
301
|
+
return part.memMiB || part.cpuCenti ? { memMiB: part.memMiB ? "share" : null, cpuCenti: part.cpuCenti ? "share" : null } : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* THE ONE RULE for offering an apply call (DES-SIZE-SUGGESTIONS): a call is offered exactly when admission would
|
|
306
|
+
* accept the suggested job, the pair `{ memMiB, cpuCenti }` (each dimension the suggested size, or the current one where
|
|
307
|
+
* nothing is suggested). Doctor passes its one host's budget, the panel and the insights page the live hosts' budgets.
|
|
308
|
+
* Only a host that published an integer budget in some dimension is judged: with every budget `off` or unknown,
|
|
309
|
+
* admission refuses nothing, so the call is offered. The call is withheld when EVERY judged host refuses the pair (for
|
|
310
|
+
* doctor: its host refuses it). Returns null (offer the call) or the refusal, `{ memMiB, cpuCenti }`, each `host`
|
|
311
|
+
* (above every judged host's budget), `share` (above the project's `hostShare` of every judged host's budget) or null
|
|
312
|
+
* (not refused by every judged host in that dimension: then the pair as a whole is what no host admits).
|
|
313
|
+
*/
|
|
314
|
+
export function sizeRefusal(pair, budgets, share = null) {
|
|
315
|
+
const judged = (Array.isArray(budgets) ? budgets : []).filter((b) => b !== null && typeof b === "object" && (Number.isSafeInteger(b.memMiB) || Number.isSafeInteger(b.cpuCenti)));
|
|
316
|
+
if (judged.length === 0) return null;
|
|
317
|
+
const each = judged.map((b) => refusedOn(pair, b, share));
|
|
318
|
+
if (each.some((r) => r === null)) return null;
|
|
319
|
+
const dim = (k) => (each.every((r) => r[k] !== null) ? (each.every((r) => r[k] === "host") ? "host" : "share") : null);
|
|
320
|
+
return { memMiB: dim("memMiB"), cpuCenti: dim("cpuCenti") };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A refusal (`sizeRefusal`) in words, naming the dimension that does not fit: `memory 6g is above this host's budget
|
|
325
|
+
* (4g)`, `its 4 CPUs are above its hostShare (40%) of this host's budget (3.2 CPUs)`. A dimension the suggestion changes
|
|
326
|
+
* is named by its new size, one it keeps by "its". With `budget` (doctor's one host) the words are that host's and name
|
|
327
|
+
* its limit; without it (the panel) they speak of every live host.
|
|
328
|
+
*/
|
|
329
|
+
export function refusalWords(refusal, suggestion, share = null, budget = null) {
|
|
330
|
+
if (refusal === null || refusal === undefined) return "";
|
|
331
|
+
const where = budget === null ? "every live host's budget" : "this host's budget";
|
|
332
|
+
const m = suggestion?.memory ?? {};
|
|
333
|
+
const c = suggestion?.cpu ?? {};
|
|
334
|
+
const mem = m.suggested ?? m.current;
|
|
335
|
+
const cpus = c.suggested ?? c.current;
|
|
336
|
+
const limit = (k, kind, text) => (budget === null || !Number.isSafeInteger(budget[k]) ? "" : ` (${text(kind === "share" ? Math.floor((budget[k] * share) / 100) : budget[k])})`);
|
|
337
|
+
const above = (k, kind, text) => `above ${kind === "share" ? `its hostShare (${share}%) of ${where}` : where}${limit(k, kind, text)}`;
|
|
338
|
+
const parts = [];
|
|
339
|
+
if (refusal.memMiB) parts.push(`${m.suggested ? "memory" : "its memory"} ${formatMemory(mem)} is ${above("memMiB", refusal.memMiB, formatMemory)}`);
|
|
340
|
+
if (refusal.cpuCenti) parts.push(`${c.suggested ? "" : "its "}${cpusText(cpus)} ${cpus === 100 ? "is" : "are"} ${above("cpuCenti", refusal.cpuCenti, cpusText)}`);
|
|
341
|
+
return parts.length > 0 ? parts.join(" and ") : `memory ${formatMemory(mem)} with ${cpusText(cpus)} fits no live host's budget`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The suggestion for one project. Pure: no I/O, no clock read (`now` is injected, millis or a Date).
|
|
346
|
+
*
|
|
347
|
+
* `records` is any list of run records (other projects' are skipped), `current` the project's size now (`{ memMiB,
|
|
348
|
+
* cpuCenti }`, `resolveJobSize`'s answer), `cap` the largest size a raise may reach on ONE host (`{ memMiB, cpuCenti }`,
|
|
349
|
+
* each an integer or null for unknown; `hostCap`), or instead `hosts`, the live hosts' budget pairs (`fleetCap` judges
|
|
350
|
+
* them per host, against the size the other dimension will have), and `hostShare` the project's row's share (an
|
|
351
|
+
* integer percent, or null), which caps each integer budget at floor(budget x hostShare / 100) on the `hosts` path (on
|
|
352
|
+
* the `cap` path the caller passes `hostCap(budget, total, hostShare)`).
|
|
353
|
+
*
|
|
354
|
+
* Returns `{ project, runs, memory, cpu }`: `runs` the records read (after the window), and per dimension `{ current,
|
|
355
|
+
* suggested, reason, evidence, fact, held, wanted, overBudget }`, where `suggested` is null when the size should stay,
|
|
356
|
+
* `reason` one of `MEMORY_REASONS` / `CPU_REASONS`, `fact` one of `SIZE_FACTS` or null, `held` one of `MEMORY_HELD` or
|
|
357
|
+
* null, `cap` the cap the dimension was judged against (null for none known), `capMissing` one of `MEMORY_CAP_MISSING` where a `hosts` raise is held `no-cap` (else null), `wanted` the raise before the cap (null for no raise), and `overBudget` true when a suggested LOWERING is still
|
|
358
|
+
* above the cap (a size already larger than the host). NEVER throws on records.
|
|
359
|
+
*/
|
|
360
|
+
export function suggestSize({ project, records = [], current, cap = null, hosts = null, hostShare = null, now }) {
|
|
361
|
+
const nowMs = now instanceof Date ? now.getTime() : now;
|
|
362
|
+
if (!Number.isFinite(nowMs)) throw new TypeError("suggestSize needs `now` (millis or a Date)");
|
|
363
|
+
if (recordedJobSize({ ...current, source: "project" }) === null) throw new TypeError("suggestSize needs the current size ({ memMiB, cpuCenti })");
|
|
364
|
+
const runs = windowRuns(project, records, nowMs);
|
|
365
|
+
// With `hosts`, each dimension's cap is judged per host against the size the OTHER dimension will have: the CPUs are
|
|
366
|
+
// decided first (they have no raise, so no cap bends them), then the memory against the hosts that hold those CPUs,
|
|
367
|
+
// then the CPUs' flag against the hosts that hold that memory.
|
|
368
|
+
const fleet = Array.isArray(hosts);
|
|
369
|
+
const capOf = (other) => (fleet ? fleetCapWhy(hosts, other, hostShare) : { memMiB: { cap: cap?.memMiB, missing: null }, cpuCenti: { cap: cap?.cpuCenti, missing: null } });
|
|
370
|
+
const cpu = suggestCpus(runs, current.cpuCenti);
|
|
371
|
+
const memWhy = capOf({ memMiB: current.memMiB, cpuCenti: cpu.suggested ?? current.cpuCenti }).memMiB;
|
|
372
|
+
const memCap = capDim(memWhy.cap);
|
|
373
|
+
const memory = suggestMemory(runs, current.memMiB, memCap);
|
|
374
|
+
const cpuCap = capDim(capOf({ memMiB: memory.suggested ?? current.memMiB, cpuCenti: current.cpuCenti }).cpuCenti.cap);
|
|
375
|
+
const above = (dim, c) => dim.suggested !== null && c !== null && dim.suggested > c;
|
|
376
|
+
const capMissing = memory.held === "no-cap" ? memWhy.missing : null;
|
|
377
|
+
return { project, runs: runs.length, memory: { ...memory, cap: memCap, capMissing, overBudget: above(memory, memCap) }, cpu: { ...cpu, cap: cpuCap, capMissing: null, overBudget: above(cpu, cpuCap) } };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The exact admin call that applies a suggestion, or null when there is nothing to apply: `dispatch_limit_edit` with the
|
|
382
|
+
* index of the project's `project:<id>` row in `limits` (the parsed scoped-limits rows, in file order) and only the
|
|
383
|
+
* changed fields, or `dispatch_limit_add` with the row's scope when the project has none. Fields in the one spelling the
|
|
384
|
+
* parser stores (`formatMemory`, `formatCpus` as a number).
|
|
385
|
+
*/
|
|
386
|
+
export function suggestionCall(suggestion, limits = []) {
|
|
387
|
+
const fields = {};
|
|
388
|
+
if (suggestion?.memory?.suggested) fields.memory = formatMemory(suggestion.memory.suggested);
|
|
389
|
+
if (suggestion?.cpu?.suggested) fields.cpus = Number(formatCpus(suggestion.cpu.suggested));
|
|
390
|
+
if (Object.keys(fields).length === 0) return null;
|
|
391
|
+
const scope = `project:${suggestion.project}`;
|
|
392
|
+
const index = (Array.isArray(limits) ? limits : []).findIndex((l) => l?.scope === scope);
|
|
393
|
+
return index >= 0 ? `dispatch_limit_edit ${JSON.stringify({ index, ...fields })}` : `dispatch_limit_add ${JSON.stringify({ scope, ...fields })}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
397
|
+
/** Cores in words: `1 core`, `0.5 cores`, `2 cores`. */
|
|
398
|
+
export const coresText = (centi) => `${formatCpus(centi)} core${centi === 100 ? "" : "s"}`;
|
|
399
|
+
/** CPUs in words: `1 CPU`, `0.5 CPUs`, `2 CPUs`. */
|
|
400
|
+
export const cpusText = (centi) => `${formatCpus(centi)} CPU${centi === 100 ? "" : "s"}`;
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The evidence of a suggestion in words, for doctor, the panel and the insights page alike: `{ memory, cpu, memoryHeld,
|
|
404
|
+
* memoryFact, cpuFact }`, each a short clause (empty when it does not apply), such as `2 runs ended oom-killed (the
|
|
405
|
+
* largest size killed 4g)` or `p95 peak 2560m, largest 3g, over 24 runs`. Ids and numbers only. A fact is worded as
|
|
406
|
+
* information and a held raise as what the host offers: none of them is an instruction, and none advises growing a
|
|
407
|
+
* host's budget.
|
|
408
|
+
*/
|
|
409
|
+
export function suggestionEvidence(suggestion) {
|
|
410
|
+
const m = suggestion?.memory ?? {};
|
|
411
|
+
const c = suggestion?.cpu ?? {};
|
|
412
|
+
const me = m.evidence ?? {};
|
|
413
|
+
const ce = c.evidence ?? {};
|
|
414
|
+
const peaks = `p95 peak ${formatMemory(me.p95MiB ?? 0)}, largest ${formatMemory(me.maxPeakMiB ?? 0)}, over ${plural(me.samples, "run")}`;
|
|
415
|
+
const cores = `p95 ${coresText(ce.p95CoresCenti ?? 0)} used, largest ${coresText(ce.maxCoresCenti ?? 0)}, over ${plural(ce.samples, "run")}`;
|
|
416
|
+
const memWords = {
|
|
417
|
+
"oom-killed": `${plural(me.ooms, "run")} ended oom-killed (the largest size killed ${formatMemory(me.largestOomMiB ?? 0)})`,
|
|
418
|
+
"not-enough-runs": `${me.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements it needs`,
|
|
419
|
+
oversized: peaks,
|
|
420
|
+
fits: peaks,
|
|
421
|
+
};
|
|
422
|
+
const cpuWords = {
|
|
423
|
+
"not-enough-runs": `${ce.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements it needs`,
|
|
424
|
+
underused: cores,
|
|
425
|
+
fits: cores,
|
|
426
|
+
};
|
|
427
|
+
const wanted = Number.isSafeInteger(m.wanted) ? formatMemory(m.wanted) : "";
|
|
428
|
+
const heldWords = {
|
|
429
|
+
cap: `this project's runs need more than this host offers: they ask for ${wanted}, the largest here is ${formatMemory(m.suggested ?? 0)}`,
|
|
430
|
+
// a size ABOVE the cap (set by hand, or a hostShare lowered since) is not "at" the largest: say which it is
|
|
431
|
+
largest: Number.isSafeInteger(m.cap) && m.current > m.cap ? `already above the largest size this host offers (${formatMemory(m.cap)})` : "already at the largest size this host offers",
|
|
432
|
+
"no-cap": `they ask for ${wanted}, but the largest size this host offers is not known here, so no call is offered`,
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
memory: memWords[m.reason] ?? "",
|
|
436
|
+
cpu: cpuWords[c.reason] ?? "",
|
|
437
|
+
memoryHeld: heldWords[m.held] ?? "",
|
|
438
|
+
memoryFact: m.fact === "pressure" ? `${me.pressured} of ${plural(me.samples, "run")} reached the memory limit while stalled for memory more than 1% of their time` : "",
|
|
439
|
+
cpuFact: c.fact === "ceiling" ? `the median run was held back ${ce.throttledPct}% of its time by this host's CPU ceiling (its CPU budget, shared by every job), which a job's cpus do not change` : "",
|
|
440
|
+
};
|
|
441
|
+
}
|