@edgehero/pi-dispatch 3.0.0 → 4.0.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/.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 +39 -1
- 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 +631 -21
- package/src/env-allowlist.mjs +23 -5
- package/src/host-budget.mjs +736 -0
- package/src/host-pi.mjs +1 -1
- 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/model-catalog.mjs +1 -1
- package/src/model-endpoints.mjs +22 -0
- package/src/models-json.mjs +10 -4
- package/src/output-cap.mjs +3 -3
- package/src/prepare.mjs +7 -3
- package/src/processor.mjs +91 -14
- package/src/reserved-env.mjs +3 -2
- 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 +8 -5
package/src/sandbox.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { DEFAULT_BACKEND, PODMAN_BACKEND, UNATTRIBUTED_BACKEND, parseBackendFloo
|
|
|
9
9
|
import { configError } from "./config.mjs";
|
|
10
10
|
import { assertJobUser, CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs";
|
|
11
11
|
import { buildDockerRunArgs, buildPodmanRunArgs, insideDir } from "./docker-run.mjs";
|
|
12
|
+
import { CGROUP_PARENT, cgroupParentFor } from "./cpu-reserve.mjs";
|
|
13
|
+
import { DEFAULT_JOB_SIZE, recordedJobSize } from "./job-size.mjs";
|
|
12
14
|
import { makeImagePreflight } from "./image-preflight.mjs";
|
|
13
15
|
import { decideJobUser, JOB_USER_FIX, makeDaemonFactsReader, relabelsPrivateMounts, resolveImageUser, socketFacts } from "./job-user.mjs";
|
|
14
16
|
import { DEFAULT_EGRESS_PROXY, NETWORK_SUFFIX, createJobNetwork, egressArmed, egressEnv, egressProxyName, networkEndpoints, networkExists, networkNameFor, removeJobNetwork, removeNetworkOrSay } from "./egress.mjs";
|
|
@@ -22,7 +24,8 @@ import { isSandboxTombstone, readManifest, readRetained, sandboxDeadline, sandbo
|
|
|
22
24
|
*
|
|
23
25
|
* A SECOND container shape, deliberately not a second copy of the first. The argv comes from the run's VENUE's own
|
|
24
26
|
* job builder (`buildDockerRunArgs` on `local`, `buildPodmanRunArgs` on `podman`, `SANDBOX_LAUNCHERS` below) through
|
|
25
|
-
* its `extraFlags` seam, so `ISOLATION_FLAGS
|
|
27
|
+
* its `extraFlags` seam, so `ISOLATION_FLAGS` and the size flags (`--memory`, `--memory-swap`, `--cpu-shares`, `--shm-size`
|
|
28
|
+
* and the `--cpus` ceiling, at the RUN's recorded size, issue #596) (and on podman keep-id and
|
|
26
29
|
* `PODMAN_PINNED_FLAGS`) reach this container BY CONSTRUCTION: a future change to the boundary cannot land on job
|
|
27
30
|
* containers and miss this one, which is the whole reason for reusing the builder rather than writing a leaner argv
|
|
28
31
|
* here.
|
|
@@ -154,7 +157,7 @@ function inPortRange(n) {
|
|
|
154
157
|
* @param relabel true where the job's own mounts carried `:Z` (issue #355), so the retained ones do again
|
|
155
158
|
* @param workspaceOwned true when `workspace` is the retained clone (the worker's own), false for an operator's folder
|
|
156
159
|
*/
|
|
157
|
-
export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {}, user = null, home = null, relabel = false, workspaceOwned = false }) {
|
|
160
|
+
export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {}, user = null, home = null, relabel = false, workspaceOwned = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cgroupParent = CGROUP_PARENT }) {
|
|
158
161
|
// Thrown, not defaulted to docker: a caller naming a venue this file has no launcher for is assembling a session
|
|
159
162
|
// in a runtime nobody chose, which is the one mistake the table exists to make impossible.
|
|
160
163
|
const launcher = sandboxLauncher(venue);
|
|
@@ -192,6 +195,13 @@ export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, work
|
|
|
192
195
|
// operator's folder, which a private label would take away from every other container.
|
|
193
196
|
relabel,
|
|
194
197
|
workspaceOwned,
|
|
198
|
+
// Issue #596: the RUN's size (its manifest's, `sandboxSizeOf`), so the shell has the memory, swap bound and CPU
|
|
199
|
+
// weight the job had, and the same `--cpus` ceiling from this CLI's own runtime read.
|
|
200
|
+
size,
|
|
201
|
+
hostCpus,
|
|
202
|
+
// Issue #596, phase 2: the jobs' parent cgroup, as a job on this venue gets it (none where Podman's cgroup manager
|
|
203
|
+
// is not systemd, `cgroupParentFor`), so a session shares the host's CPU reserve with the jobs.
|
|
204
|
+
cgroupParent,
|
|
195
205
|
// The terminal's two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
|
|
196
206
|
// forgotten session closes itself. HOME beside `--user` and the proxy variables below are the rest.
|
|
197
207
|
// `buildDockerRunArgs` skips undefined, so an unset TERM or a disabled idle timeout emits nothing rather than
|
|
@@ -1029,6 +1039,9 @@ export async function openSandbox({
|
|
|
1029
1039
|
if (resolved.refused) return resolved;
|
|
1030
1040
|
// Admitted by `resolveSandbox`, so never null here.
|
|
1031
1041
|
const { bin } = sandboxLauncher(resolved.venue);
|
|
1042
|
+
// Issue #596: the run's size, decided before anything is asked or created. A recorded size that is malformed refuses.
|
|
1043
|
+
const size = sandboxSizeOf(resolved.manifest);
|
|
1044
|
+
if (size === null) return { refused: "size-invalid", message: `the run's recorded size is malformed, so the size ${jobId} ran at is unknown; re-run the job instead` };
|
|
1032
1045
|
|
|
1033
1046
|
// `--publish` AND AN ARMED POLICY ARE OPPOSITE DIRECTIONS, and docker resolves the contradiction SILENTLY
|
|
1034
1047
|
// (issue #362). An armed policy puts this shell on its own `--internal` network, and a container attached
|
|
@@ -1134,6 +1147,9 @@ export async function openSandbox({
|
|
|
1134
1147
|
user: jobUser?.user ?? null,
|
|
1135
1148
|
home: jobUser?.home ?? null,
|
|
1136
1149
|
relabel: jobUser?.relabel === true,
|
|
1150
|
+
size,
|
|
1151
|
+
hostCpus: jobUser?.hostCpus ?? null,
|
|
1152
|
+
cgroupParent: jobUser?.cgroupParent === null ? null : CGROUP_PARENT,
|
|
1137
1153
|
// By containment, the rule `rebaseWorkspace` already moves the retained clone by: a workspace inside the retained
|
|
1138
1154
|
// job dir is the worker's own clone, one outside it is the operator's folder. Not by the manifest's `kind`, so a run
|
|
1139
1155
|
// retained before a preparer moved its clone is still judged by where the files actually are.
|
|
@@ -1421,15 +1437,21 @@ async function decideLocalSandboxJobUser({
|
|
|
1421
1437
|
// Issue #452, gate round 5: the facts read ONCE here even where the uid needs none of them (a VM-backed platform, an
|
|
1422
1438
|
// endpoint on another machine), and carried beside the answer for the teardown's detach gate, which otherwise reads
|
|
1423
1439
|
// `docker info` again at teardown and, if that read fails, keeps the network (the Docker Desktop case).
|
|
1440
|
+
// Issue #596: the CPU count rides beside it, for the session's `--cpus` ceiling.
|
|
1441
|
+
let admittedCpus = null;
|
|
1424
1442
|
const admittedOn = async () => {
|
|
1425
1443
|
try {
|
|
1426
1444
|
const read = await readFacts();
|
|
1445
|
+
admittedCpus = read?.answered ? (read.facts?.hostCpus ?? null) : null;
|
|
1427
1446
|
return read?.answered ? runtimeFromFacts(read) : undefined;
|
|
1428
1447
|
} catch {
|
|
1429
1448
|
return undefined;
|
|
1430
1449
|
}
|
|
1431
1450
|
};
|
|
1432
|
-
if (platform === "darwin" || platform === "win32")
|
|
1451
|
+
if (platform === "darwin" || platform === "win32") {
|
|
1452
|
+
const runtime = await admittedOn();
|
|
1453
|
+
return withRuntime({ user: null, home: null }, runtime, admittedCpus);
|
|
1454
|
+
}
|
|
1433
1455
|
const stamp = manifest?.jobUser;
|
|
1434
1456
|
const read = readJobUserStamp(stamp);
|
|
1435
1457
|
if (read.malformed) return { ...MALFORMED_STAMP };
|
|
@@ -1467,7 +1489,8 @@ async function decideLocalSandboxJobUser({
|
|
|
1467
1489
|
if (rootful?.refusal) return { refused: PODMAN_CONF_WIDENS_JOB, message: rootfulConfRefusal(rootful.refusal) };
|
|
1468
1490
|
// `runtime` (issue #452, gate round 4): the facts this session was admitted on, for its teardown's detach gate.
|
|
1469
1491
|
const runtime = daemon?.answered ? runtimeFromFacts(daemon) : remoteRuntime;
|
|
1470
|
-
|
|
1492
|
+
const hostCpus = daemon?.answered ? (daemon.facts?.hostCpus ?? null) : admittedCpus;
|
|
1493
|
+
if (decision.mode === "image") return withRuntime({ user: null, home: null, ...relabel }, runtime, hostCpus);
|
|
1471
1494
|
const needsImage = identity.euid !== SHIPPED_IMAGE_UID;
|
|
1472
1495
|
const caps = needsImage ? await imageCapabilities(manifest?.image) : { ok: true, capabilities: [] };
|
|
1473
1496
|
if (needsImage && !caps?.ok) {
|
|
@@ -1478,7 +1501,7 @@ async function decideLocalSandboxJobUser({
|
|
|
1478
1501
|
return { refused: chosen.refused, message: `the retained image ${manifest?.image} does not declare anyUid, so it cannot run as the uid that owns this run's files (issue #341)` };
|
|
1479
1502
|
}
|
|
1480
1503
|
if (chosen.refused) return { refused: chosen.refused, message: `${JOB_USER_FIX[chosen.cause] ?? "the job user could not be decided"} (issue #341)` };
|
|
1481
|
-
return withRuntime({ user: chosen.user, home: chosen.home, ...relabel }, runtime);
|
|
1504
|
+
return withRuntime({ user: chosen.user, home: chosen.home, ...relabel }, runtime, hostCpus);
|
|
1482
1505
|
}
|
|
1483
1506
|
|
|
1484
1507
|
/**
|
|
@@ -1486,8 +1509,14 @@ async function decideLocalSandboxJobUser({
|
|
|
1486
1509
|
* non-enumerable, so the answer's shape, which callers compare and print, is what it always was, while `openSandbox`'s
|
|
1487
1510
|
* teardown hands it to the detach gate and reads the daemon nothing more.
|
|
1488
1511
|
*/
|
|
1489
|
-
function withRuntime(answer, runtime) {
|
|
1512
|
+
function withRuntime(answer, runtime, hostCpus = null, cgroupParent = CGROUP_PARENT) {
|
|
1490
1513
|
if (runtime !== undefined) Object.defineProperty(answer, "runtime", { value: runtime, enumerable: false });
|
|
1514
|
+
// Issue #596, phase 2: whether the session runs under the jobs' parent cgroup (`cgroupParentFor`), non-enumerable for
|
|
1515
|
+
// `runtime`'s reason. Set only when it is none, so every other answer is what it always was.
|
|
1516
|
+
if (cgroupParent === null) Object.defineProperty(answer, "cgroupParent", { value: null, enumerable: false });
|
|
1517
|
+
// Issue #596: the runtime's CPU count from the same read, for the session's `--cpus` ceiling. Non-enumerable for
|
|
1518
|
+
// `runtime`'s reason: the answer's shape stays what it always was.
|
|
1519
|
+
if (Number.isSafeInteger(hostCpus)) Object.defineProperty(answer, "hostCpus", { value: hostCpus, enumerable: false });
|
|
1491
1520
|
return answer;
|
|
1492
1521
|
}
|
|
1493
1522
|
|
|
@@ -1582,7 +1611,19 @@ async function decidePodmanSandboxJobUser({
|
|
|
1582
1611
|
if (chosen.unavailable) return { refused: "job-user-unknown", message: `which uid the sandbox may run as could not be decided (${chosen.reason}); is podman answering \`podman info\` as this account?` };
|
|
1583
1612
|
// `relabel` on podman is `podman info`'s SELinux fact, the rule a podman job's own mounts follow (issue #355).
|
|
1584
1613
|
// `runtime` (issue #452, gate round 4): the same read, for the session teardown's detach gate, so it reads nothing again.
|
|
1585
|
-
return withRuntime({ user: chosen.user, home: chosen.home, ...(chosen.relabel === true ? { relabel: true } : {}) }, { podman: true, rootless: info.info?.rootless ?? null, version: info.info?.version ?? null });
|
|
1614
|
+
return withRuntime({ user: chosen.user, home: chosen.home, ...(chosen.relabel === true ? { relabel: true } : {}) }, { podman: true, rootless: info.info?.rootless ?? null, version: info.info?.version ?? null }, info.info?.hostCpus ?? null, cgroupParentFor({ podman: true, cgroupManager: info.info?.cgroupManager ?? null }));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* The size a sandbox reopens a run at (issue #596): the size its manifest recorded, or the built-in 4g and 2 for a run
|
|
1619
|
+
* retained before sizes existed (no `size` key), which is the size every such run had. A `size` key that is PRESENT
|
|
1620
|
+
* and does not rebuild (`recordedJobSize`) is null, and the open refuses (`size-invalid`): the retention writes the key
|
|
1621
|
+
* only with a size, so a malformed one is damage, and repairing it to 4g would open a shell at a size the run never
|
|
1622
|
+
* had, larger than a small project's, without a word.
|
|
1623
|
+
*/
|
|
1624
|
+
export function sandboxSizeOf(manifest) {
|
|
1625
|
+
if (!manifest || typeof manifest !== "object" || !Object.hasOwn(manifest, "size")) return DEFAULT_JOB_SIZE;
|
|
1626
|
+
return recordedJobSize(manifest.size);
|
|
1586
1627
|
}
|
|
1587
1628
|
|
|
1588
1629
|
/**
|
package/src/scoped-limits.mjs
CHANGED
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
* its repo or folder row, then its project's row, then the global windows (`scopedLedgers`), and its keys are built from
|
|
40
40
|
* the project ROW's scope like every other row's, so a project needs no keyspace of its own.
|
|
41
41
|
*
|
|
42
|
+
* Issue #596 (phase 1) adds version 3: a project row may carry its jobs' size (`memory`, `cpus`) and the host budget's
|
|
43
|
+
* two knobs (`hostShare`, `minJobs`), see `SIZE_LIMIT_FIELDS`. This module only parses them; `job-size.mjs` resolves the
|
|
44
|
+
* size at pickup, and the host budget (`host-budget.mjs`, phase 2) enforces `hostShare` and `minJobs`.
|
|
45
|
+
*
|
|
42
46
|
* Custom: scoped limits validated inline per triggers.mjs/pause-windows.mjs precedent; zod not in deps
|
|
43
47
|
*/
|
|
44
48
|
|
|
@@ -51,6 +55,7 @@ import { splitModelEntry } from "./model-ref.mjs";
|
|
|
51
55
|
import { formatMicros, parseUsdMicros } from "./money.mjs";
|
|
52
56
|
import { parseScopeString, qualifiedScopeOf, scopeOf } from "./pause-windows.mjs";
|
|
53
57
|
import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
|
|
58
|
+
import { formatCpus, formatMemory, parseCpus, parseMemory } from "./job-size.mjs";
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* The highest schema version this build reads. A file declaring a higher one is refused loudly. The admin writes the
|
|
@@ -66,7 +71,26 @@ import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
|
|
|
66
71
|
* cap, a concurrency limit and a lease that one build enforces and another silently ignores. Version 2 makes every
|
|
67
72
|
* older build refuse the file loudly instead.
|
|
68
73
|
*/
|
|
69
|
-
export const SCOPED_LIMITS_VERSION =
|
|
74
|
+
export const SCOPED_LIMITS_VERSION = 3;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The four size fields of version 3 (issue #596), on a PROJECT row only, in display order:
|
|
78
|
+
* - `memory`: the project's jobs' memory (`"512m"`, `"4g"`; `job-size.mjs` `parseMemory`), stored in its one spelling;
|
|
79
|
+
* - `cpus`: their CPU weight (`0.5`, `2`; `parseCpus`), stored as a number;
|
|
80
|
+
* - `hostShare`: the most of a host's job budget the project's running jobs may hold together, as a whole PERCENTAGE
|
|
81
|
+
* from 1 to 100 (`50` is half). ENFORCED by each host's budget (`host-budget.mjs`): a job that would take the
|
|
82
|
+
* project past it waits, and a size above it is refused there for a job on that host's own queue
|
|
83
|
+
* (`job-size-exceeds-share`; on the shared queue it waits for a host whose share fits it);
|
|
84
|
+
* - `minJobs`: how many of the project's jobs a host should make room for before it admits other projects' jobs, a
|
|
85
|
+
* soft minimum. ENFORCED by the host budget's tier 1 holds (`host-budget.mjs` `rankHolds`).
|
|
86
|
+
* A version 1 or 2 file that carries one is refused naming version 3, for the version 2 reason: a 3.1.0 worker drops
|
|
87
|
+
* unknown fields, so the file would size its jobs on one build and not on another. Every released build refuses a
|
|
88
|
+
* version 3 file as newer, but only when it LOADS the file, at boot: a worker already running when the file becomes
|
|
89
|
+
* version 3 keeps its last good file on reload (`scoped_limits_reload_invalid`) and runs the project's jobs at the
|
|
90
|
+
* default size until it is upgraded and restarted. So every worker is upgraded and restarted before a size is written.
|
|
91
|
+
* A version 3 row also refuses a key it does not know, so a misspelled size field cannot drop out silently.
|
|
92
|
+
*/
|
|
93
|
+
export const SIZE_LIMIT_FIELDS = Object.freeze(["memory", "cpus", "hostShare", "minJobs"]);
|
|
70
94
|
|
|
71
95
|
/** The four job-count limit fields a row may carry, in display order. */
|
|
72
96
|
const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
@@ -74,6 +98,9 @@ const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
|
74
98
|
/** The three dollar window fields (version 2), in display order. Each maps to a dollar window: day, week, month. */
|
|
75
99
|
export const USD_LIMIT_FIELDS = Object.freeze(["dayUsd", "weekUsd", "monthUsd"]);
|
|
76
100
|
|
|
101
|
+
/** Every key a version 3 row may carry (issue #596). Any other key refuses a version 3 file (`normalizeLimit`). */
|
|
102
|
+
const V3_ROW_KEYS = new Set(["scope", ...LIMIT_FIELDS, ...USD_LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS]);
|
|
103
|
+
|
|
77
104
|
/**
|
|
78
105
|
* The scope prefix of a per-model row (issue #502 part 6): `model:<provider>/<model>`. Reserved in BOTH versions. A
|
|
79
106
|
* version 1 file naming it is refused (naming version 2) rather than read as a folder called `model:...`.
|
|
@@ -156,9 +183,10 @@ export function canonicalScope(job) {
|
|
|
156
183
|
|
|
157
184
|
/**
|
|
158
185
|
* Parse, validate, and normalize the scoped-limits file TEXT. Returns the normalized `limits` array
|
|
159
|
-
* (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal in a version 1 file,
|
|
160
|
-
* `{ scope, day, week, month, concurrent, dayUsd, weekUsd, monthUsd }` in a version 2 one,
|
|
161
|
-
* fields, unknown fields dropped
|
|
186
|
+
* (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal in a version 1 file,
|
|
187
|
+
* `{ scope, day, week, month, concurrent, dayUsd, weekUsd, monthUsd }` in a version 2 one, and that plus `memory,
|
|
188
|
+
* cpus, hostShare, minJobs` in a version 3 one, `null` for absent fields, unknown fields dropped in a version 1 or 2
|
|
189
|
+
* file -- the operator-file policy -- and refused in a version 3 one). Throws `configError` (fail-loud) on any
|
|
162
190
|
* malformed entry. `path` is for error messages only -- this function touches no filesystem.
|
|
163
191
|
*/
|
|
164
192
|
export function parseScopedLimits(text, path) {
|
|
@@ -173,7 +201,7 @@ export function parseScopedLimits(text, path) {
|
|
|
173
201
|
}
|
|
174
202
|
const version = parsed.version;
|
|
175
203
|
if (!Number.isInteger(version) || version < 1) {
|
|
176
|
-
throw configError(`scoped-limits file must have "version": 1
|
|
204
|
+
throw configError(`scoped-limits file must have "version": 1 to ${SCOPED_LIMITS_VERSION} (an integer >= 1): ${path}`);
|
|
177
205
|
}
|
|
178
206
|
if (version > SCOPED_LIMITS_VERSION) {
|
|
179
207
|
throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
|
|
@@ -240,10 +268,10 @@ function normalizeModelLimit(row, trimmed, at, path, version) {
|
|
|
240
268
|
// The runner folds model-less and overflow calls into an `other/other` usage row, so a row naming that pair
|
|
241
269
|
// could never be told apart from the fold.
|
|
242
270
|
if (`${ref.provider}/${ref.model}`.toLowerCase() === "other/other") throw configError(`${at}: model:other/other names the usage ledger's fold row, not a model: ${path}`);
|
|
243
|
-
for (const field of LIMIT_FIELDS) {
|
|
271
|
+
for (const field of [...LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS]) {
|
|
244
272
|
if (row[field] !== undefined && row[field] !== null) throw configError(`${at}: a model row carries dayUsd, weekUsd and monthUsd only (${field} is refused): ${path}`);
|
|
245
273
|
}
|
|
246
|
-
const norm = { scope: `${MODEL_SCOPE_PREFIX}${ref.provider}/${ref.model}`, day: null, week: null, month: null, concurrent: null, dayUsd: null, weekUsd: null, monthUsd: null };
|
|
274
|
+
const norm = { scope: `${MODEL_SCOPE_PREFIX}${ref.provider}/${ref.model}`, day: null, week: null, month: null, concurrent: null, dayUsd: null, weekUsd: null, monthUsd: null, ...(version >= 3 ? sizeNulls() : {}) };
|
|
247
275
|
let any = false;
|
|
248
276
|
for (const field of USD_LIMIT_FIELDS) {
|
|
249
277
|
if (row[field] === undefined || row[field] === null) continue;
|
|
@@ -260,6 +288,13 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
|
|
|
260
288
|
throw configError(`${at}: must be an object: ${path}`);
|
|
261
289
|
}
|
|
262
290
|
if (!isNonEmptyString(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
291
|
+
// Version 3 refuses a key it does not know (issue #596): a misspelled `Memory`, `cpu` or `mem` beside a field that
|
|
292
|
+
// keeps the row valid was dropped, and the project's jobs ran at the default size while the file read as sized.
|
|
293
|
+
// Versions 1 and 2 keep dropping unknown keys, so a file an older build wrote and reads still loads unchanged.
|
|
294
|
+
if (version >= 3) {
|
|
295
|
+
const unknown = Object.keys(row).filter((key) => !V3_ROW_KEYS.has(key));
|
|
296
|
+
if (unknown.length > 0) throw configError(`${at}: unknown key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (a version 3 row carries only scope, ${[...LIMIT_FIELDS, ...USD_LIMIT_FIELDS, ...SIZE_LIMIT_FIELDS].join(", ")}; keys are case-sensitive): ${path}`);
|
|
297
|
+
}
|
|
263
298
|
const trimmed = row.scope.trim().normalize("NFC"); // the same NFC canonicalScope applies job-side
|
|
264
299
|
if (isModelScope(trimmed)) return normalizeModelLimit(row, trimmed, at, path, version);
|
|
265
300
|
// A NEAR MISS of a reserved prefix is refused, never read as a repo or folder row (PR #549's review): a
|
|
@@ -312,6 +347,9 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
|
|
|
312
347
|
// read-modify-write writes back exactly what the parser accepts. `dollarCapsFor` turns them into integers.
|
|
313
348
|
// Only in a version 2 file's rows: a version 1 file reads into exactly the five-key literal it always did.
|
|
314
349
|
...(version >= 2 ? { dayUsd: null, weekUsd: null, monthUsd: null } : {}),
|
|
350
|
+
// Version 3's size fields (issue #596), the same way: only in a version 3 file's rows, null unless a project row
|
|
351
|
+
// sets them, so a version 1 or 2 file reads into exactly the literal it always did.
|
|
352
|
+
...(version >= 3 ? sizeNulls() : {}),
|
|
315
353
|
};
|
|
316
354
|
let any = false;
|
|
317
355
|
for (const field of USD_LIMIT_FIELDS) {
|
|
@@ -337,12 +375,55 @@ function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
|
|
|
337
375
|
norm[field] = value;
|
|
338
376
|
any = true;
|
|
339
377
|
}
|
|
378
|
+
// Issue #596: a project row may carry a size and nothing else, so the size fields count as limiting something.
|
|
379
|
+
if (sizeFields(row, norm, { at, path, version, project: isProjectScope(trimmed) })) any = true;
|
|
340
380
|
if (!any) {
|
|
341
|
-
throw configError(`${at}: at least one of day, week, month, concurrent${version >= 2 ? ", dayUsd, weekUsd, monthUsd" : ""} is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
381
|
+
throw configError(`${at}: at least one of day, week, month, concurrent${version >= 2 ? ", dayUsd, weekUsd, monthUsd" : ""}${version >= 3 && isProjectScope(trimmed) ? ", memory, cpus, hostShare, minJobs" : ""} is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
342
382
|
}
|
|
343
383
|
return norm;
|
|
344
384
|
}
|
|
345
385
|
|
|
386
|
+
/** The four version 3 size fields, all null: what a version 3 row carries when it sets none. */
|
|
387
|
+
function sizeNulls() {
|
|
388
|
+
return { memory: null, cpus: null, hostShare: null, minJobs: null };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Read a row's size fields into `norm` (issue #596), refusing loudly, and say whether it set any. Only a project row
|
|
393
|
+
* may carry them: a size is what one project's jobs need, and a repo or folder row is not where a job's project is
|
|
394
|
+
* decided. `minJobs` needs a size on the same row (a minimum of jobs of an unstated size reserves nothing anyone can
|
|
395
|
+
* judge) and may not exceed the row's own `concurrent` (a minimum the project can never reach). `hostShare` against
|
|
396
|
+
* `minJobs` times the size is judged where a host's budget is known (doctor warns, `hostBudgetChecks`), because a
|
|
397
|
+
* percentage of a host is an amount only on a host.
|
|
398
|
+
*/
|
|
399
|
+
function sizeFields(row, norm, { at, path, version, project }) {
|
|
400
|
+
const present = SIZE_LIMIT_FIELDS.filter((f) => row[f] !== undefined && row[f] !== null);
|
|
401
|
+
if (present.length === 0) return false;
|
|
402
|
+
if (!project) throw configError(`${at}: ${present.join(", ")} belong on a project row (project:<id>), because a job's size is its project's: ${path}`);
|
|
403
|
+
if (version < 3) throw configError(`${at}: ${present.join(", ")} needs "version": 3 (this file says ${version}), so a build that predates job sizes refuses the file rather than running the project's jobs at the default size: ${path}`);
|
|
404
|
+
const sized = (field, parse, store) => {
|
|
405
|
+
if (row[field] === undefined || row[field] === null) return;
|
|
406
|
+
try {
|
|
407
|
+
norm[field] = store(parse(row[field]));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw configError(`${at}: ${field}: ${error.message}: ${path}`);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
sized("memory", parseMemory, formatMemory);
|
|
413
|
+
sized("cpus", parseCpus, (centi) => Number(formatCpus(centi)));
|
|
414
|
+
if (row.hostShare !== undefined && row.hostShare !== null) {
|
|
415
|
+
if (!Number.isSafeInteger(row.hostShare) || row.hostShare < 1 || row.hostShare > 100) throw configError(`${at}: hostShare is a whole percentage of the host's job budget, from 1 to 100 (got ${JSON.stringify(row.hostShare)}): ${path}`);
|
|
416
|
+
norm.hostShare = row.hostShare;
|
|
417
|
+
}
|
|
418
|
+
if (row.minJobs !== undefined && row.minJobs !== null) {
|
|
419
|
+
if (!Number.isSafeInteger(row.minJobs) || row.minJobs < 1) throw configError(`${at}: minJobs must be an integer >= 1: ${path}`);
|
|
420
|
+
if (norm.memory === null && norm.cpus === null) throw configError(`${at}: minJobs needs memory or cpus on the same row, so the room it asks a host to keep has a size: ${path}`);
|
|
421
|
+
if (norm.concurrent !== null && row.minJobs > norm.concurrent) throw configError(`${at}: minJobs ${row.minJobs} is above this row's concurrent ${norm.concurrent}, a minimum the project can never reach: ${path}`);
|
|
422
|
+
norm.minJobs = row.minJobs;
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
346
427
|
/**
|
|
347
428
|
* Load and validate the scoped-limits file named by `config.scopedLimitsFile`. Returns `[]` when the file
|
|
348
429
|
* is unset (no scoped caps or concurrency -- a valid deployment; the folder mutex holds regardless, it is
|
|
@@ -567,12 +648,15 @@ export function dollarRowsBelowJobCap(limits, deploymentMaxCostUsd) {
|
|
|
567
648
|
}
|
|
568
649
|
|
|
569
650
|
/**
|
|
570
|
-
* The lowest file version that expresses `rows` (normalized or as the admin builds them):
|
|
571
|
-
* dollar field, is a model row, is a project row (issue #499 part B)
|
|
651
|
+
* The lowest file version that expresses `rows` (normalized or as the admin builds them): 3 when any row carries a size
|
|
652
|
+
* field (issue #596), else 2 when any row carries a dollar field, is a model row, is a project row (issue #499 part B)
|
|
653
|
+
* or has a forge-qualified scope (issue #498), else 1.
|
|
572
654
|
* The admin writes this, so a file
|
|
573
655
|
* with bare and folder job-count rows only stays a version 1 file that an older worker still reads.
|
|
574
656
|
*/
|
|
575
657
|
export function scopedLimitsVersionFor(rows) {
|
|
658
|
+
// Issue #596: a size field on any row needs version 3 (the parser refuses it off a project row, and says so).
|
|
659
|
+
if ((rows ?? []).some((l) => SIZE_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined))) return 3;
|
|
576
660
|
const v2 = (rows ?? []).some((l) => {
|
|
577
661
|
const scope = typeof l?.scope === "string" ? l.scope.trim() : l?.scope;
|
|
578
662
|
return isModelScope(scope) || isProjectScope(scope) || isQualifiedScope(scope) || USD_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run records a size suggestion reads (issue #596, phase 3, DES-SIZE-SUGGESTIONS), read ONCE per doctor run, panel
|
|
3
|
+
* view or insights render and handed to `suggestSize` for every project. Doctor and the admin share this reader, so the
|
|
4
|
+
* bounds below hold on every surface:
|
|
5
|
+
*
|
|
6
|
+
* - an MTIME PREFILTER: a file the worker last wrote before the window (plus a day for skew) is not opened. A
|
|
7
|
+
* keep-forever logs directory (`PI_LOG_RETENTION_DAYS=0`) is otherwise read whole on every render;
|
|
8
|
+
* - a SIZE CAP per file (`SIZING_RECORD_MAX_BYTES`, 256 KiB): a larger file is skipped and COUNTED, never parsed. A
|
|
9
|
+
* run record is a few KiB; the cap keeps a hand-placed or corrupted giant from costing a render its memory, and
|
|
10
|
+
* the count says it happened rather than leaving a silent hole;
|
|
11
|
+
* - only a record that carries measurements (an object `resources` and a `size`) is kept, and of those the NEWEST
|
|
12
|
+
* `SUGGEST_WINDOW_RUNS` per project (by `endedAt`), since no suggestion reads more. The filter runs BEFORE the
|
|
13
|
+
* cut: a run refused before its container started (a budget or policy refusal) writes a record with neither, and
|
|
14
|
+
* cut first, fifty such refusals would crowd every measured run out of the window.
|
|
15
|
+
*
|
|
16
|
+
* Never throws: an absent directory holds no records; another unreadable one is `unreachable` (doctor reads that as
|
|
17
|
+
* none, the panel says it).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { SUGGEST_WINDOW_DAYS, SUGGEST_WINDOW_RUNS } from "./size-suggest.mjs";
|
|
23
|
+
|
|
24
|
+
/** The largest run record a size suggestion parses: 256 KiB. A larger one is skipped and counted. */
|
|
25
|
+
export const SIZING_RECORD_MAX_BYTES = 256 * 1024;
|
|
26
|
+
/** How far back a file's mtime may lie and still be opened: the window plus a day of skew. */
|
|
27
|
+
export const SIZING_MTIME_DAYS = SUGGEST_WINDOW_DAYS + 1;
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `{ records, skipped, unreachable }`: the parsed records (at most SUGGEST_WINDOW_RUNS per project, newest first by
|
|
34
|
+
* `endedAt`; a record with no string `project`, no readable `endedAt`, no object `resources` or no `size` is dropped,
|
|
35
|
+
* since no suggestion could read it), how many files were skipped for their size, and null or a reason when the
|
|
36
|
+
* directory itself could not be listed.
|
|
37
|
+
* `fs` is `{ readdirSync, statSync, readFileSync }`.
|
|
38
|
+
*/
|
|
39
|
+
export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFileSync, statSync } } = {}) {
|
|
40
|
+
const since = nowMs - SIZING_MTIME_DAYS * DAY_MS;
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = fs.readdirSync(logsDir);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { records: [], skipped: 0, unreachable: err?.code === "ENOENT" ? null : `logs dir unreadable (${err?.code ?? "read-error"})` };
|
|
46
|
+
}
|
|
47
|
+
const byProject = new Map();
|
|
48
|
+
let skipped = 0;
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
if (typeof name !== "string" || !name.endsWith(".json")) continue;
|
|
51
|
+
try {
|
|
52
|
+
const path = join(logsDir, name);
|
|
53
|
+
const st = fs.statSync(path);
|
|
54
|
+
if (!st.isFile() || st.mtimeMs < since) continue;
|
|
55
|
+
if (st.size > SIZING_RECORD_MAX_BYTES) {
|
|
56
|
+
skipped++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const buf = fs.readFileSync(path);
|
|
60
|
+
if (buf.length > SIZING_RECORD_MAX_BYTES) {
|
|
61
|
+
skipped++; // it grew between the stat and the read
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const record = JSON.parse(buf.toString("utf8"));
|
|
65
|
+
const at = typeof record?.endedAt === "string" ? Date.parse(record.endedAt) : NaN;
|
|
66
|
+
if (typeof record?.project !== "string" || !Number.isFinite(at)) continue;
|
|
67
|
+
if (!isObject(record.resources) || !isObject(record.size)) continue; // no measurements: never counts toward the 50
|
|
68
|
+
if (!byProject.has(record.project)) byProject.set(record.project, []);
|
|
69
|
+
byProject.get(record.project).push({ at, record });
|
|
70
|
+
} catch {
|
|
71
|
+
// unparseable, or reaped between the listing and the read
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const records = [];
|
|
75
|
+
for (const list of byProject.values()) {
|
|
76
|
+
list.sort((a, b) => b.at - a.at);
|
|
77
|
+
for (const { record } of list.slice(0, SUGGEST_WINDOW_RUNS)) records.push(record);
|
|
78
|
+
}
|
|
79
|
+
return { records, skipped, unreachable: null };
|
|
80
|
+
}
|