@edgehero/pi-dispatch 2.1.0 → 3.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 +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +8 -1
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2261 -203
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +107 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +135 -7
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/envelope.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The allocation envelope (issue #504, INT-ENVELOPE-FILE-CONTRACT): the operator's outer limits for delegated
|
|
3
|
+
* allocation. One `envelope.json` of a total per window, a floor per project, default weights and the delegation
|
|
4
|
+
* rules. An agent's plan moves headroom between projects INSIDE it (priorities.mjs); nothing an agent writes reaches
|
|
5
|
+
* this file.
|
|
6
|
+
*
|
|
7
|
+
* Pure and fs-injectable, on the scoped-limits.mjs and projects.mjs pattern: `parseEnvelope` judges the file TEXT
|
|
8
|
+
* fail-loud, `loadEnvelope` layers the one fs read on top. The worker will hold the parsed envelope in a watched ref
|
|
9
|
+
* with a last-good copy, as it does the projects file.
|
|
10
|
+
*
|
|
11
|
+
* `version` is REQUIRED and a newer one is refused: this is a money file, and a field an old build silently drops
|
|
12
|
+
* could widen what a plan may move. Unknown keys are REFUSED here, stricter than the operator-file policy that drops
|
|
13
|
+
* them in scoped-limits.json and projects.json: a mistyped `maxStepPct` dropped in silence would read as a bound the
|
|
14
|
+
* file does not hold, and every key in this file is a bound.
|
|
15
|
+
*
|
|
16
|
+
* Every refusal names the field, never a value: a value typed into the wrong field could be anything.
|
|
17
|
+
*
|
|
18
|
+
* Custom: envelope validated inline per scoped-limits.mjs precedent; zod not in deps
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync, realpathSync as fsRealpathSync, statSync as fsStatSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
23
|
+
import { configError } from "./config.mjs";
|
|
24
|
+
import { fingerprint } from "./fingerprint.mjs";
|
|
25
|
+
import { formatMicros, parseUsdMicros } from "./money.mjs";
|
|
26
|
+
import { OTHER, PLAN_WRITERS, WEIGHT_MAX } from "./priorities.mjs";
|
|
27
|
+
import { isProjectId } from "./project-id.mjs";
|
|
28
|
+
|
|
29
|
+
/** The highest schema version this build reads. A file declaring a higher one is refused loudly. */
|
|
30
|
+
export const ENVELOPE_VERSION = 1;
|
|
31
|
+
/** The windows an envelope may govern: the UTC day, the Monday week and the month of budget.mjs's keys. */
|
|
32
|
+
export const ENVELOPE_WINDOWS = Object.freeze(["day", "week", "month"]);
|
|
33
|
+
/** Each window's operator dollar field on a scoped-limits row (`project:<id>`). */
|
|
34
|
+
const WINDOW_USD_FIELD = Object.freeze({ day: "dayUsd", week: "weekUsd", month: "monthUsd" });
|
|
35
|
+
/** Each window's row fields that bound it: its own and every LONGER window's, since a week cap also caps each day in it. */
|
|
36
|
+
const BOUNDING_FIELDS = Object.freeze({ day: ["dayUsd", "weekUsd", "monthUsd"], week: ["weekUsd", "monthUsd"], month: ["monthUsd"] });
|
|
37
|
+
|
|
38
|
+
/** The longest interval and plan lifetime a file may set: a year. Beyond it a value is a typo, not a policy. */
|
|
39
|
+
const MAX_INTERVAL_HOURS = 24 * 366;
|
|
40
|
+
const MAX_PLAN_DAYS = 366;
|
|
41
|
+
|
|
42
|
+
const TOP_KEYS = new Set(["version", "window", "totalUsd", "floorsUsd", "defaultWeights", "delegation"]);
|
|
43
|
+
const DELEGATION_KEYS = new Set(["enabled", "writers", "maxStepPct", "minIntervalHours", "maxPlanDays"]);
|
|
44
|
+
|
|
45
|
+
function isPlainObject(value) {
|
|
46
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function refuseUnknown(object, allowed, at, path) {
|
|
50
|
+
const extra = Object.keys(object).filter((k) => !allowed.has(k)).length;
|
|
51
|
+
// Counted, never quoted: the key itself may be anything an editor produced.
|
|
52
|
+
if (extra > 0) throw configError(`envelope file: ${at} has ${extra} unknown key(s); the fields are ${[...allowed].join(", ")}: ${path}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A floor: `parseUsdMicros`'s rules (a plain decimal, at most 6 decimals, a string recommended), except that 0 is a
|
|
57
|
+
* floor and not a refusal. A floor of 0 is the honest "no guaranteed share", and `_other`'s default.
|
|
58
|
+
*/
|
|
59
|
+
function floorMicros(value, field, path) {
|
|
60
|
+
if (value === 0 || (typeof value === "string" && /^0(\.0{1,6})?$/.test(value))) return 0;
|
|
61
|
+
try {
|
|
62
|
+
return parseUsdMicros(value, field);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw configError(`envelope file: ${error.message.replace(/above 0/, "of 0 or more")}: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse, validate and normalize the envelope file TEXT. Returns
|
|
70
|
+
* `{ version, window, totalMicros, floors, defaultWeights, delegation }`:
|
|
71
|
+
* - `floors`: `{ id: micros }` for every project the file names and `_other` (floor 0 when absent);
|
|
72
|
+
* - `defaultWeights`: `{ id: weight }` for the same entries, each 1 when absent;
|
|
73
|
+
* - `delegation`: `{ enabled, writers, maxStepPct, minIntervalHours, maxPlanDays }`, `enabled` false and the rest
|
|
74
|
+
* null when the file has no `delegation`.
|
|
75
|
+
* Throws `configError` on anything malformed. `path` is for messages only.
|
|
76
|
+
*
|
|
77
|
+
* `projects` (the parsed projects.json) and `limits` (the parsed scoped-limits.json) are what the floors are judged
|
|
78
|
+
* against: a floor names a project or `_other`, and a project's operator dollar row for the envelope's window may not
|
|
79
|
+
* sit below its floor. `maxCostMicros` is the deployment's per-job cost cap, or null: an envelope needs one, because
|
|
80
|
+
* every governed job reserves its per-job cap against its project's allocation.
|
|
81
|
+
*/
|
|
82
|
+
export function parseEnvelope(text, path, { projects = [], limits = [], maxCostMicros = null } = {}) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = JSON.parse(text);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Not the parser's own message: it quotes file text around the fault.
|
|
88
|
+
const at = /position (\d+)/.exec(String(error?.message))?.[1];
|
|
89
|
+
throw configError(`envelope file is not valid JSON${at === undefined ? "" : ` (at character ${at})`}: ${path}`);
|
|
90
|
+
}
|
|
91
|
+
if (!isPlainObject(raw)) throw configError(`envelope file must be an object with "version", "window", "totalUsd" and "floorsUsd": ${path}`);
|
|
92
|
+
const version = raw.version;
|
|
93
|
+
if (!Number.isInteger(version) || version < 1) throw configError(`envelope file must have "version": ${ENVELOPE_VERSION} (an integer >= 1): ${path}`);
|
|
94
|
+
if (version > ENVELOPE_VERSION) throw configError(`envelope file written by a newer pi-dispatch (version ${version}; this build understands ${ENVELOPE_VERSION}): ${path}`);
|
|
95
|
+
refuseUnknown(raw, TOP_KEYS, "the file", path);
|
|
96
|
+
|
|
97
|
+
// A per-job cap first: without one no governed job can reserve, so every such job would refuse as a configuration
|
|
98
|
+
// error, and an envelope that admits nothing is a mistake the boot should name.
|
|
99
|
+
// A positive safe integer of micro-dollars, nothing else: 0 would be a cap that admits no priced call, and a string
|
|
100
|
+
// or a float here is a caller's defect that must not read as "a cap is set".
|
|
101
|
+
if (!Number.isSafeInteger(maxCostMicros) || maxCostMicros <= 0) {
|
|
102
|
+
throw configError(`envelope file needs a per-job cost cap: set PI_MAX_COST_USD (each job governed by the envelope reserves its per-job cap against its project's allocation): ${path}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!ENVELOPE_WINDOWS.includes(raw.window)) throw configError(`envelope file: window must be one of ${ENVELOPE_WINDOWS.join(", ")}: ${path}`);
|
|
106
|
+
const window = raw.window;
|
|
107
|
+
|
|
108
|
+
let totalMicros;
|
|
109
|
+
try {
|
|
110
|
+
totalMicros = parseUsdMicros(raw.totalUsd, "totalUsd");
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw configError(`envelope file: ${error.message}: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!isPlainObject(raw.floorsUsd)) throw configError(`envelope file: floorsUsd must be an object of project id to dollars: ${path}`);
|
|
116
|
+
const known = new Set((Array.isArray(projects) ? projects : []).map((p) => p?.id));
|
|
117
|
+
const floors = {};
|
|
118
|
+
for (const id of Object.keys(raw.floorsUsd).sort()) {
|
|
119
|
+
checkEntryId(id, known, "floorsUsd", path);
|
|
120
|
+
floors[id] = floorMicros(raw.floorsUsd[id], `floorsUsd.${id}`, path);
|
|
121
|
+
}
|
|
122
|
+
if (floors[OTHER] === undefined) floors[OTHER] = 0;
|
|
123
|
+
const sum = Object.values(floors).reduce((s, v) => s + v, 0);
|
|
124
|
+
if (sum > totalMicros) throw configError(`envelope file: the floors add up to ${formatMicros(sum)}, above totalUsd ${formatMicros(totalMicros)}: ${path}`);
|
|
125
|
+
|
|
126
|
+
const defaultWeights = {};
|
|
127
|
+
if (raw.defaultWeights !== undefined) {
|
|
128
|
+
if (!isPlainObject(raw.defaultWeights)) throw configError(`envelope file: defaultWeights must be an object of project id to weight: ${path}`);
|
|
129
|
+
for (const id of Object.keys(raw.defaultWeights)) {
|
|
130
|
+
checkEntryId(id, known, "defaultWeights", path);
|
|
131
|
+
if (floors[id] === undefined) throw configError(`envelope file: defaultWeights.${id} names a project with no floor; add it to floorsUsd (0 is a floor): ${path}`);
|
|
132
|
+
const w = raw.defaultWeights[id];
|
|
133
|
+
if (!Number.isInteger(w) || w < 0 || w > WEIGHT_MAX) throw configError(`envelope file: defaultWeights.${id} must be an integer from 0 to ${WEIGHT_MAX}: ${path}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const id of Object.keys(floors).sort()) defaultWeights[id] = raw.defaultWeights?.[id] ?? 1;
|
|
137
|
+
|
|
138
|
+
checkRowsAgainstFloors(floors, window, limits, path);
|
|
139
|
+
const delegation = parseDelegation(raw.delegation, path);
|
|
140
|
+
return { version, window, totalMicros, floors, defaultWeights, delegation };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A key of `floorsUsd` or `defaultWeights`: `_other`, or a project id that projects.json has. */
|
|
144
|
+
function checkEntryId(id, known, field, path) {
|
|
145
|
+
if (id === OTHER) return;
|
|
146
|
+
// The id is quoted only once it is known to match the id charset: it then cannot carry a control byte or a path.
|
|
147
|
+
if (!isProjectId(id)) throw configError(`envelope file: a key of ${field} is neither ${OTHER} nor a project id: ${path}`);
|
|
148
|
+
if (!known.has(id)) throw configError(`envelope file: ${field}.${id} names a project that is not in the projects file; add the project there first, or remove the key: ${path}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A project's operator dollar row (`project:<id>` in scoped-limits.json) for the envelope's window OR A LONGER ONE, below
|
|
153
|
+
* the project's floor, refuses the file and names both. The allocation can only lower a project's cap
|
|
154
|
+
* (`min(row, allocation)`), so such a floor promises money the operator's own row never lets the project spend: a
|
|
155
|
+
* `weekUsd` of $1 bounds every day of that week, so a day floor of $50 under it is as empty as a day row of $1. A
|
|
156
|
+
* shorter row (a `dayUsd` under a week envelope) is not compared: seven days of it may still reach the floor.
|
|
157
|
+
*/
|
|
158
|
+
function checkRowsAgainstFloors(floors, window, limits, path) {
|
|
159
|
+
for (const [id, floor] of Object.entries(floors)) {
|
|
160
|
+
if (id === OTHER || floor === 0) continue;
|
|
161
|
+
const row = (Array.isArray(limits) ? limits : []).find((l) => l?.scope === `project:${id}`);
|
|
162
|
+
for (const field of BOUNDING_FIELDS[window]) {
|
|
163
|
+
const value = row?.[field];
|
|
164
|
+
if (value === null || value === undefined) continue;
|
|
165
|
+
const cap = parseUsdMicros(value, field);
|
|
166
|
+
if (cap < floor) {
|
|
167
|
+
throw configError(`envelope file: floorsUsd.${id} (${formatMicros(floor)}) is above the scoped-limits row project:${id} ${field} (${formatMicros(cap)}); lower the floor or raise the row: ${path}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `delegation` block, normalized. Absent means off. When on, every bound is required: none has a silent default. */
|
|
174
|
+
function parseDelegation(raw, path) {
|
|
175
|
+
if (raw === undefined) return { enabled: false, writers: [], maxStepPct: null, minIntervalHours: null, maxPlanDays: null };
|
|
176
|
+
if (!isPlainObject(raw)) throw configError(`envelope file: delegation must be an object: ${path}`);
|
|
177
|
+
refuseUnknown(raw, DELEGATION_KEYS, "delegation", path);
|
|
178
|
+
if (typeof raw.enabled !== "boolean") throw configError(`envelope file: delegation.enabled must be true or false: ${path}`);
|
|
179
|
+
const out = { enabled: raw.enabled, writers: [], maxStepPct: null, minIntervalHours: null, maxPlanDays: null };
|
|
180
|
+
const need = raw.enabled;
|
|
181
|
+
if (raw.writers !== undefined || need) {
|
|
182
|
+
if (!Array.isArray(raw.writers) || raw.writers.length === 0 || raw.writers.some((w) => !PLAN_WRITERS.includes(w)) || new Set(raw.writers).size !== raw.writers.length) {
|
|
183
|
+
throw configError(`envelope file: delegation.writers must be a non-empty list of distinct writers from ${PLAN_WRITERS.join(", ")}: ${path}`);
|
|
184
|
+
}
|
|
185
|
+
out.writers = [...raw.writers].sort();
|
|
186
|
+
}
|
|
187
|
+
if (raw.maxStepPct !== undefined || need) {
|
|
188
|
+
// 0 is refused: delegation that may move nothing is delegation off, and `enabled: false` says that plainly.
|
|
189
|
+
if (!Number.isInteger(raw.maxStepPct) || raw.maxStepPct < 1 || raw.maxStepPct > 100) {
|
|
190
|
+
throw configError(`envelope file: delegation.maxStepPct must be an integer from 1 to 100 (to stop plans, set delegation.enabled to false): ${path}`);
|
|
191
|
+
}
|
|
192
|
+
out.maxStepPct = raw.maxStepPct;
|
|
193
|
+
}
|
|
194
|
+
if (raw.minIntervalHours !== undefined || need) {
|
|
195
|
+
if (!Number.isInteger(raw.minIntervalHours) || raw.minIntervalHours < 0 || raw.minIntervalHours > MAX_INTERVAL_HOURS) {
|
|
196
|
+
throw configError(`envelope file: delegation.minIntervalHours must be an integer from 0 to ${MAX_INTERVAL_HOURS}: ${path}`);
|
|
197
|
+
}
|
|
198
|
+
out.minIntervalHours = raw.minIntervalHours;
|
|
199
|
+
}
|
|
200
|
+
if (raw.maxPlanDays !== undefined || need) {
|
|
201
|
+
if (!Number.isInteger(raw.maxPlanDays) || raw.maxPlanDays < 1 || raw.maxPlanDays > MAX_PLAN_DAYS) {
|
|
202
|
+
throw configError(`envelope file: delegation.maxPlanDays must be an integer from 1 to ${MAX_PLAN_DAYS}: ${path}`);
|
|
203
|
+
}
|
|
204
|
+
out.maxPlanDays = raw.maxPlanDays;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load and validate the envelope file named by `config.envelopeFile`. Returns null when it is unset: no envelope, no
|
|
211
|
+
* delegation anywhere, and every cap exactly as the operator's rows and windows set it. An empty string is a value, so
|
|
212
|
+
* it reaches `existsSync` and is refused, the rule the projects and scoped-limits keys follow. `context` is
|
|
213
|
+
* `parseEnvelope`'s. `readFileSync`/`existsSync` are injectable for tests.
|
|
214
|
+
*/
|
|
215
|
+
export function loadEnvelope(config, context = {}, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
216
|
+
const path = config.envelopeFile;
|
|
217
|
+
if (path === null || path === undefined) return null;
|
|
218
|
+
if (!existsSync(path)) throw configError(`envelope file does not exist: ${path}`);
|
|
219
|
+
return parseEnvelope(readFileSync(path, "utf8"), path, context);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The 16-hex digest of a normalized envelope (`fingerprint.mjs`: sorted keys, sha256). Two hosts with one envelope
|
|
224
|
+
* have one digest, whatever the file's whitespace or key order; a host whose digest differs from the applied plan's
|
|
225
|
+
* refuses governed jobs (issue #504 part 7). A digest, never the values, so it is admissible in a host row.
|
|
226
|
+
*/
|
|
227
|
+
export function envelopeDigest(envelope) {
|
|
228
|
+
return fingerprint(envelope);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A file's identity: its device and inode, as `stat` with `bigint` reports them. Two names of one file share it. */
|
|
232
|
+
function identityOf(stat) {
|
|
233
|
+
return `${stat.dev}:${stat.ino}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `stat(path)` with `bigint`, following links as the kernel does, or null when nothing is there (ENOENT, ENOTDIR). Any
|
|
238
|
+
* other failure is a configuration error naming the path: a path the worker cannot stat is one it cannot vouch for.
|
|
239
|
+
*/
|
|
240
|
+
function statOrNull(path, statSync, what) {
|
|
241
|
+
try {
|
|
242
|
+
return statSync(path, { bigint: true });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
|
245
|
+
throw configError(`${what} ${JSON.stringify(String(path))} cannot be read (${error?.code ?? "error"}), so the envelope's containment check cannot judge it`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The job path's candidate locations, as path strings to `stat`. A job path's spelling is never a reason to refuse;
|
|
251
|
+
* instead it is judged at every location it can name:
|
|
252
|
+
* - the KERNEL's: absolutized WITHOUT normalizing (a relative path is appended to the working directory) and handed
|
|
253
|
+
* raw to `stat`, so `/srv/a/link/../b` follows the link first, then `..`;
|
|
254
|
+
* - the TEXTUAL one, `resolve(raw)`: what a container runtime mounts. Docker cleans a bind source as text before
|
|
255
|
+
* mounting it, so `T/link/../b` mounts `T/b` whatever `T/link` points at, and a container can write there;
|
|
256
|
+
* - for a RELATIVE path, both again against the shell's working directory: the Docker and Podman CLIs absolutize a
|
|
257
|
+
* relative bind source with Go's `filepath.Abs`, whose `os.Getwd` returns `$PWD` when `$PWD` names the working
|
|
258
|
+
* directory, possibly through a symlink or a firmlink, so `./../y` from such a cwd reaches the LOGICAL parent,
|
|
259
|
+
* not the physical one `process.cwd()` gives. Added when `$PWD` is absolute and has the cwd's identity (device and
|
|
260
|
+
* inode): Go's own `SameFile` test, so this is exactly when Go uses it.
|
|
261
|
+
*/
|
|
262
|
+
function jobPathCandidates(path, cwd, env, statSync) {
|
|
263
|
+
const bases = [cwd];
|
|
264
|
+
if (!isAbsolute(path)) {
|
|
265
|
+
// env-internal PWD: the shell's logical working directory, read only to see the cwd the way Go's os.Getwd does.
|
|
266
|
+
const pwd = env?.PWD;
|
|
267
|
+
if (typeof pwd === "string" && isAbsolute(pwd) && pwd !== cwd) {
|
|
268
|
+
let same = false;
|
|
269
|
+
try {
|
|
270
|
+
same = identityOf(statSync(pwd, { bigint: true })) === identityOf(statSync(cwd, { bigint: true }));
|
|
271
|
+
} catch {
|
|
272
|
+
same = false;
|
|
273
|
+
}
|
|
274
|
+
if (same) bases.push(pwd);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const candidates = [];
|
|
278
|
+
for (const base of isAbsolute(path) ? [null] : bases) {
|
|
279
|
+
const raw = base === null ? path : `${base}${sep}${path}`;
|
|
280
|
+
candidates.push(raw, resolve(raw));
|
|
281
|
+
}
|
|
282
|
+
return candidates;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The envelope path, checked to be its own CANONICAL path, and its real path back. Absolute, and
|
|
287
|
+
* `realpathSync.native(path) === path`: no symlink anywhere on the way, no `.` or `..`, no case variant, no firmlink
|
|
288
|
+
* spelling. A symlink on the way could sit inside a job path, however far up the chain, and the job could repoint it;
|
|
289
|
+
* requiring the canonical path removes every such link instead of chasing them. The refusal names the canonical path
|
|
290
|
+
* to write instead, when there is one. The FILE must exist (the check runs with a successful load) and must have one
|
|
291
|
+
* link: a hard link elsewhere is the same file under a name this check cannot see, and it could sit in a job path.
|
|
292
|
+
*/
|
|
293
|
+
function canonicalEnvelopePath(path, realpath, statSync) {
|
|
294
|
+
const text = String(path);
|
|
295
|
+
if (!isAbsolute(text)) throw configError(`the envelope path ${JSON.stringify(text)} must be absolute: write its canonical path, with no symlink, "." or ".." in it`);
|
|
296
|
+
let real;
|
|
297
|
+
try {
|
|
298
|
+
real = realpath(text);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw configError(`the envelope path ${JSON.stringify(text)} cannot be resolved (${error?.code ?? "error"}); the containment check needs the file to exist at its canonical path`);
|
|
301
|
+
}
|
|
302
|
+
if (real !== text) {
|
|
303
|
+
throw configError(`the envelope path ${JSON.stringify(text)} is not its own canonical path; write ${JSON.stringify(real)} instead (no symlink, ".", "..", case variant or alias anywhere on the way), so no link a job can repoint leads to it`);
|
|
304
|
+
}
|
|
305
|
+
const stat = statSync(text, { bigint: true });
|
|
306
|
+
// Before the link count: a directory's nlink counts its subdirectories, so "has 3 hard links" would misname it.
|
|
307
|
+
if (!stat.isFile()) {
|
|
308
|
+
throw configError(`the envelope path ${JSON.stringify(text)} is not a regular file; point it at the envelope file itself`);
|
|
309
|
+
}
|
|
310
|
+
if (stat.nlink > 1n) {
|
|
311
|
+
throw configError(`the envelope file ${JSON.stringify(text)} has ${stat.nlink} hard links; a second name of the file could sit inside a job path, so keep exactly one`);
|
|
312
|
+
}
|
|
313
|
+
return real;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The identities (device and inode) of the envelope file and every directory above it. A job path "contains" the
|
|
318
|
+
* envelope exactly when one of its locations has one of these identities: identity, not spelling, so a firmlink
|
|
319
|
+
* (`/System/Volumes/Data/Users` and `/Users` on macOS), a bind-mount alias, a case variant or any other second name of
|
|
320
|
+
* a job path is judged as the directory it is. Equality counts: a job path that IS the envelope's folder exposes the
|
|
321
|
+
* file as surely as its parent does.
|
|
322
|
+
*/
|
|
323
|
+
function envelopeIdentities(real, statSync) {
|
|
324
|
+
const out = new Set();
|
|
325
|
+
let p = real;
|
|
326
|
+
for (;;) {
|
|
327
|
+
const st = statOrNull(p, statSync, "the envelope path's ancestor");
|
|
328
|
+
if (st !== null) out.add(identityOf(st));
|
|
329
|
+
const parent = dirname(p);
|
|
330
|
+
if (parent === p) break;
|
|
331
|
+
p = parent;
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The identities a job folder may never have (issue #504 part B, the prepare-time re-check): the envelope file's
|
|
338
|
+
* folder and every directory above it, as `envelopeIdentities` gives them, keyed `dev:ino`. A local job's folder is
|
|
339
|
+
* resolved at prepare and refused when its identity is one of these, so a folder whose spelling was checked earlier
|
|
340
|
+
* cannot have become the envelope's folder through a swapped link since. Throws as `envelopeInsideJobPaths` does on
|
|
341
|
+
* an envelope path that is not canonical, so a caller that cannot vouch for the envelope refuses rather than guesses.
|
|
342
|
+
*/
|
|
343
|
+
export function envelopeProtectedIdentities(envelopePath, { realpathSync = fsRealpathSync.native, statSync = fsStatSync } = {}) {
|
|
344
|
+
return envelopeIdentities(canonicalEnvelopePath(envelopePath, realpathSync, statSync), statSync);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** A `stat` result's identity (`dev:ino`), the key `envelopeProtectedIdentities` holds. Pass a `bigint` stat. */
|
|
348
|
+
export function fileIdentity(stat) {
|
|
349
|
+
return identityOf(stat);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The first host path a job container can see that holds the envelope file, as `{ kind, path }`, or null. The boot
|
|
354
|
+
* refuses an envelope inside one (issue #504 part 3): a job that can write the envelope can write its own bounds.
|
|
355
|
+
* `kind` is `cron-folder` (a cron trigger's `run.folder`, bind-mounted read-write), `run-root` (a
|
|
356
|
+
* `PI_DISPATCH_RUN_ROOTS` root, where `dispatch_run` and chained jobs run), `skills-dir` (a trigger's `run.skillsDir`,
|
|
357
|
+
* copied in) or `global-pi-dir` (`PI_GLOBAL_PI_DIR`). `path` is the configured job path, as the operator wrote it.
|
|
358
|
+
*
|
|
359
|
+
* The envelope path must be its own canonical path, and the file must have one link (`canonicalEnvelopePath`).
|
|
360
|
+
* Containment is then decided by FILE IDENTITY (device and inode), never by comparing path strings: a job path contains
|
|
361
|
+
* the envelope when any of its locations (`jobPathCandidates`) is the envelope's folder or a directory above it
|
|
362
|
+
* (`envelopeIdentities`). A job path is skipped only when none of its locations exists. Residual, named: a host bind
|
|
363
|
+
* mount of the envelope's folder placed under a job path is a second view this check cannot see; do not make one.
|
|
364
|
+
*
|
|
365
|
+
* Throws `configError` when the envelope path is not canonical or the file has more than one link, when a run root is
|
|
366
|
+
* relative, and when a path cannot be read for a reason other than its absence. `realpathSync`, `statSync`, `cwd` and
|
|
367
|
+
* `env` are injectable for tests.
|
|
368
|
+
*/
|
|
369
|
+
export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env } = {}) {
|
|
370
|
+
const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync);
|
|
371
|
+
// A relative run root is refused under an envelope, as `PI_GLOBAL_PI_DIR` and `run.skillsDir` already must be
|
|
372
|
+
// absolute: the worker would judge it against its own working directory, while the admin's `dispatch_run` resolves
|
|
373
|
+
// it in the operator's pi process, so the two could mean different folders.
|
|
374
|
+
for (const root of runRoots ?? []) {
|
|
375
|
+
if (typeof root === "string" && root.trim() !== "" && !isAbsolute(root)) {
|
|
376
|
+
throw configError(`the run root ${JSON.stringify(root)} in PI_DISPATCH_RUN_ROOTS is relative; with an envelope set, every run root must be an absolute path, so the worker and the admin judge one folder`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const identities = envelopeIdentities(real, statSync);
|
|
380
|
+
const groups = [
|
|
381
|
+
["cron-folder", cronFolders],
|
|
382
|
+
["run-root", runRoots],
|
|
383
|
+
["skills-dir", skillsDirs],
|
|
384
|
+
["global-pi-dir", globalPiDir === null || globalPiDir === undefined ? [] : [globalPiDir]],
|
|
385
|
+
];
|
|
386
|
+
for (const [kind, paths] of groups) {
|
|
387
|
+
for (const p of paths ?? []) {
|
|
388
|
+
if (typeof p !== "string" || p.trim() === "") continue;
|
|
389
|
+
for (const candidate of jobPathCandidates(p, cwd, env, statSync)) {
|
|
390
|
+
const st = statOrNull(candidate, statSync, "a job path");
|
|
391
|
+
if (st !== null && identities.has(identityOf(st))) return { kind, path: p };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Load the envelope AND judge where it lies, in one step (issue #504 part B, INT-ENVELOPE-FILE-CONTRACT): every load
|
|
400
|
+
* that succeeds is followed by `envelopeInsideJobPaths` against the job paths of the moment, at boot and on every
|
|
401
|
+
* reload, because a later edit can move the file, or a job path, so that one holds the other. Returns the parsed
|
|
402
|
+
* envelope, null when `PI_ENVELOPE_FILE` is unset, or throws a `configError` naming the job path that holds it.
|
|
403
|
+
* `maxCostMicros` is the merged per-job cap in micro-dollars, or null.
|
|
404
|
+
*/
|
|
405
|
+
export function loadEnvelopeChecked(config, { projects = [], limits = [], maxCostMicros = null, jobPaths }, { io = {}, containment = {} } = {}) {
|
|
406
|
+
const envelope = loadEnvelope(config, { projects, limits, maxCostMicros }, io);
|
|
407
|
+
if (envelope === null) return null;
|
|
408
|
+
const inside = envelopeInsideJobPaths(config.envelopeFile, jobPaths, containment);
|
|
409
|
+
if (inside) {
|
|
410
|
+
throw configError(`the envelope file ${JSON.stringify(String(config.envelopeFile))} lies inside a job path (${inside.kind} ${JSON.stringify(inside.path)}) that a job container can see, so a job could write its own bounds; move the envelope outside every cron run.folder, PI_DISPATCH_RUN_ROOTS root, run.skillsDir and PI_GLOBAL_PI_DIR`);
|
|
411
|
+
}
|
|
412
|
+
return envelope;
|
|
413
|
+
}
|
package/src/exit-code.mjs
CHANGED
|
@@ -87,6 +87,28 @@ export function decideWait(exitCode) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A listener for an error on the CLI's stdout (issue #503, gate round 1): a write to a pipe whose reader has exited
|
|
92
|
+
* fails with EPIPE, which Node emits as an `error` event, and with no listener that is an uncaught exception printed
|
|
93
|
+
* with its whole stack after the verb had already done its work. Installed by the worker's CLI for every verb. It only
|
|
94
|
+
* quiets the trace: the exit code stays what that uncaught exception gave (1), or the verb's own when it set one.
|
|
95
|
+
*/
|
|
96
|
+
export function installStdoutPipeGuard({ stream = process.stdout, proc = process, write = (line) => process.stderr.write(line) } = {}) {
|
|
97
|
+
stream.on("error", (error) => {
|
|
98
|
+
// The reader went away (`pi-dispatch egress render | head -1`). The guard removes the stack trace and NOTHING
|
|
99
|
+
// else (gate round 2): the exit code is the one an unhandled EPIPE gave, 1, unless the verb had already set its
|
|
100
|
+
// own. Exiting 0 here told systemd's Restart=on-failure and launchd's SuccessfulExit=false that a long-running
|
|
101
|
+
// worker whose log reader died had finished cleanly, so neither restarted it (measured).
|
|
102
|
+
if (error?.code === "EPIPE") {
|
|
103
|
+
proc.exit(typeof proc.exitCode === "number" ? proc.exitCode : EXIT_INFRA);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Anything else on stdout is a fault worth one line on stderr, and infra's exit code, as Node's own default would be.
|
|
107
|
+
write(`error: writing to stdout failed: ${error?.code ?? error?.message ?? "unknown"}\n`);
|
|
108
|
+
proc.exit(EXIT_INFRA);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/**
|
|
91
113
|
* A process-level printer for a promise nobody handled (PR #475's review, round 2): Node prints such a rejection's
|
|
92
114
|
* reason WHOLE, and a Valkey client's error may carry what it sent (connection.mjs scrubs that at the source; this is
|
package/src/fleet-lease.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet-wide leases for the
|
|
2
|
+
* Fleet-wide leases for the bounds that stopped meaning what they say when a second host appeared
|
|
3
3
|
* (issue #57).
|
|
4
4
|
*
|
|
5
5
|
* `PI_WAIT_CHECK_SLOTS` and a `scoped-limits.json` row's `concurrent` are both enforced by
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* a second source of truth: it is the SAME source writing down what it just established. Making the
|
|
28
28
|
* reaper the claim's owner removes the contradiction rather than arguing around it.
|
|
29
29
|
*
|
|
30
|
+
* The ENDPOINT claim (issue #503) is the third, and it is a scope claim in every property that matters
|
|
31
|
+
* here: a job takes one slot of a declared model server at pickup and holds it until its container is
|
|
32
|
+
* gone, so the claim is for a container, its TTL is the scope claim's, and the boot reaper owns it the
|
|
33
|
+
* same way. Local jobs take it too, where they skip the scope claim: a folder path names nothing across
|
|
34
|
+
* hosts, but an endpoint is one physical server.
|
|
35
|
+
*
|
|
30
36
|
* N INDEPENDENT KEYS, NEVER ONE COUNTER. A counter with one TTL loses every claim when it expires and
|
|
31
37
|
* leaks a permanent `+1` on a crash; N `SET NX PX` keys mean a lost release costs exactly one slot for
|
|
32
38
|
* exactly the TTL and never the whole semaphore. `wait:key:<dedupId>` is the in-repo precedent.
|
|
@@ -36,9 +42,23 @@
|
|
|
36
42
|
* `concurrent: 2` scope frees the other holder's slot.
|
|
37
43
|
*/
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sixteen hex of a sha256: the `localJobId` idiom, shared by the scope budget keys (`scopeKeyPrefix`) and the
|
|
49
|
+
* two hashed slot keys below, so one spelling of "the hash of this name" exists. A hash rather than the name
|
|
50
|
+
* itself because a scope legally contains `:` and `/`, which would collide with the key grammar.
|
|
51
|
+
*/
|
|
52
|
+
export const hash16 = (value) => createHash("sha256").update(String(value)).digest("hex").slice(0, 16);
|
|
53
|
+
|
|
54
|
+
/** Slot keys. All live under a prefix an operator can see whole with one `KEYS`. */
|
|
40
55
|
export const checkSlotKey = (i) => `wait:check:${i}`;
|
|
41
56
|
export const scopeSlotKey = (hash, i) => `slot:s:${hash}:${i}`;
|
|
57
|
+
/**
|
|
58
|
+
* A model endpoint's slots (issue #503): `slot:m:<hash16(id)>:<i>`. Hashed although an endpoint id is already
|
|
59
|
+
* `[a-z0-9-]{1,32}`, so the three slot keys share one shape and the sweeper one argument list.
|
|
60
|
+
*/
|
|
61
|
+
export const endpointSlotKey = (hash, i) => `slot:m:${hash}:${i}`;
|
|
42
62
|
|
|
43
63
|
/**
|
|
44
64
|
* Build a lease over `slots` numbered keys.
|
|
@@ -50,6 +70,16 @@ export const scopeSlotKey = (hash, i) => `slot:s:${hash}:${i}`;
|
|
|
50
70
|
* deployment or stop every scoped job; the in-process bound is still there underneath, so failing open
|
|
51
71
|
* degrades the fleet bound to the per-host one, which is exactly the behaviour before this existed.
|
|
52
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* Compare-and-delete, in ONE server-side step (PR #518's gate). A GET followed by a DEL is not release-if-mine: between
|
|
75
|
+
* the two, the claim can expire and another host take it, and the DEL then frees THAT host's slot (measured). The
|
|
76
|
+
* script deletes only while the value is still ours.
|
|
77
|
+
*/
|
|
78
|
+
export const RELEASE_IF_MINE = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
|
79
|
+
|
|
80
|
+
/** The sweep's twin: delete only while the value starts with this host's `<workerName>#` prefix. */
|
|
81
|
+
export const DELETE_IF_PREFIX = 'local v = redis.call("get", KEYS[1]) if v and string.sub(v, 1, #ARGV[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
|
82
|
+
|
|
53
83
|
export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () => Date.now(), log = () => {}, timeoutMs = 2_000 }) {
|
|
54
84
|
// Bounded for `host-registry.mjs`'s reason, which applies to every module sharing this client:
|
|
55
85
|
// `maxRetriesPerRequest: null` makes a command against an unreachable server QUEUE rather than reject,
|
|
@@ -76,24 +106,27 @@ export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () =>
|
|
|
76
106
|
if (!Number.isFinite(slots) || slots < 1) return { ok: true, release: async () => {}, refresh: async () => true };
|
|
77
107
|
const holder = `${holderPrefix}#${id}`;
|
|
78
108
|
const start = Math.abs(hashCode(String(id))) % slots;
|
|
109
|
+
const releaseIfMine = async (key) => {
|
|
110
|
+
try {
|
|
111
|
+
await bounded(redis.eval(RELEASE_IF_MINE, 1, key, holder));
|
|
112
|
+
} catch {
|
|
113
|
+
// The TTL is the backstop. A lost release costs one slot for one TTL.
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const attempted = [];
|
|
79
117
|
for (let n = 0; n < slots; n++) {
|
|
80
118
|
const key = keyFor(...keyArgs, (start + n) % slots);
|
|
119
|
+
attempted.push(key);
|
|
81
120
|
try {
|
|
82
121
|
const won = await bounded(redis.set(key, holder, "PX", perCall ?? ttlMs, "NX"));
|
|
83
122
|
if (!won) continue;
|
|
84
123
|
return {
|
|
85
124
|
ok: true,
|
|
86
125
|
key,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// finds a value that is no longer ours.
|
|
92
|
-
if ((await bounded(redis.get(key))) === holder) await bounded(redis.del(key));
|
|
93
|
-
} catch {
|
|
94
|
-
// The TTL is the backstop. A lost release costs one slot for one TTL.
|
|
95
|
-
}
|
|
96
|
-
},
|
|
126
|
+
// Release-if-MINE, which is what makes this idempotent where the in-process map is not: a double
|
|
127
|
+
// release cannot free another holder's slot, because the second call finds a value that is no
|
|
128
|
+
// longer ours. Atomic, so a release after the TTL cannot free a claim another host took since.
|
|
129
|
+
release: () => releaseIfMine(key),
|
|
97
130
|
async refresh(nextMs = ttlMs) {
|
|
98
131
|
try {
|
|
99
132
|
if ((await bounded(redis.get(key))) !== holder) return false;
|
|
@@ -108,7 +141,13 @@ export function makeFleetLease({ redis, holderPrefix, keyFor, ttlMs, now = () =>
|
|
|
108
141
|
// Granting is the safe direction: the in-process bound is still underneath, so this
|
|
109
142
|
// degrades the fleet-wide ceiling to the per-host one rather than to nothing.
|
|
110
143
|
log("fleet_lease_unavailable", { reason: err?.message });
|
|
111
|
-
|
|
144
|
+
// A TIMED-OUT SET IS NOT A FAILED ONE (PR #518's gate). The shared client queues commands rather
|
|
145
|
+
// than rejecting them, so the SET this call gave up on can still land later and hold the slot for
|
|
146
|
+
// a whole TTL in another host's way, with nobody left to release it (measured with a slow Valkey).
|
|
147
|
+
// So the degraded handle still releases, best-effort and compare-and-delete, every key it tried:
|
|
148
|
+
// only a key holding our own value is deleted, so a key that was never ours is left alone. A
|
|
149
|
+
// release that runs before the late SET lands misses it, and the TTL is the backstop then.
|
|
150
|
+
return { ok: true, degraded: true, release: async () => void (await Promise.all(attempted.map(releaseIfMine))), refresh: async () => true };
|
|
112
151
|
}
|
|
113
152
|
}
|
|
114
153
|
return null;
|
|
@@ -139,31 +178,52 @@ function hashCode(s) {
|
|
|
139
178
|
* No `KEYS`, no `SCAN`, and no index set to leak.
|
|
140
179
|
*/
|
|
141
180
|
export function makeScopeClaimSweeper({ redis, workerName, limits, log = () => {}, timeoutMs = 2_000 }) {
|
|
181
|
+
return makeClaimSweeper({ redis, workerName, keyFor: scopeSlotKey, rows: (limits ?? []).map((row) => ({ hash: row?.hash, count: Number(row?.concurrent) })), event: "scope_claims", log, timeoutMs });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The sweep itself, over any hashed slot key (issue #503 added the endpoint slots beside the scope slots). Each row is
|
|
186
|
+
* `{ hash, count }`: the hash the key is built from and how many indexes can carry a claim. `event` names the two log
|
|
187
|
+
* lines, `<event>_sweep_skipped` and `<event>_swept`, so each lease's sweep says which it is.
|
|
188
|
+
*
|
|
189
|
+
* An endpoint's claim is for a container exactly as a scope's is (a job holds its slot for its whole run), so the
|
|
190
|
+
* same precondition decides it: only after a reaper that enumerated.
|
|
191
|
+
*/
|
|
192
|
+
export function makeClaimSweeper({ redis, workerName, keyFor, rows, event, log = () => {}, timeoutMs = 2_000 }) {
|
|
142
193
|
return async function sweep({ reaped }) {
|
|
143
194
|
if (!reaped) {
|
|
144
|
-
log(
|
|
195
|
+
log(`${event}_sweep_skipped`, { reason: "reaper-skipped" });
|
|
145
196
|
return { swept: 0, skipped: true };
|
|
146
197
|
}
|
|
147
198
|
const prefix = `${workerName}#`;
|
|
148
199
|
let swept = 0;
|
|
149
|
-
for (const row of
|
|
150
|
-
const n = Number(row?.
|
|
200
|
+
for (const row of rows ?? []) {
|
|
201
|
+
const n = Number(row?.count);
|
|
151
202
|
if (!Number.isFinite(n) || n < 1 || !row?.hash) continue;
|
|
152
203
|
for (let i = 0; i < n; i++) {
|
|
153
|
-
const key =
|
|
204
|
+
const key = keyFor(row.hash, i);
|
|
154
205
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
// One atomic compare-and-delete per key, the release's own rule: a GET then DEL could delete a claim
|
|
207
|
+
// another host took in between.
|
|
208
|
+
if (Number(await withTimeout(redis.eval(DELETE_IF_PREFIX, 1, key, prefix), timeoutMs)) > 0) swept++;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// A REPLY error is about THIS key (a WRONGTYPE on a key someone else wrote under the prefix): the server
|
|
211
|
+
// answered, so the next key is worth asking. Logged by key, never by value, and the sweep goes on
|
|
212
|
+
// (PR #518's second gate).
|
|
213
|
+
if (err?.name === "ReplyError") {
|
|
214
|
+
log(`${event}_sweep_key_error`, { key, reason: String(err.message).slice(0, 200) });
|
|
215
|
+
continue;
|
|
159
216
|
}
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
217
|
+
// Best-effort by contract: this is an OPTIMISATION over the TTL, never the mechanism, so it must never
|
|
218
|
+
// block boot. It STOPS at the first fault (PR #518's gate): a Valkey that does not answer would
|
|
219
|
+
// otherwise cost one timeout per key, 64 per endpoint, before the worker starts. A timeout or a connection
|
|
220
|
+
// fault is about the server, so no other key would fare better.
|
|
221
|
+
log(`${event}_sweep_skipped`, { reason: "valkey-fault", detail: err?.message, swept });
|
|
222
|
+
return { swept, skipped: true };
|
|
163
223
|
}
|
|
164
224
|
}
|
|
165
225
|
}
|
|
166
|
-
if (swept > 0) log(
|
|
226
|
+
if (swept > 0) log(`${event}_swept`, { count: swept });
|
|
167
227
|
return { swept, skipped: false };
|
|
168
228
|
};
|
|
169
229
|
}
|