@ricsam/r5d-worker 0.0.109 → 0.0.111
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/README.md +12 -0
- package/dist/cjs/job-resources.cjs +335 -0
- package/dist/cjs/main.cjs +158 -37
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/job-resources.mjs +293 -0
- package/dist/mjs/main.mjs +162 -37
- package/dist/mjs/package.json +1 -1
- package/dist/types/job-resources.d.ts +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MIB = 1024 * 1024;
|
|
5
|
+
const WORKER_INLINE_OUTPUT_MAX_BYTES = 8 * MIB;
|
|
6
|
+
async function collectWorkerInlineOutput(stream, maxBytes = WORKER_INLINE_OUTPUT_MAX_BYTES, signal) {
|
|
7
|
+
if (!stream) return "";
|
|
8
|
+
signal?.throwIfAborted();
|
|
9
|
+
const reader = stream.getReader();
|
|
10
|
+
const abort = () => {
|
|
11
|
+
void reader.cancel().catch(() => {
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
const parts = [];
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
const next = await reader.read();
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
if (next.done) break;
|
|
23
|
+
bytes += next.value.byteLength;
|
|
24
|
+
if (bytes > maxBytes) {
|
|
25
|
+
void reader.cancel().catch(() => {
|
|
26
|
+
});
|
|
27
|
+
throw new Error(`Inline command output exceeded ${maxBytes} bytes. Use streaming execution for large output.`);
|
|
28
|
+
}
|
|
29
|
+
parts.push(decoder.decode(next.value, { stream: true }));
|
|
30
|
+
}
|
|
31
|
+
parts.push(decoder.decode());
|
|
32
|
+
return parts.join("");
|
|
33
|
+
} finally {
|
|
34
|
+
signal?.removeEventListener("abort", abort);
|
|
35
|
+
reader.releaseLock();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function positiveInteger(value, fallback, name) {
|
|
39
|
+
const result = value === void 0 ? fallback : Number(value);
|
|
40
|
+
if (!Number.isSafeInteger(result) || result <= 0) throw new Error(`${name} must be a positive integer`);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
function workerJobBudget(effectiveMemoryBytes, env = process.env) {
|
|
44
|
+
const generalSlots = positiveInteger(env.R5D_WORKER_JOB_CONCURRENCY, 2, "R5D_WORKER_JOB_CONCURRENCY");
|
|
45
|
+
const utilitySlots = positiveInteger(env.R5D_WORKER_UTILITY_CONCURRENCY, 2, "R5D_WORKER_UTILITY_CONCURRENCY");
|
|
46
|
+
const memoryPercent = positiveInteger(env.R5D_WORKER_JOBS_MEMORY_PERCENT, 70, "R5D_WORKER_JOBS_MEMORY_PERCENT");
|
|
47
|
+
if (memoryPercent > 70) throw new Error("Worker jobs may reserve at most 70% of effective memory");
|
|
48
|
+
const aggregateBytes = positiveInteger(
|
|
49
|
+
env.R5D_WORKER_JOBS_MEMORY_BYTES,
|
|
50
|
+
Math.floor(effectiveMemoryBytes * memoryPercent / 100),
|
|
51
|
+
"R5D_WORKER_JOBS_MEMORY_BYTES"
|
|
52
|
+
);
|
|
53
|
+
const utilityBytes = positiveInteger(
|
|
54
|
+
env.R5D_WORKER_UTILITY_MEMORY_BYTES,
|
|
55
|
+
Math.min(256 * MIB, Math.floor(aggregateBytes / 10)),
|
|
56
|
+
"R5D_WORKER_UTILITY_MEMORY_BYTES"
|
|
57
|
+
);
|
|
58
|
+
const generalBytes = positiveInteger(
|
|
59
|
+
env.R5D_WORKER_JOB_MEMORY_BYTES,
|
|
60
|
+
Math.floor((aggregateBytes - utilityBytes * utilitySlots) / generalSlots),
|
|
61
|
+
"R5D_WORKER_JOB_MEMORY_BYTES"
|
|
62
|
+
);
|
|
63
|
+
if (aggregateBytes > Math.floor(effectiveMemoryBytes * 0.7)) throw new Error("Worker jobs may reserve at most 70% of effective memory");
|
|
64
|
+
if (generalBytes * generalSlots + utilityBytes * utilitySlots > aggregateBytes)
|
|
65
|
+
throw new Error("Worker job limits exceed the aggregate memory budget");
|
|
66
|
+
const queueTimeoutMs = positiveInteger(env.R5D_WORKER_JOB_QUEUE_TIMEOUT_MS, 6e4, "R5D_WORKER_JOB_QUEUE_TIMEOUT_MS");
|
|
67
|
+
if (queueTimeoutMs > 6e4) throw new Error("Worker job queue timeout cannot exceed 60000ms");
|
|
68
|
+
return {
|
|
69
|
+
aggregateBytes,
|
|
70
|
+
generalBytes,
|
|
71
|
+
utilityBytes,
|
|
72
|
+
generalSlots,
|
|
73
|
+
utilitySlots,
|
|
74
|
+
queueTimeoutMs,
|
|
75
|
+
maxQueued: positiveInteger(env.R5D_WORKER_JOB_QUEUE_LIMIT, 128, "R5D_WORKER_JOB_QUEUE_LIMIT"),
|
|
76
|
+
pidsPerJob: positiveInteger(env.R5D_WORKER_JOB_PIDS, 512, "R5D_WORKER_JOB_PIDS")
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function workerDelegatedCgroupRoot(cgroupText, mount = "/sys/fs/cgroup") {
|
|
80
|
+
const relative = cgroupText.split("\n").find((line) => line.startsWith("0::/"))?.slice(3);
|
|
81
|
+
if (!relative) return void 0;
|
|
82
|
+
const segments = relative.split("/").filter(Boolean);
|
|
83
|
+
const index = segments.indexOf("r5d-worker.service");
|
|
84
|
+
if (index < 0 || segments[index + 1] !== "control") return void 0;
|
|
85
|
+
return path.join(mount, ...segments.slice(0, index + 1));
|
|
86
|
+
}
|
|
87
|
+
function effectiveWorkerMemory(root, hostBytes = os.totalmem(), mount = "/sys/fs/cgroup") {
|
|
88
|
+
let result = hostBytes;
|
|
89
|
+
if (!root) return result;
|
|
90
|
+
let current = root;
|
|
91
|
+
while (current === mount || current.startsWith(`${mount}/`)) {
|
|
92
|
+
try {
|
|
93
|
+
const limit = Number(fs.readFileSync(path.join(current, "memory.max"), "utf8").trim());
|
|
94
|
+
if (Number.isSafeInteger(limit) && limit > 0) result = Math.min(result, limit);
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
if (current === mount) break;
|
|
98
|
+
current = path.dirname(current);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
class WorkerJobCapacityError extends Error {
|
|
103
|
+
constructor() {
|
|
104
|
+
super("Worker command capacity is busy. No command was started; retry later or stop another command or terminal.");
|
|
105
|
+
this.name = "WorkerJobCapacityError";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
class WorkerJobCancelledError extends Error {
|
|
109
|
+
constructor() {
|
|
110
|
+
super("Command cancelled before it started");
|
|
111
|
+
this.name = "WorkerJobCancelledError";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
class WorkerJobResourceController {
|
|
115
|
+
constructor(budget, delegatedRoot) {
|
|
116
|
+
this.budget = budget;
|
|
117
|
+
this.isolation = delegatedRoot ? "cgroup-v2" : "concurrency-only";
|
|
118
|
+
if (delegatedRoot) {
|
|
119
|
+
if (fs.readFileSync(path.join(delegatedRoot, "cgroup.procs"), "utf8").trim())
|
|
120
|
+
throw new Error("Worker cgroup delegation root must be empty; configure DelegateSubgroup=control");
|
|
121
|
+
fs.writeFileSync(path.join(delegatedRoot, "cgroup.subtree_control"), "+memory +pids");
|
|
122
|
+
this.jobsRoot = path.join(delegatedRoot, "jobs");
|
|
123
|
+
fs.mkdirSync(this.jobsRoot, { recursive: true });
|
|
124
|
+
fs.writeFileSync(path.join(this.jobsRoot, "memory.max"), String(budget.aggregateBytes));
|
|
125
|
+
fs.writeFileSync(path.join(this.jobsRoot, "memory.swap.max"), "0");
|
|
126
|
+
fs.writeFileSync(path.join(this.jobsRoot, "memory.oom.group"), "0");
|
|
127
|
+
fs.writeFileSync(path.join(this.jobsRoot, "cgroup.subtree_control"), "+memory +pids");
|
|
128
|
+
for (const name of fs.readdirSync(this.jobsRoot)) {
|
|
129
|
+
if (!/^job-[0-9a-f-]+$/.test(name)) continue;
|
|
130
|
+
const old = path.join(this.jobsRoot, name);
|
|
131
|
+
if (!fs.statSync(old).isDirectory()) continue;
|
|
132
|
+
fs.writeFileSync(path.join(old, "cgroup.kill"), "1");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
budget;
|
|
137
|
+
isolation;
|
|
138
|
+
jobsRoot;
|
|
139
|
+
active = /* @__PURE__ */ new Map();
|
|
140
|
+
waiters = [];
|
|
141
|
+
timer;
|
|
142
|
+
get status() {
|
|
143
|
+
return { isolation: this.isolation, active: this.active.size, queued: this.waiters.length };
|
|
144
|
+
}
|
|
145
|
+
acquire(input) {
|
|
146
|
+
try {
|
|
147
|
+
input.assertAdmission();
|
|
148
|
+
if (this.active.has(input.id) || this.waiters.some((entry) => entry.id === input.id))
|
|
149
|
+
throw new Error(`Duplicate resource admission: ${input.id}`);
|
|
150
|
+
if (this.available(input.lane)) return Promise.resolve(this.start(input));
|
|
151
|
+
if (this.waiters.length >= this.budget.maxQueued) throw new WorkerJobCapacityError();
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
this.waiters.push({ ...input, resolve, reject, deadline: Date.now() + this.budget.queueTimeoutMs });
|
|
154
|
+
this.timer ??= setInterval(() => this.drain(), Math.min(100, this.budget.queueTimeoutMs));
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return Promise.reject(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
cancel(id) {
|
|
161
|
+
for (let index = this.waiters.length - 1; index >= 0; index--) {
|
|
162
|
+
if (this.waiters[index].id !== id) continue;
|
|
163
|
+
this.waiters.splice(index, 1)[0].reject(new WorkerJobCancelledError());
|
|
164
|
+
}
|
|
165
|
+
this.active.get(id)?.lease.kill();
|
|
166
|
+
this.drain();
|
|
167
|
+
}
|
|
168
|
+
cancelSession(sessionId) {
|
|
169
|
+
this.cancelMatching((_id, ownerSessionId) => ownerSessionId === sessionId);
|
|
170
|
+
}
|
|
171
|
+
cancelMatching(matches) {
|
|
172
|
+
const ids = /* @__PURE__ */ new Set([
|
|
173
|
+
...this.waiters.filter((entry) => matches(entry.id, entry.sessionId)).map((entry) => entry.id),
|
|
174
|
+
...[...this.active].filter(([id, entry]) => matches(id, entry.sessionId)).map(([id]) => id)
|
|
175
|
+
]);
|
|
176
|
+
const errors = [];
|
|
177
|
+
for (const id of ids) {
|
|
178
|
+
try {
|
|
179
|
+
this.cancel(id);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
errors.push(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (errors.length) throw new AggregateError(errors, "Worker job cancellation was incomplete");
|
|
185
|
+
}
|
|
186
|
+
available(lane) {
|
|
187
|
+
return [...this.active.values()].filter((entry) => entry.lane === lane).length < (lane === "general" ? this.budget.generalSlots : this.budget.utilitySlots);
|
|
188
|
+
}
|
|
189
|
+
drain() {
|
|
190
|
+
for (let index = 0; index < this.waiters.length; ) {
|
|
191
|
+
const waiter = this.waiters[index];
|
|
192
|
+
try {
|
|
193
|
+
waiter.assertAdmission();
|
|
194
|
+
if (Date.now() >= waiter.deadline) throw new WorkerJobCapacityError();
|
|
195
|
+
if (!this.available(waiter.lane)) {
|
|
196
|
+
index++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
this.waiters.splice(index, 1);
|
|
200
|
+
waiter.resolve(this.start(waiter));
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (this.waiters[index] === waiter) this.waiters.splice(index, 1);
|
|
203
|
+
waiter.reject(error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (this.waiters.length === 0 && this.timer) {
|
|
207
|
+
clearInterval(this.timer);
|
|
208
|
+
this.timer = void 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
start(input) {
|
|
212
|
+
input.assertAdmission();
|
|
213
|
+
const memoryBytes = input.lane === "general" ? this.budget.generalBytes : this.budget.utilityBytes;
|
|
214
|
+
const cgroupPath = this.jobsRoot ? path.join(this.jobsRoot, `job-${crypto.randomUUID()}`) : void 0;
|
|
215
|
+
if (cgroupPath) {
|
|
216
|
+
fs.mkdirSync(cgroupPath);
|
|
217
|
+
try {
|
|
218
|
+
fs.writeFileSync(path.join(cgroupPath, "memory.max"), String(memoryBytes));
|
|
219
|
+
fs.writeFileSync(path.join(cgroupPath, "memory.swap.max"), "0");
|
|
220
|
+
fs.writeFileSync(path.join(cgroupPath, "memory.oom.group"), "1");
|
|
221
|
+
fs.writeFileSync(path.join(cgroupPath, "pids.max"), String(this.budget.pidsPerJob));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
fs.rmdirSync(cgroupPath);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let released = false;
|
|
228
|
+
let revoked = false;
|
|
229
|
+
let releasePromise;
|
|
230
|
+
const kill = () => {
|
|
231
|
+
revoked = true;
|
|
232
|
+
if (!released && cgroupPath) fs.writeFileSync(path.join(cgroupPath, "cgroup.kill"), "1");
|
|
233
|
+
};
|
|
234
|
+
const lease = {
|
|
235
|
+
cgroupPath,
|
|
236
|
+
memoryBytes,
|
|
237
|
+
wrap: (argv) => {
|
|
238
|
+
if (released) throw new Error("Worker job resource lease was already released");
|
|
239
|
+
if (revoked) throw new WorkerJobCancelledError();
|
|
240
|
+
if (!argv.length) throw new Error("Command argv must not be empty");
|
|
241
|
+
return cgroupPath ? [
|
|
242
|
+
"/bin/sh",
|
|
243
|
+
"-c",
|
|
244
|
+
'set -e; printf "%s" "$$" > "$1/cgroup.procs"; read -r oom < /proc/self/oom_score_adj; if [ "$oom" -lt 0 ]; then printf 0 > /proc/self/oom_score_adj; fi; shift; exec "$@"',
|
|
245
|
+
"r5d-job",
|
|
246
|
+
cgroupPath,
|
|
247
|
+
...argv
|
|
248
|
+
] : argv;
|
|
249
|
+
},
|
|
250
|
+
kill,
|
|
251
|
+
memoryLimitExceeded: () => {
|
|
252
|
+
if (!cgroupPath || released) return false;
|
|
253
|
+
return /^oom_kill [1-9][0-9]*$/m.test(fs.readFileSync(path.join(cgroupPath, "memory.events"), "utf8"));
|
|
254
|
+
},
|
|
255
|
+
release: () => releasePromise ??= (async () => {
|
|
256
|
+
kill();
|
|
257
|
+
if (cgroupPath) {
|
|
258
|
+
const deadline = Date.now() + 2e3;
|
|
259
|
+
while (/^populated 1$/m.test(fs.readFileSync(path.join(cgroupPath, "cgroup.events"), "utf8"))) {
|
|
260
|
+
if (Date.now() >= deadline) throw new Error(`Worker job cgroup remained populated: ${cgroupPath}`);
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
262
|
+
}
|
|
263
|
+
fs.rmdirSync(cgroupPath);
|
|
264
|
+
}
|
|
265
|
+
released = true;
|
|
266
|
+
this.active.delete(input.id);
|
|
267
|
+
this.drain();
|
|
268
|
+
})()
|
|
269
|
+
};
|
|
270
|
+
this.active.set(input.id, { lane: input.lane, sessionId: input.sessionId, lease });
|
|
271
|
+
return lease;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function createWorkerJobResources(env = process.env) {
|
|
275
|
+
const mode = env.R5D_WORKER_RESOURCE_ISOLATION ?? "auto";
|
|
276
|
+
if (mode !== "auto" && mode !== "required") throw new Error("R5D_WORKER_RESOURCE_ISOLATION must be auto or required");
|
|
277
|
+
const root = process.platform === "linux" ? workerDelegatedCgroupRoot(fs.readFileSync("/proc/self/cgroup", "utf8")) : void 0;
|
|
278
|
+
if (mode === "required" && !root)
|
|
279
|
+
throw new Error("Worker requires delegated cgroup v2 resource isolation; configure Delegate=memory pids and DelegateSubgroup=control");
|
|
280
|
+
const budget = workerJobBudget(effectiveWorkerMemory(root), env);
|
|
281
|
+
return new WorkerJobResourceController(budget, root);
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
WORKER_INLINE_OUTPUT_MAX_BYTES,
|
|
285
|
+
WorkerJobCancelledError,
|
|
286
|
+
WorkerJobCapacityError,
|
|
287
|
+
WorkerJobResourceController,
|
|
288
|
+
collectWorkerInlineOutput,
|
|
289
|
+
createWorkerJobResources,
|
|
290
|
+
effectiveWorkerMemory,
|
|
291
|
+
workerDelegatedCgroupRoot,
|
|
292
|
+
workerJobBudget
|
|
293
|
+
};
|