@edgehero/pi-dispatch 4.0.0 → 4.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/package.json +6 -2
- package/src/capacity-cli.mjs +375 -0
- package/src/capacity-records.mjs +209 -0
- package/src/capacity.mjs +654 -0
- package/src/cli.mjs +9 -0
- package/src/config.mjs +4 -20
- package/src/connection.mjs +4 -2
- package/src/doctor.mjs +214 -36
- package/src/host-budget.mjs +6 -0
- package/src/host-registry.mjs +20 -2
- package/src/index.mjs +97 -6
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +135 -0
- package/src/prepare.mjs +1 -9
- package/src/provider-steering.mjs +1 -0
- package/src/repeat-slot.mjs +13 -0
- package/src/run-earlier.mjs +29 -0
- package/src/run-history.mjs +115 -5
- package/src/run-mirror.mjs +161 -12
- package/src/size-suggest.mjs +34 -14
- package/src/start.mjs +39 -4
- package/src/wait-for.mjs +7 -0
- package/src/worker-name.mjs +25 -0
package/src/cli.mjs
CHANGED
|
@@ -52,6 +52,9 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
|
|
|
52
52
|
pi-dispatch pause stop taking new jobs (durable; survives worker restart)
|
|
53
53
|
pi-dispatch resume resume taking jobs
|
|
54
54
|
pi-dispatch status show paused state + job counts
|
|
55
|
+
pi-dispatch capacity [--since 24h|7d|30d] [--host <name>] [--json] [--valkey-url <url>]
|
|
56
|
+
how busy each host was: busy and idle time, slots in use, memory and CPU
|
|
57
|
+
promised and used, waits and projects, read from the run records (jobs only)
|
|
55
58
|
pi-dispatch cancel <jobId> stop one job: a queued or held job is removed (the line says whether it had
|
|
56
59
|
made attempts; cancel records nothing), a running one is aborted on whichever
|
|
57
60
|
host owns it (its record says operator-cancel)
|
|
@@ -235,6 +238,12 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
|
|
|
235
238
|
return code;
|
|
236
239
|
}
|
|
237
240
|
|
|
241
|
+
if (cmd === "capacity") {
|
|
242
|
+
// Read-only, and on the kill switch's footing: the Valkey URL and the logs directory, never loadConfig (issue #599).
|
|
243
|
+
const { runCapacity } = await import("./capacity-cli.mjs");
|
|
244
|
+
return runCapacity(argv.slice(1), { env, write, valkeyRefusal, deploymentEnv: cliDeploymentEnv });
|
|
245
|
+
}
|
|
246
|
+
|
|
238
247
|
if (cmd === "cancel") {
|
|
239
248
|
// The kill switch's doctrine (issue #287): VALKEY_URL only, never loadConfig, so one misbehaving
|
|
240
249
|
// job can be stopped even when everything else about the deployment is misconfigured. On a shell/.env
|
package/src/config.mjs
CHANGED
|
@@ -19,6 +19,9 @@ import { jobSizeDefaults } from "./job-size.mjs";
|
|
|
19
19
|
import { hostBudgetSettings } from "./host-budget.mjs";
|
|
20
20
|
import { CONTAINER_ENV_NAMES, KEYLESS_ENV_NAME, RUNNER_ENV_NAMES } from "./reserved-env.mjs";
|
|
21
21
|
import { modelListProblem } from "./model-ref.mjs";
|
|
22
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
23
|
+
|
|
24
|
+
export { WORKER_NAME_RE };
|
|
22
25
|
import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsdMicros } from "./money.mjs";
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -1146,26 +1149,7 @@ export function underOsTempDir(candidate, env = process.env, { realpath = realpa
|
|
|
1146
1149
|
return false;
|
|
1147
1150
|
}
|
|
1148
1151
|
|
|
1149
|
-
|
|
1150
|
-
* What a worker may call itself (issue #57). The CHARACTER CLASS is `sanitizeJobId`'s
|
|
1151
|
-
* (`[A-Za-z0-9._-]`), reused rather than invented so this project has one name-safe alphabet -- but that
|
|
1152
|
-
* function is a REPLACER, not a validator, so the three rules around the class are NEW and are claimed
|
|
1153
|
-
* as new here rather than borrowed:
|
|
1154
|
-
*
|
|
1155
|
-
* - a leading alphanumeric, which is what refuses `..` and a leading `-` that reads as a flag;
|
|
1156
|
-
* - a 64-character ceiling, because the name is a Valkey key segment and a log field on every line;
|
|
1157
|
-
* - no `.json`/`.log` tail, which is not decoration. The class contains the dot, so `prod.json` is
|
|
1158
|
-
* otherwise a legal name -- and a later slice writes a per-host marker file into `PI_LOGS_DIR`,
|
|
1159
|
-
* where `<something>.json` is parsed as a run record by the admin and DELETED by the log reaper.
|
|
1160
|
-
* A name is refused here rather than escaped there, because the escape would have to be remembered
|
|
1161
|
-
* at every site that ever composes a filename from this value.
|
|
1162
|
-
*
|
|
1163
|
-
* The class is `:`-free, `,`-free and `#`-free, which is what lets the name be a Valkey key segment
|
|
1164
|
-
* UNHASHED. That is the point of validating instead of hashing (`scopeKeyPrefix` does the opposite for
|
|
1165
|
-
* a folder path, which was never chosen for key-safety and cannot be refused): the whole value of a host
|
|
1166
|
-
* registry is that `HGETALL host:h:mac-mini-1` is readable by a human.
|
|
1167
|
-
*/
|
|
1168
|
-
export const WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
1152
|
+
// WORKER_NAME_RE lives in worker-name.mjs (a leaf, for the capacity report) and is re-exported above; its reasons are there.
|
|
1169
1153
|
|
|
1170
1154
|
/** True when the name would collide with the run-history filename namespace. See WORKER_NAME_RE. */
|
|
1171
1155
|
const RESERVED_NAME_TAIL = /\.(json|log)$/i;
|
package/src/connection.mjs
CHANGED
|
@@ -189,8 +189,10 @@ export function parseConnection(url, { failFast = false, servername = null, cont
|
|
|
189
189
|
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
|
190
190
|
* `failFast` as ioredis and `parseConnection` read them.
|
|
191
191
|
*/
|
|
192
|
-
export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false } = {}) {
|
|
193
|
-
|
|
192
|
+
export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false, disconnectTimeoutMs = null } = {}) {
|
|
193
|
+
// `disconnectTimeoutMs` (issue #599): how long ioredis's `disconnect()` waits for a socket to close before it destroys
|
|
194
|
+
// it (its default is 2 s, on a ref'd timer). A one-shot read that must not hold its process passes 0.
|
|
195
|
+
return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}), ...(Number.isSafeInteger(disconnectTimeoutMs) && disconnectTimeoutMs >= 0 ? { disconnectTimeout: disconnectTimeoutMs } : {}) });
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
/**
|
package/src/doctor.mjs
CHANGED
|
@@ -99,6 +99,8 @@ import { CONTAINER_HOME, SHIPPED_IMAGE_UID, SIZE_LABEL_CPU, SIZE_LABEL_MEM } fro
|
|
|
99
99
|
import { DEFAULT_JOB_SIZE, cpuCeilingCenti, formatCpus, formatMemory, jobSizeDefaults, resolveJobSize } from "./job-size.mjs";
|
|
100
100
|
import { SUGGEST_WINDOW_DAYS, cpusText, hostCap, refusalWords, sizeRefusal, suggestSize, suggestionCall, suggestionEvidence } from "./size-suggest.mjs";
|
|
101
101
|
import { SIZING_RECORD_MAX_BYTES, readSizingRecords } from "./size-records.mjs";
|
|
102
|
+
import { CAPACITY_WINDOWS, computeCapacity } from "./capacity.mjs";
|
|
103
|
+
import { JOBS_ONLY, durationText, milliText, notSharedWhy, percentText, shareText } from "./capacity-cli.mjs";
|
|
102
104
|
import { CGROUP_PARENT, cgroupParentFor, operatorQuotaCommand, readQuota, reservePlan, userQuotaCommand } from "./cpu-reserve.mjs";
|
|
103
105
|
import { HOST_BUDGET_KEYS, computeHostBudget, hostBudgetSettings, largestFit, neverFits, projectBudgetRow, publishedBudget, readUserServiceLimits } from "./host-budget.mjs";
|
|
104
106
|
import { makeImagePreflight, normalizeImageId } from "./image-preflight.mjs";
|
|
@@ -220,6 +222,9 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
220
222
|
dollarKeysExist,
|
|
221
223
|
// Issue #504 part B: the applied split's envelope digest (`alloc:plan`), read once. Undefined means the default.
|
|
222
224
|
readAppliedSplit,
|
|
225
|
+
// Issue #599, phase 2: the capacity line's read of the run history (the run mirror and the logs directory).
|
|
226
|
+
// Undefined means the default, which reads the deployment's real Valkey and files.
|
|
227
|
+
readCapacity,
|
|
223
228
|
// --live (issue #278, INT-LIVE-PROBE-CONTRACT): read the backend declarations back off short-lived real containers.
|
|
224
229
|
// STRICTLY `=== true`, so only the CLI's own flag arms it: a truthy string from a caller that forwarded an
|
|
225
230
|
// option bag runs nothing. The fs, PID-liveness and nonce are seams so the sequence is driven without Docker.
|
|
@@ -383,7 +388,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
383
388
|
return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from };
|
|
384
389
|
}
|
|
385
390
|
: null;
|
|
386
|
-
const seams = { cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
391
|
+
const seams = { cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
387
392
|
// Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the
|
|
388
393
|
// same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is
|
|
389
394
|
// handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env`
|
|
@@ -595,7 +600,7 @@ export const ENV_FILE_READABLE_KEYS = Object.freeze(["PI_PAUSE_WINDOWS_FILE", "P
|
|
|
595
600
|
export const GITHUB_SERVICE_KEYS = Object.freeze(["GITHUB_AUTH_SOURCE", "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_PRIVATE_KEY"]);
|
|
596
601
|
/** Issue #471: the worker's settings doctor judges, which it read from this shell alone while the service read them from
|
|
597
602
|
* `.env`. TEMP is TMPDIR's twin in the worker's temp root; PI_CODING_AGENT_DIR is where the worker reads auth.json. */
|
|
598
|
-
export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
|
|
603
|
+
export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
|
|
599
604
|
/** Issue #471: the receiver's keys doctor judges its boot by (the receiver's unit reads the same `.env`). */
|
|
600
605
|
export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
|
|
601
606
|
/**
|
|
@@ -2594,6 +2599,11 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2594
2599
|
// Said, rather than silently absent: "no peers" and "could not ask" are different facts.
|
|
2595
2600
|
checks.push({ ok: true, label: `Fleet: could not read the host registry (${printable(fleet.unreachable)})` });
|
|
2596
2601
|
}
|
|
2602
|
+
// Issue #599, phase 2: how busy each host was over the last 7 days, one fact line per host, never a warning (nothing
|
|
2603
|
+
// decides on it). On every deployment, a single host too: its own files are read when the run mirror is not. Read from
|
|
2604
|
+
// the same Valkey the fleet was, with the registry rows just read (their running jobs count up to now), every Valkey
|
|
2605
|
+
// call bounded and the whole read under `CAPACITY_DOCTOR_TIMEOUT_MS`, so a slow mirror costs this line, not the run.
|
|
2606
|
+
checks.push(...(await doctorCapacity({ seams, env, home, url: valkeyUsable ? valkeyTalkUrl : null, hosts: fleet.hosts ?? [], localHost: workerNameOf(declaredWorkerName) })));
|
|
2597
2607
|
// Issue #504 part B: the APPLIED split names the envelope it was made for, and every host
|
|
2598
2608
|
// whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
|
|
2599
2609
|
// the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
|
|
@@ -4722,21 +4732,15 @@ export function appliedSplitChecks(applied, mine, myName, peers) {
|
|
|
4722
4732
|
*/
|
|
4723
4733
|
export async function defaultReadAppliedSplit(url) {
|
|
4724
4734
|
try {
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
client.on("error", () => {});
|
|
4728
|
-
try {
|
|
4729
|
-
await client.connect();
|
|
4730
|
-
const text = await client.get(ALLOC_PLAN_KEY);
|
|
4735
|
+
return await withDoctorClient(url, async (client) => {
|
|
4736
|
+
const text = await boundedOp(client.get(ALLOC_PLAN_KEY), DOCTOR_VALKEY_OP_TIMEOUT_MS);
|
|
4731
4737
|
if (text === null || text === undefined) return null;
|
|
4732
4738
|
let digest = null;
|
|
4733
4739
|
try {
|
|
4734
4740
|
digest = JSON.parse(text)?.envelopeDigest;
|
|
4735
4741
|
} catch {}
|
|
4736
4742
|
return typeof digest === "string" && /^[0-9a-f]{16}$/.test(digest) ? { digest } : { undecodable: true };
|
|
4737
|
-
}
|
|
4738
|
-
client.disconnect();
|
|
4739
|
-
}
|
|
4743
|
+
});
|
|
4740
4744
|
} catch {
|
|
4741
4745
|
return null;
|
|
4742
4746
|
}
|
|
@@ -7389,16 +7393,9 @@ function workerNameOf(declared) {
|
|
|
7389
7393
|
|
|
7390
7394
|
async function defaultReadHosts(url) {
|
|
7391
7395
|
try {
|
|
7392
|
-
const { makeRedisClient } = await import("./connection.mjs");
|
|
7393
7396
|
const { readLiveHosts } = await import("./host-registry.mjs");
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
try {
|
|
7397
|
-
await client.connect();
|
|
7398
|
-
return await readLiveHosts(client);
|
|
7399
|
-
} finally {
|
|
7400
|
-
client.disconnect();
|
|
7401
|
-
}
|
|
7397
|
+
// readLiveHosts bounds each of its own commands.
|
|
7398
|
+
return await withDoctorClient(url, (client) => readLiveHosts(client));
|
|
7402
7399
|
} catch (err) {
|
|
7403
7400
|
return { unreachable: err?.message ?? "registry unreadable" };
|
|
7404
7401
|
}
|
|
@@ -7447,35 +7444,60 @@ export async function dollarKeysExistWith(client, { now = () => new Date(), dead
|
|
|
7447
7444
|
/** `dollarKeysExistWith` over a fail-fast client on `url`, always disconnected. */
|
|
7448
7445
|
export async function defaultDollarKeysExist(url) {
|
|
7449
7446
|
try {
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
client.on("error", () => {});
|
|
7453
|
-
try {
|
|
7454
|
-
await client.connect();
|
|
7455
|
-
return await dollarKeysExistWith(client);
|
|
7456
|
-
} finally {
|
|
7457
|
-
client.disconnect();
|
|
7458
|
-
}
|
|
7447
|
+
// dollarKeysExistWith bounds its whole read.
|
|
7448
|
+
return await withDoctorClient(url, (client) => dollarKeysExistWith(client));
|
|
7459
7449
|
} catch {
|
|
7460
7450
|
return false;
|
|
7461
7451
|
}
|
|
7462
7452
|
}
|
|
7463
7453
|
|
|
7464
7454
|
async function defaultProbeValkey(url) {
|
|
7465
|
-
const { makeRedisClient } = await import("./connection.mjs");
|
|
7466
|
-
const client = makeRedisClient(url, { failFast: true, lazyConnect: true });
|
|
7467
|
-
client.on("error", () => {}); // swallow connect errors + retries; reachability is the ✓/✗, not a trace
|
|
7468
7455
|
try {
|
|
7469
|
-
await client.
|
|
7470
|
-
await client.ping();
|
|
7471
|
-
return true;
|
|
7456
|
+
return await withDoctorClient(url, async (client) => (await boundedOp(client.ping(), DOCTOR_VALKEY_OP_TIMEOUT_MS), true));
|
|
7472
7457
|
} catch {
|
|
7473
7458
|
return false;
|
|
7459
|
+
}
|
|
7460
|
+
}
|
|
7461
|
+
|
|
7462
|
+
/** How long doctor waits for a Valkey connection to be ready (the TCP connect AND the ready check), and for one command. */
|
|
7463
|
+
export const DOCTOR_VALKEY_CONNECT_TIMEOUT_MS = 5_000;
|
|
7464
|
+
export const DOCTOR_VALKEY_OP_TIMEOUT_MS = 5_000;
|
|
7465
|
+
|
|
7466
|
+
/**
|
|
7467
|
+
* One fail-fast client for one of doctor's own Valkey reads, its connection bounded, always dropped. Measured: a
|
|
7468
|
+
* server that accepts the connection and never answers holds ioredis in its ready check forever (`failFast` bounds
|
|
7469
|
+
* only the TCP connect), so doctor never exited; and `disconnect()` arms a 2 s timer a closed socket never clears, so
|
|
7470
|
+
* the client is built with a disconnect timeout of 0 and its stream destroyed. Throws when the connection is not ready
|
|
7471
|
+
* in time; `fn`'s own commands are the caller's to bound (`boundedOp`).
|
|
7472
|
+
*/
|
|
7473
|
+
export async function withDoctorClient(url, fn, { connectTimeoutMs = DOCTOR_VALKEY_CONNECT_TIMEOUT_MS, makeClient } = {}) {
|
|
7474
|
+
const make = makeClient ?? (await import("./connection.mjs")).makeRedisClient;
|
|
7475
|
+
const client = make(url, { failFast: true, lazyConnect: true, disconnectTimeoutMs: 0 });
|
|
7476
|
+
client.on?.("error", () => {}); // swallow connect errors + retries; reachability is the ✓/✗, not a trace
|
|
7477
|
+
try {
|
|
7478
|
+
await connectWithin(client, connectTimeoutMs);
|
|
7479
|
+
return await fn(client);
|
|
7474
7480
|
} finally {
|
|
7475
|
-
|
|
7481
|
+
try {
|
|
7482
|
+
client.disconnect?.();
|
|
7483
|
+
client.stream?.destroy?.();
|
|
7484
|
+
} catch {
|
|
7485
|
+
// a release that failed has stopped mattering
|
|
7486
|
+
}
|
|
7476
7487
|
}
|
|
7477
7488
|
}
|
|
7478
7489
|
|
|
7490
|
+
/** A command's reply, or a rejection after `ms` (the client is dropped by `withDoctorClient` either way). */
|
|
7491
|
+
export function boundedOp(promise, ms) {
|
|
7492
|
+
return new Promise((resolve, reject) => {
|
|
7493
|
+
const t = setTimeout(() => reject(new Error("timeout")), ms);
|
|
7494
|
+
Promise.resolve(promise).then(
|
|
7495
|
+
(v) => (clearTimeout(t), resolve(v)),
|
|
7496
|
+
(e) => (clearTimeout(t), reject(e)),
|
|
7497
|
+
);
|
|
7498
|
+
});
|
|
7499
|
+
}
|
|
7500
|
+
|
|
7479
7501
|
/**
|
|
7480
7502
|
* The deployment's default job size as doctor reads it (issue #596): `PI_JOB_MEMORY` and `PI_JOB_CPUS` through the
|
|
7481
7503
|
* worker's own rule (`jobSizeDefaults`), or the built-in 4g and 2 when they do not parse (`jobSizeChecks` reports that as
|
|
@@ -7756,6 +7778,162 @@ export function hostBudgetChecks(view, { concurrency = 3, limits = [], env = {},
|
|
|
7756
7778
|
return checks;
|
|
7757
7779
|
}
|
|
7758
7780
|
|
|
7781
|
+
/** The most the capacity read may take in doctor, all of it: a full mirror reads in well under a second. */
|
|
7782
|
+
export const CAPACITY_DOCTOR_TIMEOUT_MS = 5_000;
|
|
7783
|
+
|
|
7784
|
+
/**
|
|
7785
|
+
* The capacity lines (issue #599, phase 2): the report over the last 7 days (`capacity.mjs`, the CLI's function), read
|
|
7786
|
+
* by `seams.readCapacity` (default: `readCapacityRecords` through a fail-fast client, `prune`-free and writing nothing).
|
|
7787
|
+
* Never throws and never warns: an unreadable history is one line saying so.
|
|
7788
|
+
*/
|
|
7789
|
+
export async function doctorCapacity({ seams, env, home, url, hosts, localHost }) {
|
|
7790
|
+
try {
|
|
7791
|
+
const window = CAPACITY_WINDOWS["7d"];
|
|
7792
|
+
const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
|
|
7793
|
+
const retention = typeof env.PI_LOG_RETENTION_DAYS === "string" && /^\d{1,6}$/.test(env.PI_LOG_RETENTION_DAYS) ? Number(env.PI_LOG_RETENTION_DAYS) : 30;
|
|
7794
|
+
// The read is told to stop when the bound passes (`signal`): giving up on it is not enough, since a client still
|
|
7795
|
+
// waiting on its connection would keep this process alive after doctor has printed everything.
|
|
7796
|
+
const stop = new AbortController();
|
|
7797
|
+
const args = { url, logsDir: logsDirPath(env, home), sinceMs: nowMs - window.ms, nowMs, retentionDays: retention, localHost, signal: stop.signal };
|
|
7798
|
+
let timer;
|
|
7799
|
+
const read = await Promise.race([
|
|
7800
|
+
Promise.resolve()
|
|
7801
|
+
.then(() => (seams.readCapacity ?? defaultReadCapacity)(args))
|
|
7802
|
+
.finally(() => clearTimeout(timer)),
|
|
7803
|
+
new Promise((resolve) => {
|
|
7804
|
+
timer = setTimeout(() => {
|
|
7805
|
+
stop.abort();
|
|
7806
|
+
resolve(null);
|
|
7807
|
+
}, seams.capacityTimeoutMs ?? CAPACITY_DOCTOR_TIMEOUT_MS);
|
|
7808
|
+
}),
|
|
7809
|
+
]);
|
|
7810
|
+
if (read === null) return [{ ok: true, label: "Capacity: the run history did not answer in time, so nothing is shown (pi-dispatch capacity waits up to 2 s per Valkey call)" }];
|
|
7811
|
+
const report = computeCapacity({ records: read.records, live: hosts, windowStartMs: nowMs - window.ms, nowMs, bucketMs: window.bucketMs, coverage: read.coverage });
|
|
7812
|
+
return capacityChecks(report, { since: "7d" });
|
|
7813
|
+
} catch (err) {
|
|
7814
|
+
if (err?.[FORBIDDEN_READ]) throw err;
|
|
7815
|
+
return [{ ok: true, label: `Capacity: not read (${printable(err?.message ?? "error")})` }];
|
|
7816
|
+
}
|
|
7817
|
+
}
|
|
7818
|
+
|
|
7819
|
+
/**
|
|
7820
|
+
* Set by the test helper (test/helpers/doctor.mjs): from then on the DEFAULT capacity read throws an error doctor does
|
|
7821
|
+
* not swallow, so a test that reaches the developer's real Valkey and logs directory fails loudly instead of printing
|
|
7822
|
+
* whatever that machine holds.
|
|
7823
|
+
*/
|
|
7824
|
+
let defaultCapacityReadForbidden = false;
|
|
7825
|
+
export function forbidDefaultCapacityRead() {
|
|
7826
|
+
defaultCapacityReadForbidden = true;
|
|
7827
|
+
}
|
|
7828
|
+
const FORBIDDEN_READ = Symbol("forbidden-read");
|
|
7829
|
+
|
|
7830
|
+
/** How long the default read waits for its Valkey connection to be ready (the TCP connect and the ready check). */
|
|
7831
|
+
export const CAPACITY_CONNECT_TIMEOUT_MS = 2_000;
|
|
7832
|
+
|
|
7833
|
+
/**
|
|
7834
|
+
* The default capacity read: a fail-fast client, its connection bounded (`connectWithin`: a server that accepts TCP and
|
|
7835
|
+
* never answers holds ioredis in its ready check forever) and DISCONNECTED when `signal` aborts, so a read doctor gave
|
|
7836
|
+
* up on cannot keep the process alive.
|
|
7837
|
+
*/
|
|
7838
|
+
async function defaultReadCapacity({ url, logsDir, sinceMs, nowMs, retentionDays, localHost, signal }) {
|
|
7839
|
+
if (defaultCapacityReadForbidden) throw Object.assign(new Error("doctor reached its real capacity read in a test: pass readCapacity"), { [FORBIDDEN_READ]: true });
|
|
7840
|
+
const { readCapacityRecords } = await import("./capacity-records.mjs");
|
|
7841
|
+
let client = null;
|
|
7842
|
+
// Measured on ioredis 5.11: `disconnect()` ends the socket and arms a timer (`disconnectTimeout`, 2 s by default)
|
|
7843
|
+
// that destroys it unless the socket's `close` clears it first, and the client's own failed ready check disconnects
|
|
7844
|
+
// again after the close, arming one that nothing clears: a server that never answered held the process 2 s past
|
|
7845
|
+
// doctor's bound. So the client is built with a disconnect timeout of 0 (no timer outlives the socket), its stream
|
|
7846
|
+
// is destroyed in the same tick (the socket is closed by the time doctor prints), and it is dropped once.
|
|
7847
|
+
const drop = () => {
|
|
7848
|
+
const c = client;
|
|
7849
|
+
client = null;
|
|
7850
|
+
try {
|
|
7851
|
+
c?.disconnect?.();
|
|
7852
|
+
c?.stream?.destroy?.();
|
|
7853
|
+
} catch {
|
|
7854
|
+
// a release that failed has stopped mattering
|
|
7855
|
+
}
|
|
7856
|
+
};
|
|
7857
|
+
signal?.addEventListener?.("abort", drop, { once: true });
|
|
7858
|
+
if (url && !signal?.aborted) {
|
|
7859
|
+
try {
|
|
7860
|
+
const { makeRedisClient } = await import("./connection.mjs");
|
|
7861
|
+
client = makeRedisClient(url, { failFast: true, lazyConnect: true, disconnectTimeoutMs: 0 });
|
|
7862
|
+
client.on("error", () => {});
|
|
7863
|
+
await connectWithin(client, CAPACITY_CONNECT_TIMEOUT_MS, signal);
|
|
7864
|
+
} catch {
|
|
7865
|
+
drop();
|
|
7866
|
+
}
|
|
7867
|
+
}
|
|
7868
|
+
try {
|
|
7869
|
+
return await readCapacityRecords({ redis: client, logsDir, sinceMs, nowMs, retentionDays, localHost, noMirrorReason: url ? "the Valkey did not answer: only this host's files were read" : "no Valkey to read: only this host's files were read" });
|
|
7870
|
+
} finally {
|
|
7871
|
+
signal?.removeEventListener?.("abort", drop);
|
|
7872
|
+
drop();
|
|
7873
|
+
}
|
|
7874
|
+
}
|
|
7875
|
+
|
|
7876
|
+
/**
|
|
7877
|
+
* `client.connect()`, or a rejection after `ms` or as soon as `signal` aborts (the client is then the caller's to
|
|
7878
|
+
* disconnect). The timer goes with an abort, so nothing of a read doctor gave up on holds the process.
|
|
7879
|
+
*/
|
|
7880
|
+
export function connectWithin(client, ms, signal) {
|
|
7881
|
+
return new Promise((resolve, reject) => {
|
|
7882
|
+
const stop = () => (clearTimeout(t), reject(new Error("connect stopped")));
|
|
7883
|
+
const t = setTimeout(() => (signal?.removeEventListener?.("abort", stop), reject(new Error("connect timeout"))), ms);
|
|
7884
|
+
signal?.addEventListener?.("abort", stop, { once: true });
|
|
7885
|
+
Promise.resolve(client.connect()).then(
|
|
7886
|
+
(v) => (clearTimeout(t), signal?.removeEventListener?.("abort", stop), resolve(v)),
|
|
7887
|
+
(e) => (clearTimeout(t), signal?.removeEventListener?.("abort", stop), reject(e)),
|
|
7888
|
+
);
|
|
7889
|
+
});
|
|
7890
|
+
}
|
|
7891
|
+
|
|
7892
|
+
/**
|
|
7893
|
+
* One fact line per host of a capacity report (`INT-CAPACITY-REPORT`), `{ ok: true, label }` each, never a warning:
|
|
7894
|
+
* `Host a: last 7d busy 63% (avg 2.1 of 4 slots, full 12%), promised 48% memory / 40% CPU, used 18% CPU of 8, wait p50
|
|
7895
|
+
* 40s p95 6m, most busy: web`, each part only when it is known, then what the line cannot see: the running jobs not
|
|
7896
|
+
* counted, and the coverage when the history is cut, not shared, or this host's files only. Every control character is
|
|
7897
|
+
* removed (the report admits none; this is the printer not relying on it).
|
|
7898
|
+
*/
|
|
7899
|
+
export function capacityChecks(report, { since = "7d" } = {}) {
|
|
7900
|
+
const out = [];
|
|
7901
|
+
const localOnly = report?.coverage?.source === "local";
|
|
7902
|
+
for (const h of Array.isArray(report?.hosts) ? report.hosts : []) {
|
|
7903
|
+
const notes = [];
|
|
7904
|
+
let line;
|
|
7905
|
+
if (h.coveredMs === 0) {
|
|
7906
|
+
line = `Host ${h.name}: last ${since} no history here`;
|
|
7907
|
+
if (!h.shared) notes.push(notSharedWhy(h, report.coverage));
|
|
7908
|
+
} else {
|
|
7909
|
+
const c = h.capacity ?? {};
|
|
7910
|
+
const slots = Number.isSafeInteger(c.slots) ? ` of ${c.slots} slots` : " at once";
|
|
7911
|
+
const full = h.fullMs !== null ? `, full ${shareText(h.fullMs, h.coveredMs)}` : "";
|
|
7912
|
+
const parts = [`busy ${shareText(h.busyMs, h.coveredMs)} (avg ${milliText(h.avgMilli ?? 0)}${slots}${full})`];
|
|
7913
|
+
const promised = [h.promisedMemPerMille !== null ? `${percentText(h.promisedMemPerMille)} memory` : null, h.promisedCpuPerMille !== null ? `${percentText(h.promisedCpuPerMille)} CPU` : null].filter(Boolean);
|
|
7914
|
+
if (promised.length > 0) parts.push(`promised ${promised.join(" / ")}`);
|
|
7915
|
+
if (h.usedCpuPerMille !== null) parts.push(`used ${percentText(h.usedCpuPerMille)} CPU${Number.isSafeInteger(c.cpus) ? ` of ${c.cpus}` : ""}`);
|
|
7916
|
+
if (h.waits?.n > 0) parts.push(`wait p50 ${durationText(h.waits.p50Ms)} p95 ${durationText(h.waits.p95Ms)}`);
|
|
7917
|
+
if (h.projects?.length > 0) parts.push(`most busy: ${h.projects[0].project ?? "(no project)"}`);
|
|
7918
|
+
line = `Host ${h.name}: last ${since} ${parts.join(", ")}`;
|
|
7919
|
+
if (h.coverage.live > 0) notes.push(`${h.coverage.live} running now, counted to now`);
|
|
7920
|
+
if (h.missingMs > 0) notes.push(`history from ${new Date(h.coverage.fromMs).toISOString().slice(0, 16).replace("T", " ")} UTC only${h.coverage.truncated ? " (the run mirror holds nothing older)" : ""}, earlier time counted as neither busy nor idle`);
|
|
7921
|
+
// The reason with it: a run mirror that did not answer leaves a busy fleet reading nearly idle here.
|
|
7922
|
+
// The reasons end in "only this host's files were read", which this clause already says.
|
|
7923
|
+
const why = typeof report.coverage.reason === "string" ? report.coverage.reason.replace(/: only this host's files were read$/, "") : "";
|
|
7924
|
+
if (localOnly) notes.push(`this host's files only${why ? ` (${why})` : ""}`);
|
|
7925
|
+
}
|
|
7926
|
+
if (h.coverage?.liveNotCounted > 0) notes.push(`${h.coverage.liveNotCounted} running now not counted`);
|
|
7927
|
+
if (h.coverage?.liveUnreadable > 0) notes.push("its running jobs could not be read, not counted");
|
|
7928
|
+
if (report.coverage?.liveRowMissing === h.name) notes.push("no live row read for it, so its running jobs are not known");
|
|
7929
|
+
out.push({ ok: true, label: `${line}${notes.length > 0 ? `; ${notes.join("; ")}` : ""}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, "") });
|
|
7930
|
+
}
|
|
7931
|
+
// What none of these lines can see, in the words every surface of the report uses (REQ-CAPACITY-INSIGHTS): a fact,
|
|
7932
|
+
// never a warning, once after the hosts.
|
|
7933
|
+
if (out.length > 0) out.push({ ok: true, label: JOBS_ONLY.replace(/\.$/, "") });
|
|
7934
|
+
return out;
|
|
7935
|
+
}
|
|
7936
|
+
|
|
7759
7937
|
/**
|
|
7760
7938
|
* The fleet's budgets (issue #596, phase 2), from the registry rows (this host's own included): one line per host that
|
|
7761
7939
|
* publishes a budget (its budget, what its jobs hold, what its holds keep, and the largest project size that fits it),
|
package/src/host-budget.mjs
CHANGED
|
@@ -484,6 +484,9 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
|
|
|
484
484
|
return Number.isSafeInteger(limit) && limit >= 0 ? limit : null;
|
|
485
485
|
};
|
|
486
486
|
|
|
487
|
+
// The runtime's own CPU count from the last facts read (`docker info` NCPU, `podman info` host.cpus), or null: what a
|
|
488
|
+
// run record names as the host's CPUs (issue #599), never the worker's own count, which on Docker Desktop is the Mac's.
|
|
489
|
+
let hostCpus = null;
|
|
487
490
|
const refresh = async () => {
|
|
488
491
|
let facts = {};
|
|
489
492
|
try {
|
|
@@ -493,6 +496,7 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
|
|
|
493
496
|
}
|
|
494
497
|
const next = computeHostBudget(settings, facts, jobDefault);
|
|
495
498
|
budget = { memMiB: next.memMiB, cpuCenti: next.cpuCenti };
|
|
499
|
+
hostCpus = Number.isSafeInteger(facts.hostCpus) && facts.hostCpus >= 1 ? facts.hostCpus : null;
|
|
496
500
|
detail = next.detail;
|
|
497
501
|
for (const entry of ledger.values()) {
|
|
498
502
|
if (entry.guess) Object.assign(entry, pessimisticSize([], entry.guess, budget));
|
|
@@ -618,6 +622,8 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
|
|
|
618
622
|
refresh,
|
|
619
623
|
/** The budget in force: `{ memMiB, cpuCenti }`, each an integer, `Infinity` (off) or null (unknown). */
|
|
620
624
|
current: () => ({ ...budget }),
|
|
625
|
+
/** The runtime's CPU count from the last facts read, or null when no venue answered. */
|
|
626
|
+
hostCpus: () => hostCpus,
|
|
621
627
|
detail: () => detail,
|
|
622
628
|
/** null when `size` can start here some day, else `host` or `share` (`neverFits`). */
|
|
623
629
|
neverFits: (size, project, limits = scopedLimits()) => neverFits(size, budget, project ? rulesOf(limits).shareOf(project) : null),
|
package/src/host-registry.mjs
CHANGED
|
@@ -29,8 +29,14 @@
|
|
|
29
29
|
* from the reader rather than the writer -- the panel is where an operator screenshots, and doctor
|
|
30
30
|
* prints these rows. A value that must be carried but cannot satisfy the rule is HASHED before it gets
|
|
31
31
|
* here (`scopeKeyPrefix`'s idiom), never abbreviated.
|
|
32
|
+
*
|
|
33
|
+
* `jobs` (issue #599, phase 2) is the one field that carries a list: JSON of the jobs this host runs now, ids and
|
|
34
|
+
* integers only, built and read back by `live-jobs.mjs`, whose header argues each field against this rule. The reader
|
|
35
|
+
* below parses it through that module's allowlist, so a row's `jobs` is an array (or null), never the raw string.
|
|
32
36
|
*/
|
|
33
37
|
|
|
38
|
+
import { parseJobsMore, parseLiveJobs } from "./live-jobs.mjs";
|
|
39
|
+
|
|
34
40
|
/** The index. A SET cannot expire its members, so the leak is handled by the reader, as `wait:held` does. */
|
|
35
41
|
export const HOST_SET = "host:live";
|
|
36
42
|
|
|
@@ -270,7 +276,7 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
270
276
|
* one treats them alike: "there are no other hosts" and "I could not find out" differ, and a panel that
|
|
271
277
|
* renders the second as the first tells an operator their fleet is gone when Valkey merely blinked.
|
|
272
278
|
*/
|
|
273
|
-
export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS } = {}) {
|
|
279
|
+
export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true } = {}) {
|
|
274
280
|
let names;
|
|
275
281
|
try {
|
|
276
282
|
names = await bounded(redis.smembers(HOST_SET), timeoutMs);
|
|
@@ -285,12 +291,24 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
|
|
|
285
291
|
try {
|
|
286
292
|
const row = await bounded(redis.hgetall(hostKey(member)), timeoutMs);
|
|
287
293
|
if (!row || Object.keys(row).length === 0) {
|
|
288
|
-
|
|
294
|
+
// `prune: false` for a reader that promises to write nothing (the capacity report, issue #599): the stale member
|
|
295
|
+
// is skipped the same, and left for the next pruning reader.
|
|
296
|
+
if (prune) await bounded(redis.srem(HOST_SET, member), timeoutMs).catch(() => {});
|
|
289
297
|
continue;
|
|
290
298
|
}
|
|
291
299
|
const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
|
|
300
|
+
// The running jobs through the allowlist (issue #599, phase 2): an array, or null where the row publishes none (a
|
|
301
|
+
// worker from before the field) or none that parses. A listed entry that does not read is a running job not
|
|
302
|
+
// counted, so it joins `jobsMore` rather than vanishing.
|
|
303
|
+
const live = parseLiveJobs(row.jobs);
|
|
304
|
+
const more = parseJobsMore(row.jobsMore);
|
|
292
305
|
hosts.push({
|
|
293
306
|
...row,
|
|
307
|
+
jobs: live.jobs,
|
|
308
|
+
jobsMore: live.jobs === null ? more : (more ?? 0) + live.dropped,
|
|
309
|
+
// A row that HAS a `jobs` value that is not a list: how many it runs is unknown, which a reader must say
|
|
310
|
+
// rather than read as a worker from before the field.
|
|
311
|
+
jobsUnreadable: live.jobs === null && typeof row.jobs === "string" && row.jobs !== "",
|
|
294
312
|
name: row.name || member,
|
|
295
313
|
// Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
|
|
296
314
|
// A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the
|