forgedock 1.32.0 → 1.33.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/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/lean/schedd.mjs
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
|
|
25
25
|
* running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
|
|
26
26
|
* daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
|
|
27
|
+
* Exit reasons (#4503): every exit logs a line: `idle, exiting`, `exiting (<signal>|uncaught: …)`, `lost lock|socket …,
|
|
28
|
+
* exiting` (checked every `lockCheckMs`: the lock no longer holds this pid, or the socket is gone; nothing it does not own
|
|
29
|
+
* is removed), then `exited (code N)`. Taking over a dead owner's lock is logged, so a SIGKILLed daemon leaves a trace.
|
|
27
30
|
* `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
|
|
28
31
|
*/
|
|
29
32
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
@@ -35,6 +38,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
|
35
38
|
|
|
36
39
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
37
40
|
export const STOP_MS = 10 * 1000;
|
|
41
|
+
export const LOCK_CHECK_MS = 5 * 1000;
|
|
38
42
|
|
|
39
43
|
export function scheddPaths(dir = schedDir()) {
|
|
40
44
|
return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
|
|
@@ -54,17 +58,19 @@ export function mergeEnv(base, kept) {
|
|
|
54
58
|
return { ...out, ...kept };
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
/** Take the daemon lock; null when another live daemon holds it. */
|
|
61
|
+
/** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
|
|
58
62
|
function takeLock(lock, pid, alive) {
|
|
63
|
+
let took = null;
|
|
59
64
|
for (let i = 0; i < 5; i++) {
|
|
60
65
|
try {
|
|
61
66
|
const fd = openSync(lock, "wx");
|
|
62
67
|
try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
|
|
63
|
-
return
|
|
68
|
+
return { took };
|
|
64
69
|
} catch (e) { if (e?.code !== "EEXIST") throw e; }
|
|
65
70
|
let owner = NaN;
|
|
66
71
|
try { owner = parseInt(readFileSync(lock, "utf8"), 10); } catch { continue; }
|
|
67
72
|
if (Number.isInteger(owner) && owner !== pid && alive(owner)) return null;
|
|
73
|
+
if (Number.isInteger(owner) && owner !== pid) took = owner;
|
|
68
74
|
rmSync(lock, { force: true });
|
|
69
75
|
}
|
|
70
76
|
return null;
|
|
@@ -74,10 +80,13 @@ function takeLock(lock, pid, alive) {
|
|
|
74
80
|
* Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
|
|
75
81
|
*/
|
|
76
82
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
77
|
-
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS
|
|
83
|
+
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
84
|
+
lockCheckMs = LOCK_CHECK_MS } = {}) {
|
|
78
85
|
const p = scheddPaths(dir);
|
|
79
86
|
mkdirSync(dir, { recursive: true });
|
|
80
|
-
|
|
87
|
+
const lock = takeLock(p.lock, pid, alive);
|
|
88
|
+
if (lock?.took) log(`schedd: took over the lock of dead pid ${lock.took} (it exited without a clean shutdown)`);
|
|
89
|
+
if (!lock) {
|
|
81
90
|
let owner = NaN;
|
|
82
91
|
try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
|
|
83
92
|
log(`schedd: already running (pid ${owner})`);
|
|
@@ -88,7 +97,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
88
97
|
const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
|
|
89
98
|
const conns = new Set();
|
|
90
99
|
const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
|
|
91
|
-
|
|
100
|
+
const starting = new Map(); // batch id -> host() promise of a start-up resume not yet hosted (#4503: an attach waits for it)
|
|
101
|
+
let closing = null, resolveClosed, keepSock = false;
|
|
92
102
|
let lastActive = Date.now();
|
|
93
103
|
const touch = () => { lastActive = Date.now(); };
|
|
94
104
|
|
|
@@ -143,6 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
143
153
|
return send({ type: "batch", id: ent.id, dir: ent.dir });
|
|
144
154
|
}
|
|
145
155
|
if (req.op === "attach") {
|
|
156
|
+
if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
|
|
146
157
|
const live = hosted.get(req.id);
|
|
147
158
|
if (live) {
|
|
148
159
|
// Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
|
|
@@ -191,6 +202,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
191
202
|
const close = () => {
|
|
192
203
|
if (closing) return closing;
|
|
193
204
|
clearInterval(idleTimer);
|
|
205
|
+
clearInterval(lockTimer);
|
|
194
206
|
for (const c of conns) c.destroy();
|
|
195
207
|
const stopping = [...runs.keys()];
|
|
196
208
|
for (const ac of runs.values()) ac.abort();
|
|
@@ -198,9 +210,11 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
198
210
|
// #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
|
|
199
211
|
const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
|
|
200
212
|
.then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
|
|
201
|
-
|
|
213
|
+
// A lost lock: the socket path may be the successor's, and closing the server unlinks it (libuv), so leave it unref'd.
|
|
214
|
+
const serverClosed = keepSock ? Promise.resolve(server.unref()) : new Promise((r) => server.close(() => r()));
|
|
215
|
+
closing = Promise.all([serverClosed, stopped]).then(() => {
|
|
202
216
|
try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
|
|
203
|
-
rmSync(p.sock, { force: true });
|
|
217
|
+
if (!keepSock) rmSync(p.sock, { force: true });
|
|
204
218
|
resolveClosed();
|
|
205
219
|
});
|
|
206
220
|
return closing;
|
|
@@ -209,6 +223,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
209
223
|
if (!hosted.size && !conns.size && Date.now() - lastActive >= idleMs) { log("schedd: idle, exiting"); close(); }
|
|
210
224
|
}, Math.min(idleMs, 30_000));
|
|
211
225
|
idleTimer.unref?.();
|
|
226
|
+
// #4503: a daemon whose lock or socket was taken stops (its batches stay resumable) instead of running unreachable.
|
|
227
|
+
const lockTimer = setInterval(() => {
|
|
228
|
+
if (closing) return;
|
|
229
|
+
let owner = NaN;
|
|
230
|
+
try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
|
|
231
|
+
if (owner !== pid) { keepSock = true; log(`schedd: lost lock (owner ${Number.isInteger(owner) ? `pid ${owner}` : "none"}), exiting`); close(); }
|
|
232
|
+
else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
|
|
233
|
+
}, lockCheckMs);
|
|
234
|
+
lockTimer.unref?.();
|
|
212
235
|
|
|
213
236
|
// Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
|
|
214
237
|
if (resumeOnStart) {
|
|
@@ -216,22 +239,35 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
216
239
|
const b = openBatch(root, id);
|
|
217
240
|
if (!b.readPlan()?.daemon || b.readReport()) continue;
|
|
218
241
|
log(`schedd: resuming batch ${id}`);
|
|
219
|
-
host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true })
|
|
242
|
+
const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
|
|
243
|
+
starting.set(id, started);
|
|
244
|
+
started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
|
|
220
245
|
}
|
|
221
246
|
}
|
|
222
247
|
return { running: true, pid, sock: p.sock, close, closed };
|
|
223
248
|
}
|
|
224
249
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
250
|
+
/**
|
|
251
|
+
* #4454: on SIGTERM/SIGINT/SIGHUP stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo,
|
|
252
|
+
* once; an uncaught exception or unhandled rejection does the same and exits 1. #4503: each logs its reason first, and the
|
|
253
|
+
* process `exit` logs `schedd: exited (code N)`.
|
|
254
|
+
*/
|
|
255
|
+
export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s === "exit" ? process.once(s, f) : process.on(s, f)),
|
|
256
|
+
exit = (c) => process.exit(c), log = (m) => console.log(m), signals = ["SIGTERM", "SIGINT", "SIGHUP"] } = {}) {
|
|
257
|
+
const SIGNO = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
|
|
228
258
|
let fired = false;
|
|
229
|
-
|
|
259
|
+
const stop = async (reason, code, detail = null) => {
|
|
230
260
|
if (fired) return;
|
|
231
261
|
fired = true;
|
|
262
|
+
log(`schedd: exiting (${reason})`);
|
|
263
|
+
if (detail) log(detail);
|
|
232
264
|
try { await d.close(); } catch { /* exit anyway */ }
|
|
233
|
-
exit(
|
|
234
|
-
}
|
|
265
|
+
exit(code);
|
|
266
|
+
};
|
|
267
|
+
for (const sig of signals) on(sig, () => stop(sig, 128 + (SIGNO[sig] ?? 0)));
|
|
268
|
+
on("uncaughtException", (e) => stop(`uncaught: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
|
|
269
|
+
on("unhandledRejection", (e) => stop(`unhandled rejection: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
|
|
270
|
+
on("exit", (code) => log(`schedd: exited (code ${code})`));
|
|
235
271
|
}
|
|
236
272
|
|
|
237
273
|
/** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
10
|
import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
|
|
10
|
-
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
|
+
import { schedDir } from "./sched.mjs";
|
|
11
13
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
12
14
|
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
13
15
|
|
|
16
|
+
// #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
|
|
17
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
|
|
18
|
+
// Stub lanes run detached: a host whose test never reached its `finally` still has them reaped when the file ends.
|
|
19
|
+
const hosts = [];
|
|
20
|
+
process.on("exit", () => { for (const h of hosts) h.reap(); });
|
|
21
|
+
const LANE_MAX_MS = 120_000;
|
|
22
|
+
|
|
14
23
|
const until = async (cond, ms = 15000) => {
|
|
15
24
|
const end = Date.now() + ms;
|
|
16
25
|
while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
|
|
@@ -24,13 +33,20 @@ const capture = async (fn) => {
|
|
|
24
33
|
/** A host: sched + batch dirs, a stub lane that waits for `release`, and a daemon script with injected lanes. */
|
|
25
34
|
function host() {
|
|
26
35
|
const work = tmpDir("schedd-");
|
|
27
|
-
const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: []
|
|
36
|
+
const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [],
|
|
37
|
+
lanePidFile: join(work, "lane-pids"), log: join(work, "schedd.log") };
|
|
28
38
|
writeFileSync(h.launches, "");
|
|
39
|
+
writeFileSync(h.lanePidFile, "");
|
|
40
|
+
hosts.push(h);
|
|
29
41
|
const stub = join(work, "stub-lane.mjs");
|
|
30
42
|
writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
|
|
31
43
|
const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
|
|
32
44
|
appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
|
|
33
|
-
|
|
45
|
+
appendFileSync(${JSON.stringify(h.lanePidFile)}, process.pid + "\\n");
|
|
46
|
+
// #4503: a stub whose test run is gone (work dir removed) or that outlived its bound exits instead of polling forever.
|
|
47
|
+
const born = Date.now();
|
|
48
|
+
const t = setInterval(() => { if (!existsSync(${JSON.stringify(work)}) || Date.now() - born > ${LANE_MAX_MS}) process.exit(0);
|
|
49
|
+
if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
|
|
34
50
|
writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
|
|
35
51
|
// Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
|
|
36
52
|
process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
|
|
@@ -49,7 +65,9 @@ await d.closed;
|
|
|
49
65
|
h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
|
|
50
66
|
h.daemon = async () => {
|
|
51
67
|
const before = existsSync(scheddPaths(h.sched).lock) ? readFileSync(scheddPaths(h.sched).lock, "utf8") : null;
|
|
52
|
-
const
|
|
68
|
+
const out = openSync(h.log, "a");
|
|
69
|
+
let child;
|
|
70
|
+
try { child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: ["ignore", out, out] }); } finally { closeSync(out); }
|
|
53
71
|
h.procs.push(child);
|
|
54
72
|
await until(() => existsSync(scheddPaths(h.sched).sock) && existsSync(scheddPaths(h.sched).lock) && readFileSync(scheddPaths(h.sched).lock, "utf8") === String(child.pid) && readFileSync(scheddPaths(h.sched).lock, "utf8") !== before);
|
|
55
73
|
// Wait for the socket to accept.
|
|
@@ -59,9 +77,15 @@ await d.closed;
|
|
|
59
77
|
h.client = { scheddDir: h.sched, spawnDaemon: null };
|
|
60
78
|
h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
|
|
61
79
|
h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
|
|
62
|
-
h.
|
|
63
|
-
|
|
80
|
+
h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
|
|
81
|
+
h.reap = () => {
|
|
64
82
|
for (const p of h.procs) { try { p.kill("SIGKILL"); } catch { /* gone */ } }
|
|
83
|
+
for (const pid of h.stubPids()) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
|
|
84
|
+
};
|
|
85
|
+
h.cleanup = async () => {
|
|
86
|
+
if (existsSync(h.work)) writeFileSync(h.release, "");
|
|
87
|
+
h.reap();
|
|
88
|
+
await until(() => h.stubPids().every((pid) => !isLaneAlive(pid, null)), 5000).catch(() => {});
|
|
65
89
|
};
|
|
66
90
|
return h;
|
|
67
91
|
}
|
|
@@ -98,7 +122,7 @@ describe("schedd host scheduler (#4192)", () => {
|
|
|
98
122
|
const id = done[0].batch;
|
|
99
123
|
await withRoot(h, () => capture(() => orchestrateFromCli(["--set-parallel", id, "5"], h.client)));
|
|
100
124
|
assert.equal(openBatch(h.root, id).readParallel(), 5);
|
|
101
|
-
} finally { h.cleanup(); }
|
|
125
|
+
} finally { await h.cleanup(); }
|
|
102
126
|
});
|
|
103
127
|
|
|
104
128
|
it("a client that disconnects does not stop the batch; --resume reattaches, and replays the report once done", async () => {
|
|
@@ -127,7 +151,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
127
151
|
assert.deepEqual(again.res.rows, res.rows);
|
|
128
152
|
assert.ok(again.lines.join("\n").includes(formatTable(res.rows)));
|
|
129
153
|
assert.deepEqual(h.launched(), [1, 2]);
|
|
130
|
-
} finally { h.cleanup(); }
|
|
154
|
+
} finally { await h.cleanup(); }
|
|
131
155
|
});
|
|
132
156
|
|
|
133
157
|
it("a daemon restart resumes the batch from state and re-adopts its live lanes without duplicate launches", async () => {
|
|
@@ -150,7 +174,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
150
174
|
assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
|
|
151
175
|
assert.deepEqual(h.launched(), [1, 2, 3], "adopted, not relaunched");
|
|
152
176
|
assert.deepEqual(openBatch(h.root, id).store.load().running, {});
|
|
153
|
-
} finally { h.cleanup(); }
|
|
177
|
+
} finally { await h.cleanup(); }
|
|
154
178
|
});
|
|
155
179
|
|
|
156
180
|
it("#4408: --add and --resume <id> N add issues to the live batch (no second batch); an unhosted finished batch runs its adds on --resume", async () => {
|
|
@@ -181,7 +205,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
181
205
|
}));
|
|
182
206
|
assert.equal(res.rows.length, 3);
|
|
183
207
|
assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
|
|
184
|
-
} finally { h.cleanup(); }
|
|
208
|
+
} finally { await h.cleanup(); }
|
|
185
209
|
});
|
|
186
210
|
|
|
187
211
|
it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
|
|
@@ -211,7 +235,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
211
235
|
assert.equal(again.cancelled, false, "a finished batch is left alone");
|
|
212
236
|
await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
|
|
213
237
|
}));
|
|
214
|
-
} finally { h.cleanup(); }
|
|
238
|
+
} finally { await h.cleanup(); }
|
|
215
239
|
});
|
|
216
240
|
|
|
217
241
|
it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
|
|
@@ -232,7 +256,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
232
256
|
assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
|
|
233
257
|
assert.deepEqual(h.launched(), [1]);
|
|
234
258
|
}));
|
|
235
|
-
} finally { h.cleanup(); }
|
|
259
|
+
} finally { await h.cleanup(); }
|
|
236
260
|
});
|
|
237
261
|
|
|
238
262
|
it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
|
|
@@ -252,7 +276,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
252
276
|
assert.equal(st.pid, process.pid);
|
|
253
277
|
} finally { await third.close(); }
|
|
254
278
|
assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
|
|
255
|
-
} finally { h.cleanup(); }
|
|
279
|
+
} finally { await h.cleanup(); }
|
|
256
280
|
});
|
|
257
281
|
|
|
258
282
|
it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
|
|
@@ -309,11 +333,12 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
309
333
|
const h = host();
|
|
310
334
|
try {
|
|
311
335
|
const d1 = await h.daemon();
|
|
312
|
-
await withRoot(h, () => capture(async () => {
|
|
313
|
-
|
|
336
|
+
const { lines } = await withRoot(h, () => capture(async () => {
|
|
337
|
+
// #4503: a gives up at once (reconnectMs 0) with the resume hint; b re-attaches to the next daemon on its own.
|
|
338
|
+
const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 }).then(() => null, (e) => e);
|
|
314
339
|
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
315
340
|
const [idA] = listBatches(h.root);
|
|
316
|
-
const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client)
|
|
341
|
+
const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
317
342
|
await until(() => h.launched().includes(5) && h.lanePids().length === 2);
|
|
318
343
|
const idB = listBatches(h.root).find((x) => x !== idA);
|
|
319
344
|
const lanes = h.lanePids();
|
|
@@ -327,15 +352,102 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
327
352
|
assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
|
|
328
353
|
assert.equal(openBatch(h.root, idB).readReport(), null);
|
|
329
354
|
assert.match(String((await a)?.message), /--resume/);
|
|
330
|
-
await b;
|
|
331
355
|
await h.daemon(); // resumes both batches
|
|
332
|
-
const ra = orchestrateFromCli(["--resume", idA], h.client)
|
|
356
|
+
const ra = orchestrateFromCli(["--resume", idA], h.client);
|
|
333
357
|
setTimeout(() => writeFileSync(h.release, ""), 200);
|
|
334
358
|
assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
|
|
335
|
-
assert.deepEqual((await
|
|
359
|
+
assert.deepEqual((await b).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]], "b re-attached and finished");
|
|
336
360
|
assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
|
|
337
361
|
}));
|
|
338
|
-
|
|
362
|
+
assert.ok(lines.some((l) => l.startsWith("schedd connection lost; reconnecting to batch ")), "the client said it reconnects");
|
|
363
|
+
const log = readFileSync(h.log, "utf8");
|
|
364
|
+
assert.match(log, /^schedd: exiting \(SIGTERM\)$/m);
|
|
365
|
+
assert.match(log, /^schedd: exited \(code 143\)$/m);
|
|
366
|
+
} finally { await h.cleanup(); }
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("#4503: every schedd exit writes a reason line", async () => {
|
|
370
|
+
for (const [ev, arg, code, re] of [["SIGTERM", undefined, 143, /^schedd: exiting \(SIGTERM\)$/], ["SIGINT", undefined, 130, /^schedd: exiting \(SIGINT\)$/],
|
|
371
|
+
["SIGHUP", undefined, 129, /^schedd: exiting \(SIGHUP\)$/], ["uncaughtException", new Error("boom\nmore"), 1, /^schedd: exiting \(uncaught: boom\)$/],
|
|
372
|
+
["unhandledRejection", new Error("nope"), 1, /^schedd: exiting \(unhandled rejection: nope\)$/]]) {
|
|
373
|
+
const handlers = {}, lines = [], exits = [];
|
|
374
|
+
exitOnSignals({ close: async () => { lines.push("closed"); } }, { on: (s, f) => { handlers[s] = f; }, exit: (c) => exits.push(c), log: (m) => lines.push(m) });
|
|
375
|
+
await handlers[ev](arg);
|
|
376
|
+
assert.match(lines[0], re, `${ev}: the reason is logged before close()`);
|
|
377
|
+
assert.equal(lines.at(-1), "closed");
|
|
378
|
+
assert.deepEqual(exits, [code]);
|
|
379
|
+
handlers.exit(code);
|
|
380
|
+
assert.equal(lines.at(-1), `schedd: exited (code ${code})`);
|
|
381
|
+
}
|
|
382
|
+
const dir = tmpDir("schedd-idle-"), lines = [];
|
|
383
|
+
const d = await startDaemon({ dir, root: join(dir, "batches"), runBatch: async () => ({}), log: (m) => lines.push(m), resumeOnStart: false, idleMs: 50 });
|
|
384
|
+
await d.closed;
|
|
385
|
+
assert.ok(lines.includes("schedd: idle, exiting"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("#4503: a daemon that lost its lock or socket logs it and stops its batches resumable, never removing the successor's lock or socket; a dead owner's takeover is logged", async () => {
|
|
389
|
+
const dir = tmpDir("schedd-lost-"), p = scheddPaths(dir), root = join(dir, "batches"), lines = [];
|
|
390
|
+
let stopped = false;
|
|
391
|
+
const d = await startDaemon({ dir, root, log: (m) => lines.push(m), resumeOnStart: false, lockCheckMs: 30,
|
|
392
|
+
runBatch: ({ sink, signal }) => { sink.batch({ id: "x", dir }); return new Promise((_, rej) => signal.addEventListener("abort", () => { stopped = true; rej(Object.assign(new Error("stopped"), { code: "batch.stopped" })); })); } });
|
|
393
|
+
request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
|
|
394
|
+
await until(() => lines.some((l) => l.includes("listening")));
|
|
395
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
396
|
+
writeFileSync(p.lock, "999999999"); // a successor (now dead) took the lock
|
|
397
|
+
await d.closed;
|
|
398
|
+
assert.ok(lines.includes("schedd: lost lock (owner pid 999999999), exiting"), lines.join("\n"));
|
|
399
|
+
assert.equal(stopped, true, "the hosted batch loop was stopped (no report: the next daemon resumes it)");
|
|
400
|
+
assert.equal(readFileSync(p.lock, "utf8"), "999999999", "the successor's lock is left alone");
|
|
401
|
+
assert.equal(existsSync(p.sock), true, "the successor's socket is left alone");
|
|
402
|
+
|
|
403
|
+
const lines2 = [];
|
|
404
|
+
const d2 = await startDaemon({ dir, root, runBatch: async () => ({}), log: (m) => lines2.push(m), resumeOnStart: false, lockCheckMs: 30 });
|
|
405
|
+
assert.ok(lines2.includes("schedd: took over the lock of dead pid 999999999 (it exited without a clean shutdown)"), lines2.join("\n"));
|
|
406
|
+
rmSync(p.sock);
|
|
407
|
+
await d2.closed;
|
|
408
|
+
assert.ok(lines2.includes(`schedd: lost socket ${p.sock}, exiting`), lines2.join("\n"));
|
|
409
|
+
assert.equal(existsSync(p.lock), false, "its own lock is released");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("#4503: a client whose daemon is SIGKILLed re-attaches to the next daemon and finishes, without relaunching a lane", async () => {
|
|
413
|
+
const h = host();
|
|
414
|
+
try {
|
|
415
|
+
const d1 = await h.daemon();
|
|
416
|
+
await withRoot(h, () => capture(async () => {
|
|
417
|
+
const a = orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
418
|
+
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
419
|
+
const gone = new Promise((r) => d1.on("exit", r));
|
|
420
|
+
d1.kill("SIGKILL");
|
|
421
|
+
await gone;
|
|
422
|
+
await new Promise((r) => setTimeout(r, 300)); // the client is retrying against the dead socket
|
|
423
|
+
await h.daemon();
|
|
424
|
+
setTimeout(() => writeFileSync(h.release, ""), 200);
|
|
425
|
+
assert.deepEqual((await a).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
|
|
426
|
+
assert.deepEqual(h.launched(), [1], "adopted, not relaunched");
|
|
427
|
+
}));
|
|
428
|
+
assert.match(readFileSync(h.log, "utf8"), /^schedd: took over the lock of dead pid \d+ \(it exited without a clean shutdown\)$/m);
|
|
429
|
+
} finally { await h.cleanup(); }
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("#4503: test runs never resolve ~/.forge/sched, and teardown leaves no live stub lane", async () => {
|
|
433
|
+
assert.notEqual(schedDir(), join(homedir(), ".forge", "sched"));
|
|
434
|
+
assert.ok(schedDir().startsWith(tmpdir()), schedDir());
|
|
435
|
+
const h = host();
|
|
436
|
+
let pids = [], a;
|
|
437
|
+
try {
|
|
438
|
+
await h.daemon();
|
|
439
|
+
await withRoot(h, async () => {
|
|
440
|
+
a = capture(() => orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
|
|
441
|
+
await until(() => h.stubPids().length === 2 && h.lanePids().length === 2);
|
|
442
|
+
});
|
|
443
|
+
pids = h.stubPids();
|
|
444
|
+
for (const pid of pids) assert.equal(isLaneAlive(pid, null), true);
|
|
445
|
+
const [id] = listBatches(h.root);
|
|
446
|
+
assert.equal(openBatch(h.root, id).readPlan().env.FORGEDOCK_SCHED_DIR, schedDir(), "the lanes' env pins the test sched dir");
|
|
447
|
+
} finally { await h.cleanup(); }
|
|
448
|
+
await a;
|
|
449
|
+
assert.equal(pids.length, 2);
|
|
450
|
+
for (const pid of pids) assert.equal(isLaneAlive(pid, null), false, `stub lane ${pid} reaped`);
|
|
339
451
|
});
|
|
340
452
|
|
|
341
453
|
it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
|
package/bin/lean/seqlease.mjs
CHANGED
|
@@ -6,23 +6,70 @@
|
|
|
6
6
|
* Each key is a one-slot `heavy.mjs` semaphore in its own registry dir (`schedDir()/seq-lease/<key>/heavy.json`):
|
|
7
7
|
* file-locked, FIFO, dead holders dropped by pid. The heavy-step registry and its env caps are never touched.
|
|
8
8
|
* Fail-open: a wait over `maxWaitS` (or an unreadable registry) proceeds without the lease; CI and base-sync stay the gate.
|
|
9
|
+
* #4526: number reservations. `reserve` picks the numbers of a renumber under a short host lock per (repo, base, dir) in
|
|
10
|
+
* its own registry (`schedDir()/seq-pick/<key>/`, never the lease above: its holder renumbers while it holds it) and
|
|
11
|
+
* records them in `reserved.json` until `unreserve` (run end), the holder's death or 24h, so concurrent lanes renumbering
|
|
12
|
+
* against the same base get distinct numbers without holding a lock across regenerate and push. Fail-open.
|
|
9
13
|
*/
|
|
10
14
|
import { createHash } from "node:crypto";
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
16
|
import { join } from "node:path";
|
|
12
17
|
import { acquireHeavy, openHeavy } from "./heavy.mjs";
|
|
13
|
-
import { schedDir } from "./sched.mjs";
|
|
18
|
+
import { pidAlive, schedDir } from "./sched.mjs";
|
|
14
19
|
|
|
15
20
|
/** The lease key of one sequence dir of one lane branch of one repo. */
|
|
16
21
|
export const sequenceLeaseKey = (repo, lane, dir) => createHash("sha1").update(`${repo}\0${lane}\0${dir}`).digest("hex").slice(0, 16);
|
|
17
22
|
|
|
18
23
|
const NO_MEM = Object.freeze({ cores: () => 2, memAvailableMb: () => null });
|
|
24
|
+
const RESERVE_TTL_MS = 24 * 3600 * 1000;
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* `acquire({repo, lane, dirs, issue, maxWaitS})` takes every dir's lease in sorted order (no lock-order deadlock).
|
|
22
28
|
* @returns {{acquire: (e:object) => Promise<{waitMs:number, timedOut:boolean, release:Function}>}}
|
|
23
29
|
*/
|
|
24
|
-
export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), open = openHeavy, acquire = acquireHeavy, pollMs } = {}) {
|
|
30
|
+
export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), pickDir = join(schedDir(), "seq-pick"), open = openHeavy, acquire = acquireHeavy, pollMs, alive = pidAlive, now = () => Date.now() } = {}) {
|
|
31
|
+
const reservedDirs = new Map(); // `${repo}\0${lane}\0${issue}` -> dirs reserved in, for unreserve
|
|
32
|
+
/** Run `fn(entries) => entries'` on one key's reservations under its pick lock; returns what `fn` set as `out`. */
|
|
33
|
+
const locked = async ({ repo, lane, dir: d }, fn) => {
|
|
34
|
+
const kd = join(pickDir, sequenceLeaseKey(repo, lane, d)), file = join(kd, "reserved.json");
|
|
35
|
+
const h = open(kd, { cfg: { max: 1, max_wait_s: 60 }, env: {}, host: NO_MEM });
|
|
36
|
+
const g = await acquire(h, { phase: "sequence-pick" }, ...(pollMs ? [{ pollMs }] : []));
|
|
37
|
+
try {
|
|
38
|
+
let entries = [];
|
|
39
|
+
try { const o = JSON.parse(readFileSync(file, "utf8")); if (Array.isArray(o?.entries)) entries = o.entries; } catch { /* none yet */ }
|
|
40
|
+
const t = now();
|
|
41
|
+
entries = entries.filter((e) => Array.isArray(e?.numbers) && alive(e.pid) && t - Number(e.at ?? 0) < RESERVE_TTL_MS);
|
|
42
|
+
const r = fn(entries);
|
|
43
|
+
mkdirSync(kd, { recursive: true });
|
|
44
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify({ entries: r.entries }, null, 2)}\n`);
|
|
45
|
+
renameSync(`${file}.tmp`, file);
|
|
46
|
+
return r.out;
|
|
47
|
+
} finally { try { g.release(); } catch { /* fail-open */ } }
|
|
48
|
+
};
|
|
25
49
|
return {
|
|
50
|
+
/**
|
|
51
|
+
* #4526: `count` consecutive numbers above max(floor, every other issue's live reservation) in (repo, lane, dir),
|
|
52
|
+
* recorded for `issue` (its earlier entries there replaced). @returns {Promise<{numbers:number[]}|null>} null on error.
|
|
53
|
+
*/
|
|
54
|
+
async reserve({ repo, lane, dir: d, issue = null, floor = 0, count = 1, pid = process.pid } = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const k = `${repo}\0${lane}\0${issue}`;
|
|
57
|
+
reservedDirs.set(k, new Set([...(reservedDirs.get(k) ?? []), d]));
|
|
58
|
+
return await locked({ repo, lane, dir: d }, (entries) => {
|
|
59
|
+
const others = entries.filter((e) => String(e.issue) !== String(issue));
|
|
60
|
+
let n = Math.max(Number(floor) || 0, ...others.flatMap((e) => e.numbers.map(Number)).filter(Number.isFinite));
|
|
61
|
+
const numbers = Array.from({ length: Math.max(0, count) }, () => ++n);
|
|
62
|
+
return { entries: [...others, { issue, pid, numbers, at: now() }], out: { numbers } };
|
|
63
|
+
});
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
},
|
|
66
|
+
/** #4526: drop `issue`'s reservations in every dir of (repo, lane) it reserved in this process. Never throws. */
|
|
67
|
+
async unreserve({ repo, lane, issue = null } = {}) {
|
|
68
|
+
for (const d of [...(reservedDirs.get(`${repo}\0${lane}\0${issue}`) ?? [])]) {
|
|
69
|
+
try { await locked({ repo, lane, dir: d }, (entries) => ({ entries: entries.filter((e) => String(e.issue) !== String(issue)) })); } catch { /* fail-open */ }
|
|
70
|
+
}
|
|
71
|
+
reservedDirs.delete(`${repo}\0${lane}\0${issue}`);
|
|
72
|
+
},
|
|
26
73
|
async acquire({ repo, lane, dirs = [], issue = null, maxWaitS = 1800 } = {}) {
|
|
27
74
|
const held = [];
|
|
28
75
|
let waitMs = 0, timedOut = false;
|
|
@@ -39,6 +39,20 @@ describe("sequence lease (#4386)", () => {
|
|
|
39
39
|
assert.equal(b.timedOut, true);
|
|
40
40
|
b.release(); a.release();
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it("#4526: reserve gives each issue distinct numbers above the floor and other issues' live reservations; dead or unreserved entries free them", async () => {
|
|
44
|
+
const dead = new Set();
|
|
45
|
+
const io = makeSequenceLeaseIo({ dir: tmpDir("seqlease-"), pickDir: tmpDir("seqpick-"), pollMs: 20, alive: (pid) => !dead.has(pid) });
|
|
46
|
+
const k = { repo: "o/r", lane: "staging", dir: "db" };
|
|
47
|
+
const [a, b] = await Promise.all([io.reserve({ ...k, issue: 1, floor: 51, count: 1, pid: 101 }), io.reserve({ ...k, issue: 2, floor: 51, count: 2, pid: 102 })]);
|
|
48
|
+
assert.deepEqual([...a.numbers, ...b.numbers].sort((x, y) => x - y), [52, 53, 54]);
|
|
49
|
+
assert.deepEqual((await io.reserve({ ...k, issue: 1, floor: 51, count: 1, pid: 101 })).numbers, [Math.max(...b.numbers) + 1], "own entry replaced, others kept");
|
|
50
|
+
assert.deepEqual((await io.reserve({ ...k, dir: "docs/adr", issue: 3, floor: 51, pid: 103 })).numbers, [52], "other dir: own registry");
|
|
51
|
+
assert.deepEqual((await io.reserve({ ...k, lane: "main", issue: 3, floor: 51, pid: 103 })).numbers, [52], "other base: own registry");
|
|
52
|
+
dead.add(102);
|
|
53
|
+
await io.unreserve({ repo: "o/r", lane: "staging", issue: 1 });
|
|
54
|
+
assert.deepEqual((await io.reserve({ ...k, issue: 4, floor: 51, pid: 104 })).numbers, [52], "dead holder and unreserved issue dropped");
|
|
55
|
+
});
|
|
42
56
|
});
|
|
43
57
|
|
|
44
58
|
const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
package/bin/lean/sequence.mjs
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* #4343: a leading calendar date (YYYY-MM-DD, YYYYMMDD) is not a number, and a directory only counts when it holds a
|
|
12
12
|
* series on the base: a migration journal (`<dir>/meta/_journal.json`, `<dir>/_journal.json`), or >=2 distinct numbers
|
|
13
13
|
* of the lane file's width that fill at least half of their min..max range (sparse issue-numbered docs are no series).
|
|
14
|
+
* #4526: renumberLane takes its numbers from a host reservation (`reserve`, seqlease.mjs) so concurrent lanes get distinct
|
|
15
|
+
* ones; verifyRenumber accepts a first-line header that only swaps the file's own tag (`headerOnlyChange`); a hand-written
|
|
16
|
+
* migration the generator writes nothing for gets the generator's `--custom` snapshot and keeps the lane's SQL.
|
|
14
17
|
*/
|
|
15
18
|
import { execFile } from "node:child_process";
|
|
16
19
|
import { randomUUID } from "node:crypto";
|
|
@@ -123,6 +126,21 @@ export function numberOnlyChange(was, now, from, to) {
|
|
|
123
126
|
return new RegExp(`^${parts.map((p, i) => (i % 2 ? alt(p) : esc(p))).join("")}$`).test(String(now));
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* #4526: everything after the first line is byte-identical, and the first line of `now` is that of `was` with the old
|
|
131
|
+
* stem (`fromPath` without extension) mapped to the new one, then the old number (padded or bare, not next to another
|
|
132
|
+
* digit) to the new number: a migration's `-- Migration 0067_x` or an ADR's `# 0029. Title` header following a renumber.
|
|
133
|
+
*/
|
|
134
|
+
export function headerOnlyChange(was, now, fromPath, toPath) {
|
|
135
|
+
const from = numOf(fromPath), to = numOf(toPath);
|
|
136
|
+
if (!from || !to) return false;
|
|
137
|
+
const split = (t) => { const s = String(t), i = s.indexOf("\n"); return i < 0 ? [s, ""] : [s.slice(0, i), s.slice(i)]; };
|
|
138
|
+
const [h1, r1] = split(was), [h2, r2] = split(now);
|
|
139
|
+
if (r1 !== r2) return false;
|
|
140
|
+
const mapped = h1.replaceAll(stemOf(fromPath), stemOf(toPath)).replace(new RegExp(`(?<!\\d)0*${Number(from)}(?!\\d)`, "g"), (tok) => (tok.length >= to.length ? to : String(Number(to))));
|
|
141
|
+
return mapped === h2;
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
|
|
127
145
|
export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
|
|
128
146
|
|
|
@@ -173,7 +191,7 @@ export async function verifyRenumber({ git, base, before = [], moves = null }) {
|
|
|
173
191
|
for (const f of peers) {
|
|
174
192
|
if (kept || f.path === b.path) continue;
|
|
175
193
|
const text = await g(["cat-file", "-p", f.blob]);
|
|
176
|
-
kept = numberOnlyChange(was, text, numOf(b.path), numOf(f.path)) || (rest(f.path) === rest(b.path) && text === mapped);
|
|
194
|
+
kept = numberOnlyChange(was, text, numOf(b.path), numOf(f.path)) || (rest(f.path) === rest(b.path) && text === mapped) || headerOnlyChange(was, text, b.path, f.path);
|
|
177
195
|
}
|
|
178
196
|
if (!kept) problems.push(`${b.path}: content changed or file gone after renumbering (keep it byte-identical, rename only)`);
|
|
179
197
|
}
|
|
@@ -269,7 +287,8 @@ const writeJson = (p, o) => writeFileSync(p, `${JSON.stringify(o, null, 2)}\n`);
|
|
|
269
287
|
* where it started. Never throws.
|
|
270
288
|
* @returns {Promise<{ok:true, moves:Array<{from,to}>, commit:string|null}|{unsupported:string}>}
|
|
271
289
|
*/
|
|
272
|
-
export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerate = null, run = null, uuid = randomUUID }) {
|
|
290
|
+
export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerate = null, regenerateCustom = null, run = null, uuid = randomUUID, reserve = null }) {
|
|
291
|
+
const custom = regenerateCustom ?? (regenerate ? `${regenerate} --custom` : null);
|
|
273
292
|
const g = out(git);
|
|
274
293
|
const start = (await g(["rev-parse", "HEAD"])).trim();
|
|
275
294
|
const written = [];
|
|
@@ -286,6 +305,16 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
|
|
|
286
305
|
if (!collisions.length) return { ok: true, moves: [], commit: null };
|
|
287
306
|
const odd = collisions.find((c) => !SUPPORTED_RE.test(c.lane));
|
|
288
307
|
if (odd) return { unsupported: `${odd.lane}: no deterministic renumber for .${basename(odd.lane).split(".").pop()} migrations` };
|
|
308
|
+
// #4526: numbers above every other live lane's host reservation, not only above the base (fail-open: base-derived).
|
|
309
|
+
if (reserve) {
|
|
310
|
+
for (const dir of [...new Set(collisions.map((c) => c.dir))].sort()) {
|
|
311
|
+
const cs = collisions.filter((c) => c.dir === dir), width = cs[0].next.length;
|
|
312
|
+
let got = null;
|
|
313
|
+
try { got = await reserve({ dir, floor: Number(cs[0].next) - 1, count: cs.length }); } catch { /* fail-open */ }
|
|
314
|
+
const nums = Array.isArray(got?.numbers) ? got.numbers.map(Number) : [];
|
|
315
|
+
if (nums.length === cs.length && nums.every(Number.isInteger)) cs.forEach((c, i) => { c.next = String(nums[i]).padStart(width, "0"); });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
289
318
|
const moves = collisions.map((c) => ({ from: c.lane, to: posix.join(c.dir, c.next + basename(c.lane).slice(c.number.length)), dir: c.dir, oldNum: c.number, newNum: c.next }));
|
|
290
319
|
// Plan the journals and snapshots before touching anything, so an unsupported migration leaves the branch as it was.
|
|
291
320
|
const baseList = lines(await g(["-c", "core.quotepath=off", "ls-tree", "-r", "--name-only", base]));
|
|
@@ -296,12 +325,14 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
|
|
|
296
325
|
if (!jp) continue;
|
|
297
326
|
const meta = dirname(jp), mine = moves.filter((m) => m.dir === dir && /\.sql$/i.test(m.from));
|
|
298
327
|
const snaps = [];
|
|
328
|
+
let chained = false; // #4526: after a schema-changing move, a data-only one's predecessor is not the base tip
|
|
299
329
|
for (const m of mine) {
|
|
300
330
|
const laneSnap = await json(g, `${lane}:${posix.join(meta, `${m.oldNum}_snapshot.json`)}`);
|
|
301
331
|
if (!laneSnap) continue;
|
|
302
332
|
const laneMeta = lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", lane, `${meta}/`])).filter((p) => SNAPSHOT_RE.test(p) && Number(numOf(p)) < Number(m.oldNum));
|
|
303
333
|
const pred = laneMeta.sort((a, b) => Number(numOf(a)) - Number(numOf(b))).at(-1);
|
|
304
|
-
const dataOnly = Boolean(pred) && strip(await json(g, `${lane}:${pred}`)) === strip(laneSnap);
|
|
334
|
+
const dataOnly = !chained && Boolean(pred) && strip(await json(g, `${lane}:${pred}`)) === strip(laneSnap);
|
|
335
|
+
if (!dataOnly) chained = true;
|
|
305
336
|
if (!dataOnly && !regenerate) return { unsupported: `schema-changing migration ${m.from} needs lean.sequence.regenerate (its snapshot cannot be cloned)` };
|
|
306
337
|
if (!dataOnly) needRegen = true;
|
|
307
338
|
snaps.push({ m, dataOnly });
|
|
@@ -339,7 +370,8 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
|
|
|
339
370
|
const tags = new Set(baseEntries.map((e) => e?.tag));
|
|
340
371
|
let idx = Math.max(-1, ...baseEntries.map((e) => Number(e?.idx)).filter(Number.isFinite));
|
|
341
372
|
const add = (Array.isArray(lj.entries) ? lj.entries : []).filter((e) => !tags.has(e?.tag)).map((e) => ({ ...e, idx: ++idx, tag: stem.get(e.tag) ?? e.tag }));
|
|
342
|
-
|
|
373
|
+
p.journal = { ...bj, entries: [...baseEntries, ...add] };
|
|
374
|
+
writeJson(join(cwd, p.jp), p.journal);
|
|
343
375
|
touched.add(p.jp);
|
|
344
376
|
const baseSnaps = baseList.filter((x) => dirname(x) === p.meta && SNAPSHOT_RE.test(x)).sort((a, b) => Number(numOf(a)) - Number(numOf(b)));
|
|
345
377
|
let tip = baseSnaps.length ? await json(g, `${base}:${baseSnaps.at(-1)}`) : null;
|
|
@@ -360,11 +392,27 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
|
|
|
360
392
|
}
|
|
361
393
|
if (needRegen) {
|
|
362
394
|
try { await (run ?? defaultRun)(regenerate, { cwd }); } catch (e) { return await fail(`lean.sequence.regenerate failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0]}`); }
|
|
395
|
+
const exists = (p) => { try { readFileSync(join(cwd, p)); return true; } catch { return false; } };
|
|
363
396
|
for (const p of plans) {
|
|
364
397
|
touched.add(p.meta);
|
|
365
|
-
|
|
398
|
+
// #4526: a hand-written migration (no schema diff) gets nothing from the regular run: the generator's --custom
|
|
399
|
+
// writes an empty migration and its snapshot at that number (its SQL and later ones withheld), then the lane's
|
|
400
|
+
// SQL and the planned journal come back.
|
|
401
|
+
for (const { m, dataOnly } of [...p.snaps].sort((a, b) => Number(a.m.newNum) - Number(b.m.newNum))) {
|
|
366
402
|
const want = posix.join(p.meta, `${m.newNum}_snapshot.json`);
|
|
367
|
-
if (
|
|
403
|
+
if (dataOnly || exists(want)) continue;
|
|
404
|
+
if (!custom) return await fail(`lean.sequence.regenerate did not write ${want}`);
|
|
405
|
+
const held = moves.filter((w) => w.dir === p.dir && /\.sql$/i.test(w.to) && Number(w.newNum) >= Number(m.newNum));
|
|
406
|
+
const heldTags = new Set(held.map((w) => stemOf(w.to)));
|
|
407
|
+
for (const w of held) rmSync(join(cwd, w.to), { force: true });
|
|
408
|
+
writeJson(join(cwd, p.jp), { ...p.journal, entries: p.journal.entries.filter((e) => !heldTags.has(e?.tag)) });
|
|
409
|
+
try { await (run ?? defaultRun)(custom, { cwd }); } catch (e) { return await fail(`lean.sequence.regenerate --custom failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0]}`); }
|
|
410
|
+
for (const f of lines(await g(["-c", "core.quotepath=off", "ls-files", "--others", "--exclude-standard", "--", `${p.dir}/`]))) {
|
|
411
|
+
if (dirname(f) === p.dir && /\.sql$/i.test(f) && numOf(f) === m.newNum && f !== m.to) rmSync(join(cwd, f), { force: true });
|
|
412
|
+
}
|
|
413
|
+
for (const w of held) writeFileSync(join(cwd, w.to), await g(["cat-file", "blob", `${lane}:${w.from}`]));
|
|
414
|
+
writeJson(join(cwd, p.jp), p.journal);
|
|
415
|
+
if (!exists(want)) return await fail(`lean.sequence.regenerate did not write ${want}`);
|
|
368
416
|
}
|
|
369
417
|
}
|
|
370
418
|
}
|