Public: anyone can read this; the crew can change it.

A forge killed in the middle of a merge comes back consistent: no landed pull… #12149

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/12143-a-forge-killed-in-the-middle-of-a-merge → main 9 commits fork 4 threads · 4 unresolved

  • Merged. torque/12143-a-forge-killed-in-the-middle-of-a-merge was merged into main as 97ab7b8.

Review

Sign in to review this change.
15 files changed +564 −10
docs/features/merge-crash-recovery.md added +6
@@ -0,0 +1,6 @@
1 # A forge killed in the middle of a merge
2
3 - A merge writes its intent (`pull_landing`: the commit, who lands it, on what) before it moves the base ref. At boot (`core/reconcile.ts`) and every 30 s (`main.ts`, lease tick; intents under a minute old are left to their own merge) `settleLandings` closes as merged any open pull whose landing commit is already in its base, with a `pull.merged` event; an intent whose ref never moved is dropped.
4 - Lock files a kill left under `refs/` (and `HEAD.lock`, `packed-refs.lock`) older than 5 s are removed at boot and when a merge meets one (`git/locks.ts`). A lock still held answers `ref_locked`, not `base_moved`.
5 - A run records its holder (`host:pid:start`) at each heartbeat. At boot a running run whose holder was a process of this host that is gone has its lease lapsed, so the existing sweeps settle it at once instead of after 90 s.
6 - Not done: the slow tick does not walk every repository for locks; saying why an app did not answer within 90 s of a restart.
src/actions/revert_words.ts +2
@@ -31,6 +31,8 @@ export function takeBackWords(error: RevertPullError): string {
3131 return "git refused it";
3232 case "base_moved":
3333 return "the base moved under it";
34 case "ref_locked":
35 return "the branch is locked by another git operation";
3436 case "git":
3537 return `git said ${error.message}`;
3638 case "octopus":
src/core/landing.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The intent to merge, and its settling (#12143). A merge moves the base ref
3 * in git and then closes the pull in the database; a kill between the two
4 * left main ahead of its own record. `recordLanding` writes what is about to
5 * land before the ref moves; `clearLanding` drops it once the pull is closed
6 * or the move failed; `settleLandings` closes, as merged, every open pull
7 * whose recorded landing commit is already in its base — at boot and on a
8 * slow tick — and forgets intents whose ref never moved.
9 */
10
11 import type { Database } from "bun:sqlite";
12 import type { App } from "../app.ts";
13 import { isAncestor } from "../git/builds.ts";
14 import { repoDir } from "../git/repos.ts";
15 import { resolveOid, writeRef } from "../git/merge.ts";
16 import { closeAsMerged } from "./pulls.ts";
17
18 export interface Landing {
19 readonly mergeOid: string;
20 readonly actorId: string;
21 readonly baseOid: string;
22 readonly headOid: string;
23 }
24
25 export function recordLanding(db: Database, pullId: string, landing: Landing): void {
26 db.query(
27 `INSERT OR REPLACE INTO pull_landing (issue_id, merge_oid, actor_id, base_oid, head_oid, created_at)
28 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
29 ).run(pullId, landing.mergeOid, landing.actorId, landing.baseOid, landing.headOid, new Date().toISOString());
30 }
31
32 export function clearLanding(db: Database, pullId: string): void {
33 db.query("DELETE FROM pull_landing WHERE issue_id = ?1").run(pullId);
34 }
35
36 interface Row {
37 issue_id: string;
38 number: number;
39 repo_id: string;
40 owner_name: string;
41 repo_name: string;
42 base_branch: string;
43 merged_at: string | null;
44 merge_oid: string;
45 actor_id: string;
46 base_oid: string;
47 head_oid: string;
48 created_at: string;
49 }
50
51 /**
52 * An intent younger than this may be a merge still in flight in this process: the slow tick
53 * leaves it.
54 */
55 const IN_FLIGHT_MS = 60_000;
56
57 /**
58 * Settle every recorded landing. `all` (boot) treats each as no longer in
59 * flight; otherwise intents younger than a minute are left to their merge.
60 * Returns the numbers of pulls closed as merged.
61 */
62 let settling = false;
63
64 /** One settle at a time: a tick that meets boot's, or its own predecessor, does nothing. */
65 export async function settleLandings(app: App, all: boolean): Promise<number[]> {
66 if (settling) return [];
67 settling = true;
68 try {
69 return await settle(app, all);
70 } finally {
71 settling = false;
72 }
73 }
74
75 async function settle(app: App, all: boolean): Promise<number[]> {
76 const rows = app.db
77 .query<Row, []>(
78 `SELECT pl.*, issue.number, issue.repo_id, user.username AS owner_name, repo.name AS repo_name,
79 pull.base_branch, pull.merged_at
80 FROM pull_landing pl
81 JOIN pull ON pull.issue_id = pl.issue_id
82 JOIN issue ON issue.id = pl.issue_id
83 JOIN repo ON repo.id = issue.repo_id
84 JOIN user ON user.id = repo.owner_id`,
85 )
86 .all();
87 const closed: number[] = [];
88 const now = Date.now();
89 for (const row of rows) {
90 if (!all && now - Date.parse(row.created_at) < IN_FLIGHT_MS) continue;
91 if (row.merged_at !== null) {
92 clearLanding(app.db, row.issue_id);
93 continue;
94 }
95 const dir = repoDir(app.reposRoot, row.owner_name, row.repo_name);
96 const tip = await resolveOid(dir, `refs/heads/${row.base_branch}`);
97 if (tip === undefined) continue; // unreadable now: keep the intent for the next tick
98 if (!(await isAncestor(dir, row.merge_oid, tip))) {
99 // The ref never moved: nothing landed, nothing to settle.
100 clearLanding(app.db, row.issue_id);
101 continue;
102 }
103 // Re-read: only the call that closes the pull announces it.
104 const fresh = app.db.query<{ merged_at: string | null }, [string]>("SELECT merged_at FROM pull WHERE issue_id = ?1").get(row.issue_id);
105 if (fresh?.merged_at != null) {
106 clearLanding(app.db, row.issue_id);
107 continue;
108 }
109 closeAsMerged(app.db, row.issue_id, {
110 mergeOid: row.merge_oid,
111 actorId: row.actor_id,
112 baseOid: row.base_oid,
113 headOid: row.head_oid,
114 });
115 clearLanding(app.db, row.issue_id);
116 await writeRef(dir, `refs/pull/${row.number}/merged`, row.merge_oid);
117 app.bus.append({
118 kind: "pull.merged",
119 repoId: row.repo_id,
120 actorId: row.actor_id,
121 payload: { number: row.number, strategy: "recovered", mergeOid: row.merge_oid },
122 });
123 app.log.warn("settled a pull whose merge was cut off: its commit is on the base", {
124 repo: `${row.owner_name}/${row.repo_name}`,
125 pull: row.number,
126 });
127 closed.push(row.number);
128 }
129 return closed;
130 }
src/core/migration-pins/20261114050000-landing-intent-and-run-holder.pin added +1
@@ -0,0 +1,1 @@
1 5e60c7d4d17e29f4a6d2293882fdc1207ccd48fd96621ccbc0e444f3fc60d827
src/core/migrations/20261114050000-landing-intent-and-run-holder.ts added +17
@@ -0,0 +1,17 @@
1 // A merge moves the base ref in git and then closes the pull in this database; a kill between the
2 // two left main ahead and the pull open for good (#12143). The intent is now written first:
3 // `pull_landing` holds the commit about to land, who lands it and on what, until the pull is
4 // closed; boot and a slow tick close any pull whose landing commit is already on its base.
5 // `run.holder` names the process (host:pid:start) that last renewed a run's lease, so a boot can
6 // settle at once a run whose process is gone. Additive.
7 export default `
8 CREATE TABLE IF NOT EXISTS pull_landing (
9 issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE,
10 merge_oid TEXT NOT NULL,
11 actor_id TEXT NOT NULL,
12 base_oid TEXT NOT NULL,
13 head_oid TEXT NOT NULL,
14 created_at TEXT NOT NULL
15 );
16 ALTER TABLE run ADD COLUMN holder TEXT;
17 `;
src/core/pulls.ts +16 −2
@@ -8,6 +8,7 @@
88
99 import { getRepoById } from "./repos.ts";
1010 import { repoDir } from "../git/repos.ts";
11 import { clearLanding, recordLanding } from "./landing.ts";
1112 import { sealedByNumber } from "./sealed.ts";
1213 import type { Database } from "bun:sqlite";
1314 import { diffRange, type Diff, type DiffLaps } from "../git/diff.ts";
@@ -1247,8 +1248,20 @@ export async function mergePull(
12471248 author: input.author,
12481249 ...(input.committer === undefined ? {} : { committer: input.committer }),
12491250 ...(input.expect === undefined ? {} : { treeOid: input.expect.treeOid }),
1251 // The intent, before the ref moves: a kill between the move and the close
1252 // is settled from it at boot (`settleLandings`, #12143).
1253 beforeMove: (mergeOid) =>
1254 recordLanding(db, pull.id, {
1255 mergeOid,
1256 actorId: input.actorId,
1257 baseOid: status.baseOid,
1258 headOid: status.headOid,
1259 }),
12501260 });
1251 if (!landed.ok) return landed;
1261 if (!landed.ok) {
1262 clearLanding(db, pull.id);
1263 return landed;
1264 }
12521265
12531266 closeAsMerged(db, pull.id, {
12541267 mergeOid: landed.value.mergeOid,
@@ -1256,6 +1269,7 @@ export async function mergePull(
12561269 baseOid: status.baseOid,
12571270 headOid: status.headOid,
12581271 });
1272 clearLanding(db, pull.id);
12591273
12601274 // The merge commit is kept reachable forever, independent of the base branch.
12611275 await writeRef(dir, `refs/pull/${pull.number}/merged`, landed.value.mergeOid);
@@ -1291,7 +1305,7 @@ async function carriedBy(
12911305
12921306 /** Record the landing on the row and close the pull — once: a pull already
12931307 * merged keeps the commit it first landed as. */
1294 function closeAsMerged(
1308 export function closeAsMerged(
12951309 db: Database,
12961310 pullId: string,
12971311 landed: {
src/core/reconcile.ts +11
@@ -20,10 +20,13 @@
2020 */
2121
2222 import type { App } from "../app.ts";
23 import { sweepLocks } from "../git/locks.ts";
2324 import { installHooks } from "../git/hooks.ts";
2425 import { listRefs } from "../git/read.ts";
2526 import { repoDir } from "../git/repos.ts";
2627 import { repoExists } from "../git/repos.ts";
28 import { settleLandings } from "./landing.ts";
29 import { lapseOrphans } from "./runs.ts";
2730 import { getPull, trackPullHead } from "./pulls.ts";
2831
2932 /** Repos reconciled at once. Filesystem work, so wider than the git limit. */
@@ -66,6 +69,9 @@ export async function reconcile(app: App): Promise<void> {
6669 if (await repoExists(dir)) {
6770 await installHooks(dir);
6871 refreshed++;
72 // Locks a kill -9 left (#12143) would refuse every later move of that ref.
73 const swept = await sweepLocks(dir);
74 if (swept.length > 0) app.log.warn("removed stale git lock files", { repo: `${row.owner_name}/${row.name}`, files: swept.length });
6975 } else {
7076 app.log.warn("repo row without a git directory", {
7177 repo: `${row.owner_name}/${row.name}`,
@@ -73,12 +79,17 @@ export async function reconcile(app: App): Promise<void> {
7379 }
7480 });
7581
82 // A merge cut off between the ref and the row, and runs whose process is gone (#12143).
83 const settled = await settleLandings(app, true);
84 const orphaned = lapseOrphans(app.db);
7685 const heads = await reconcileHeads(app);
7786
7887 app.log.info("reconciled", {
7988 repos: rows.length,
8089 hooks_refreshed: refreshed,
8190 heads_repaired: heads,
91 landings_settled: settled.length,
92 orphaned_runs: orphaned,
8293 });
8394 }
8495
src/core/runs.ts +51 −4
@@ -9,6 +9,7 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { hostname } from "node:os";
1213 import { BUDGET_PREFIX } from "../kernel/budgets.ts";
1314 import { id } from "../kernel/id.ts";
1415
@@ -490,13 +491,59 @@ export function lastAgentRun(
490491 return row ? toRun(row) : undefined;
491492 }
492493
493 /** The process working these runs says it still is. */
494 /**
495 * This process, as a run's lease names it: host, pid and start, so a reused pid is not mistaken
496 * for it.
497 */
498 export const HOLDER = `${hostname()}:${process.pid}:${Date.now()}`;
499
500 /** The process working these runs says it still is, and who it is. */
494501 export function heartbeatRuns(db: Database, runIds: readonly string[]): void {
495502 if (runIds.length === 0) return;
496503 db.query(
497 `UPDATE run SET heartbeat_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
498 WHERE state = 'running' AND id IN (${runIds.map((_, n) => `?${n + 1}`).join(",")})`,
499 ).run(...runIds);
504 `UPDATE run SET heartbeat_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), holder = ?1
505 WHERE state = 'running' AND id IN (${runIds.map((_, n) => `?${n + 2}`).join(",")})`,
506 ).run(HOLDER, ...runIds);
507 }
508
509 /**
510 * Lapse the lease of every running run whose holder was a process of this
511 * host that is gone (#12143): the next sweep settles it at once, instead of
512 * after the 90 s a silent lease waits. A holder on another host, or one
513 * that cannot be told apart, is left to its lease. `alive` is a seam for tests.
514 */
515 export function lapseOrphans(
516 db: Database,
517 self: string = HOLDER,
518 alive: (pid: number) => boolean = pidAlive,
519 ): number {
520 const host = self.split(":")[0]!;
521 const rows = db
522 .query<{ id: string; holder: string }, []>(
523 "SELECT id, holder FROM run WHERE state = 'running' AND holder IS NOT NULL",
524 )
525 .all();
526 let lapsed = 0;
527 for (const row of rows) {
528 if (row.holder === self) continue;
529 const [h, pid] = row.holder.split(":");
530 if (h !== host || !/^\d+$/.test(pid ?? "")) continue;

A same-hostname holder in another pid namespace, or another instance, reads as ESRCH and its live run is lapsed. Now that this runs every tick, restrict it to boot or add an instance id to the holder.

531 // Our own pid under another start is a predecessor (a container's pid 1 again): gone.
532 if (Number(pid) !== process.pid && alive(Number(pid))) continue;
533 lapsed += db
534 .query("UPDATE run SET heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE id = ?1 AND state = 'running' AND holder = ?2")
535 .run(row.id, row.holder).changes;
536 }
537 return lapsed;
538 }
539
540 function pidAlive(pid: number): boolean {
541 try {
542 process.kill(pid, 0);
543 return true;
544 } catch (e) {
545 return (e as NodeJS.ErrnoException).code === "EPERM";
546 }
500547 }
501548
502549 /**
src/git/locks.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Lock files a killed git process left behind (#12143). git takes `<ref>.lock`
3 * for the milliseconds a ref moves; a `kill -9` in that window leaves it, and
4 * every later update of that ref answers "File exists" — through a reboot —
5 * until someone deletes it. A lock older than any git operation could last is
6 * nobody's: `sweepLocks` removes those, under `refs/` and the two at the top,
7 * at boot only, when no git of ours is alive; a merge removes only its own
8 * ref's (`sweepRefLock`).
9 */
10
11 import { readdir, rm, stat } from "node:fs/promises";
12 import { join } from "node:path";
13
14 /**
15 * What boot and a landing treat as older than any live ref update (git holds one for
16 * milliseconds).
17 */
18 export const STALE_LOCK_MS = 5_000;
19 /** The message git gives when a ref's lock is held. */
20 export const LOCK_HELD = /unable to create '[^']*\.lock'/i;
21
22 export function isLockHeld(stderr: string): boolean {
23 return LOCK_HELD.test(stderr);
24 }
25
26 async function walk(dir: string, out: string[], depth: number): Promise<void> {
27 if (depth > 12) return;
28 let names: import("node:fs").Dirent[];
29 try {
30 names = await readdir(dir, { withFileTypes: true });
31 } catch {
32 return;
33 }
34 for (const entry of names) {
35 const path = join(dir, entry.name);
36 if (entry.isDirectory()) await walk(path, out, depth + 1);
37 else if (entry.isFile() && entry.name.endsWith(".lock")) out.push(path);
38 }
39 }
40
41 /** Remove the `*.lock` files of a git directory older than `minAgeMs`; the paths removed. */
42 export async function sweepLocks(
43 gitDir: string,
44 minAgeMs: number = STALE_LOCK_MS,
45 now: number = Date.now(),
46 ): Promise<string[]> {
47 const found: string[] = [join(gitDir, "HEAD.lock"), join(gitDir, "packed-refs.lock")];
48 await walk(join(gitDir, "refs"), found, 0);
49 const removed: string[] = [];
50 for (const path of found) {
51 try {
52 const s = await stat(path);
outdated

Removes locks held by live git processes if they are older than 5 s (packed-refs.lock during pack-refs/gc, HEAD.lock). That breaks git's mutual exclusion. Scope it to the one ref at merge time and sweep the whole repository only at boot.

53 if (!s.isFile() || now - s.mtimeMs < minAgeMs) continue;
54 await rm(path, { force: true });
55 removed.push(path);
56 } catch {
57 // not there, or gone as we looked
58 }
59 }
60 return removed;
61 }
62
63 /**
64 * The lock of one ref, when it is older than `minAgeMs`: what a merge may
65 * remove while other git processes run. Never another file's lock: a live
66 * `pack-refs` holds `packed-refs.lock` for longer than any ref update.
67 */
68 export async function sweepRefLock(
69 gitDir: string,
70 ref: string,
71 minAgeMs: number = STALE_LOCK_MS,
72 now: number = Date.now(),
73 ): Promise<boolean> {
74 if (!/^refs\/heads\/[A-Za-z0-9._\/-]+$/.test(ref) || ref.includes("..")) return false;
75 const path = join(gitDir, `${ref}.lock`);
76 try {
77 const s = await stat(path);
78 if (!s.isFile() || now - s.mtimeMs < minAgeMs) return false;
79 await rm(path, { force: true });
80 return true;
81 } catch {
82 return false;
83 }
84 }
src/git/merge.ts +25 −4
@@ -8,6 +8,7 @@
88 import { err, ok, type Result } from "../kernel/result.ts";
99 import { diffMemoryKey, recalledBase, rememberBase } from "./diff-memory.ts";
1010 import { runGit, runGitCapture } from "./env.ts";
11 import { isLockHeld, sweepRefLock } from "./locks.ts";
1112 import { isSafeOid, isSafeRef, isSafeTreePath } from "./guard.ts";
1213
1314 export type MergeStrategy = "merge" | "squash";
@@ -21,6 +22,11 @@ export type MergeError =
2122 | { kind: "conflict"; files: string[] }
2223 | { kind: "unsafe" }
2324 | { kind: "base_moved" }
25 /**
26 * A ref's lock file is held (by a live git process, or a recent one's leftover): not the base
27 * moving.
28 */
29 | { kind: "ref_locked" }
2430 | { kind: "git"; message: string };
2531
2632 export interface MergeResult {
@@ -221,6 +227,11 @@ export async function merge(
221227 * nothing is committed.
222228 */
223229 treeOid?: string;
230 /**
231 * Called with the commit about to land, before the ref moves and in the
232 * same tick: the intent to merge, recorded where a kill can find it (#12143).
233 */
234 beforeMove?: (mergeOid: string) => void;
224235 },
225236 ): Promise<Result<MergeResult, MergeError>> {
226237 if (
@@ -256,10 +267,20 @@ export async function merge(
256267 const mergeOid = commit.value.trim();
257268
258269 // CAS the branch forward: fails if someone pushed between our read and now.
259 const update = await runGit(
260 ["update-ref", `refs/heads/${input.baseBranch}`, mergeOid, input.baseOid],
261 { cwd: dir },
262 );
270 input.beforeMove?.(mergeOid);
271 const move = () =>
272 runGit(
273 ["update-ref", `refs/heads/${input.baseBranch}`, mergeOid, input.baseOid],
274 { cwd: dir },
275 );
276 let update = await move();
277 // A lock a killed git left behind is not the base moving (#12143): one
278 // of this ref older than any live update is removed and the move tried once more; a
279 // lock still held is reported as held.

This sweeps every old lock in the repo on any lock-held error, not just the lock of this ref. Remove only refs/heads/<base>.lock, and only when it is stale.

280 if (!update.ok && isLockHeld(update.error.stderr)) {
281 if (await sweepRefLock(dir, `refs/heads/${input.baseBranch}`)) update = await move();
282 if (!update.ok && isLockHeld(update.error.stderr)) return err({ kind: "ref_locked" });
283 }
263284 if (!update.ok) return err({ kind: "base_moved" });
264285
265286 return ok({ mergeOid, previousOid: input.baseOid });
src/http/api/take_back.ts +2
@@ -48,6 +48,8 @@ export function takeBackRefusal(error: RevertPullError, log: Log): Response {
4848 );
4949 case "base_moved":
5050 return apiError("conflict", 409, "the default branch moved under it; try again");
51 case "ref_locked":
52 return apiError("conflict", 409, "the default branch is locked by another git operation; try again shortly");
5153 case "octopus":
5254 return apiError("conflict", 409, `it has ${error.parents} parents; take it back by hand`);
5355 case "unsafe":
src/kernel/destroy_reach.ts +1
@@ -75,6 +75,7 @@ export const ALLOWED_REMOVALS: readonly { unit: string; why: string }[] = [
7575 "src/apps/checkout.ts:exportTree",
7676 ]),
7777 { unit: "src/core/repos.ts:rowOrUndo", why: "removes the directory git has just made for the repository being created, when its row is refused" },
78 { unit: "src/git/locks.ts:sweepRefLock", why: "removes the one lock file of the ref a merge moves, when older than any live update (issue 12143): a name checked to be under refs/heads, never another file" },
7879 { unit: "src/git/write.ts:commitFiles", why: "removes its own temporary index file" },
7980 { unit: "src/kernel/file_modes.ts:writePrivate", why: "removes the temporary file it has just made itself, under a name nobody else chose, when the write or the rename failed" },
8081 { unit: "src/git/repos.ts:detachFork", why: "removes the alternates file of the fork just made" },
src/main.ts +2
@@ -70,6 +70,7 @@ import { resolveDestination } from "./snapshot/destination.ts";
7070 import { resolveMirror } from "./snapshot/mirror.ts";
7171 import { releaseOrphans } from "./core/run_secret.ts";
7272 import { failAbandonedRuns } from "./core/runs.ts";
73 import { settleLandings } from "./core/landing.ts";
7374 import { DRAIN_LINGER_MAX_S, drainLingerMs, startDraining } from "./http/health.ts";
7475 import { takePresence, warmBehind } from "./apps/presence.ts";
7576 import { STALE_AFTER_MS } from "./runs/lease.ts";
@@ -890,6 +891,7 @@ if (ssh)
890891 // agent runs are the crew's to queue again, unless there is no crew here to
891892 // do it.
892893 const leaseTimer = setInterval(() => during("lease", () => {
894 void settleLandings(app, false).catch((e) => app.log.warn("settling cut-off merges failed", { error: String(e) }));
outdated

Ticks and boot can overlap, and two overlapping settles can both emit pull.merged. Add an in-flight guard, or re-check merged_at right before closing.

893895 failAbandonedRuns(
894896 app.db,
895897 STALE_AFTER_MS,
src/web/pages/pull.ts +2
@@ -1314,6 +1314,8 @@ export function mergeFailure(error: AccessError | MergePullError): string {
13141314 return `The branches conflict in ${error.files.length} file${error.files.length === 1 ? "" : "s"} — merge the base branch in and push again.`;
13151315 if (error.kind === "base_moved")
13161316 return "The base branch moved while merging. Try again.";
1317 if (error.kind === "ref_locked")
1318 return "The base branch is locked by another git operation. Try again in a moment.";
13171319 if (error.kind === "unsafe") return "That merge was refused as unsafe.";
13181320 if (error.kind === "guarded") return guardedRefusal(error.paths);
13191321 if (error.kind === "team_review_required")
test/merge-crash-recovery.test.ts added +214
@@ -0,0 +1,214 @@
1 /**
2 * A forge killed in the middle of a merge comes back consistent (#12143):
3 * a pull whose merge commit reached the base but not the database is closed
4 * as merged at boot; a lock file a kill left behind does not block main; a
5 * run whose process is gone is settled at once.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { mergePull, openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { getIssueById } from "../src/core/issues.ts";
15 import { recordLanding, settleLandings } from "../src/core/landing.ts";
16 import { getPull, type Pull } from "../src/core/pulls.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createRun, lapseOrphans, failAbandonedRuns } from "../src/core/runs.ts";
19 import type { User } from "../src/core/users.ts";
20 import { merge, resolveOid } from "../src/git/merge.ts";
21 import { repoDir } from "../src/git/repos.ts";
22 import { sweepLocks } from "../src/git/locks.ts";
23 import { promote } from "./helpers/members.ts";
24 import { signUpProven as signUp } from "./helpers/verified.ts";
25
26 describe("a merge cut off by a kill", () => {
27 let dataDir: string;
28 let app: App;
29 let alice: User;
30 let repo: Repo;
31 let dir: string;
32 let n = 0;
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-merge-crash-"));
36 app = createApp({ dataDir, port: 0 });
37 const made = await signUp(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
38 if (!made.ok) throw new Error("sign-up");
39 alice = made.value;
40 promote(app.db, alice);
41 const r = await createRepo(app, alice, { owner: "alice", name: "site", visibility: "public" });
42 if (!r.ok) throw new Error(String(r.error));
43 repo = r.value;
44 dir = repoDir(app.reposRoot, "alice", "site");
45 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "hi\n" }] });
46 }, 30_000);
47 afterAll(async () => {
48 app.close();
49 await rm(dataDir, { recursive: true, force: true });
50 });
51
52 const newPull = async (): Promise<Pull> => {
53 const branch = `f${++n}`;
54 await commitToBranch(app, alice, repo, {
55 branch,
56 message: branch,
57 files: [{ path: `${branch}.txt`, content: "x\n" }],
58 });
59 const pull = await openPull(app, alice, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch });
60 if (!pull.ok) throw new Error(String(pull.error));
61 return pull.value;
62 };
63
64 test("killed between the ref move and the database commit: boot closes the pull as merged, with its event", async () => {
65 const pull = await newPull();
66 const base = (await resolveOid(dir, "refs/heads/main"))!;
67 // What mergePull does, stopped where the kill fell: intent recorded, ref moved, row never
68 // closed.
69 const landed = await merge(dir, {
70 baseBranch: "main",
71 baseOid: base,
72 headOid: pull.headOid,
73 strategy: "squash",
74 message: "cut off",
75 author: { name: "A", email: "[email protected]" },
76 beforeMove: (mergeOid) =>
77 recordLanding(app.db, pull.id, { mergeOid, actorId: alice.id, baseOid: base, headOid: pull.headOid }),
78 });
79 expect(landed.ok).toBe(true);
80 expect(getIssueById(app.db, pull.id)?.state).toBe("open");
81
82 // A merge still in flight is not the slow tick's to settle.
83 expect(await settleLandings(app, false)).toEqual([]);
84 expect(getIssueById(app.db, pull.id)?.state).toBe("open");
85
86 const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM event WHERE kind = 'pull.merged'").get()!.n;
87 expect(await settleLandings(app, true)).toEqual([pull.number]);
88 const after = getPull(app.db, repo.id, pull.number)!;
89 expect(after.mergedAt).not.toBeNull();
90 expect(after.mergedOid).toBe(landed.ok ? landed.value.mergeOid : "");
91 expect(getIssueById(app.db, pull.id)?.state).toBe("closed");
92 expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM event WHERE kind = 'pull.merged'").get()!.n).toBe(before + 1);
93 // Once: a second boot finds nothing.
94 expect(await settleLandings(app, true)).toEqual([]);
95 });
96
97 test("an intent whose ref never moved is dropped and the pull stays open", async () => {
98 const pull = await newPull();
99 const base = (await resolveOid(dir, "refs/heads/main"))!;
100 recordLanding(app.db, pull.id, { mergeOid: pull.headOid, actorId: alice.id, baseOid: base, headOid: pull.headOid });
101 expect(await settleLandings(app, true)).toEqual([]);
102 expect(getIssueById(app.db, pull.id)?.state).toBe("open");
103 expect(app.db.query("SELECT 1 FROM pull_landing WHERE issue_id = ?1").get(pull.id)).toBeNull();
104 });
105
106 test("a stale main.lock left by a kill does not block the next merge", async () => {
107 const pull = await newPull();
108 const lock = join(dir, "refs", "heads", "main.lock");
109 await mkdir(join(dir, "refs", "heads"), { recursive: true });
110 await writeFile(lock, "");
111 const old = new Date(Date.now() - 60_000);
112 await utimes(lock, old, old);
113 const merged = await mergePull(app, alice, repo, pull, "squash");
114 expect(merged.ok).toBe(true);
115 expect(getPull(app.db, repo.id, pull.number)?.mergedAt).not.toBeNull();
116 });
117
118 test("a merge removes only its own ref's stale lock: an old lock of another file is a live git's", async () => {
119 const pull = await newPull();
120 const mine = join(dir, "refs", "heads", "main.lock");
121 const packed = join(dir, "packed-refs.lock");
122 const other = join(dir, "refs", "heads", "other.lock");
123 const old = new Date(Date.now() - 60_000);
124 for (const f of [mine, packed, other]) {
125 await writeFile(f, "");
126 await utimes(f, old, old);
127 }
128 expect((await mergePull(app, alice, repo, pull, "squash")).ok).toBe(true);
129 expect(await Bun.file(mine).exists()).toBe(false);
130 expect(await Bun.file(packed).exists()).toBe(true);
131 expect(await Bun.file(other).exists()).toBe(true);
132 await rm(packed);
133 await rm(other);
134 });
135
136 test("two settles at once announce the merge once", async () => {
137 const pull = await newPull();
138 const base = (await resolveOid(dir, "refs/heads/main"))!;
139 const landed = await merge(dir, {
140 baseBranch: "main",
141 baseOid: base,
142 headOid: pull.headOid,
143 strategy: "squash",
144 message: "cut off",
145 author: { name: "A", email: "[email protected]" },
146 beforeMove: (mergeOid) =>
147 recordLanding(app.db, pull.id, { mergeOid, actorId: alice.id, baseOid: base, headOid: pull.headOid }),
148 });
149 expect(landed.ok).toBe(true);
150 const count = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM event WHERE kind = 'pull.merged'").get()!.n;
151 const before = count();
152 const [a, b] = await Promise.all([settleLandings(app, true), settleLandings(app, true)]);
153 expect([...a, ...b]).toEqual([pull.number]);
154 expect(count()).toBe(before + 1);
155 });
156
157 test("a lock held now is reported as held, not as the base moving", async () => {
158 const pull = await newPull();
159 const lock = join(dir, "refs", "heads", "main.lock");
160 await writeFile(lock, "");
161 const merged = await mergePull(app, alice, repo, pull, "squash");
162 expect(merged.ok).toBe(false);
163 expect(!merged.ok && (merged.error as { kind: string }).kind).toBe("ref_locked");
164 expect(getIssueById(app.db, pull.id)?.state).toBe("open");
165 expect(app.db.query("SELECT 1 FROM pull_landing WHERE issue_id = ?1").get(pull.id)).toBeNull();
166 await rm(lock);
167 expect((await mergePull(app, alice, repo, pull, "squash")).ok).toBe(true);
168 });
169
170 test("sweepLocks leaves a young lock and removes an old one", async () => {
171 const young = join(dir, "HEAD.lock");
172 await writeFile(young, "");
173 expect(await sweepLocks(dir, 5_000)).toEqual([]);
174 expect(await sweepLocks(dir, 5_000, Date.now() + 10_000)).toEqual([young]);
175 });
176 });
177
178 describe("runs a restart orphaned", () => {
179 test("a run whose process of this host is gone is settled at once; a live or foreign one is not", async () => {
180 const dataDir = await mkdtemp(join(tmpdir(), "forge-orphans-"));
181 const app = createApp({ dataDir, port: 0 });
182 try {
183 const made = await signUp(app, { username: "bob", email: "[email protected]", password: "a-long-passphrase" });
184 if (!made.ok) throw new Error("sign-up");
185 promote(app.db, made.value);
186 const r = await createRepo(app, made.value, { owner: "bob", name: "site", visibility: "public" });
187 if (!r.ok) throw new Error(String(r.error));
188 const mk = (holder: string) => {
189 const run = createRun(app.db, { repoId: r.value.id, kind: "deploy" });
190 app.db.run("UPDATE run SET state = 'running', holder = ?1, heartbeat_at = ?2 WHERE id = ?3", [
191 holder,
192 new Date().toISOString(),
193 (run as { id: string }).id,
194 ]);
195 return (run as { id: string }).id;
196 };
197 const state = (id: string) => app.db.query<{ state: string }, [string]>("SELECT state FROM run WHERE id = ?1").get(id)!.state;
198 const self = `pod:${process.pid}:5`;
199 const gone = mk("pod:4242:1");
200 const alive = mk("pod:4243:1");
201 const elsewhere = mk("other:4244:1");
202 const mine = mk(self);
203 const samePid = mk(`pod:${process.pid}:1`); // our pid, an earlier life of it
204 const live = (pid: number) => pid === 4243;
205 expect(lapseOrphans(app.db, self, live)).toBe(2);
206 failAbandonedRuns(app.db, 90_000);
207 expect([gone, samePid].map(state)).toEqual(["failed", "failed"]);
208 expect([alive, elsewhere, mine].map(state)).toEqual(["running", "running", "running"]);
209 } finally {
210 app.close();
211 await rm(dataDir, { recursive: true, force: true });
212 }
213 });
214 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs