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

The merge queue never re-forms a batch around the same failing head: suspects are judged alone while the rest lands (one red pull stalled 70+ for ~45 min) #5022

Merged opened this in answer to

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

Merged

mason/forge:mason/4999-the-merge-queue-never-re-forms-a-batch-a → main 1 commit fork

  • Merged. mason/4999-the-merge-queue-never-re-forms-a-batch-a was merged into main as 55389cc.

Review

Sign in to review this change.
4 files changed +300 −18
README.md +7
@@ -1279,6 +1279,13 @@ gated tree. Red is searched: prefixes of the candidate are gated on main
12791279 — the front half, plus one more cut per free check slot, all at once — the
12801280 longest green prefix lands, the search goes on in the steps up to the first
12811281 red prefix, and the red pull is handed back with the failing tests named.
1282 A red batch is never formed again around the same head: with a check slot
1283 free, the head of a red batch of three or more is gated alone beside the
1284 line (`[queue] red batch: its head is gated alone`) while the rest gates
1285 without it, stacked as deep as `queue.depth` goes, so the good pulls land
1286 while the suspect is judged. A pull in two red batches is never in a third:
1287 it waits for a gate of its own. Red alone, it is handed back (`[queue] red
1288 alone`); green alone, it rides at the tail of a later batch, never its head.
12821289 While a candidate gates, the next one gates on top of it (`queue.depth`
12831290 under Settings → Capacity, `FORGE_QUEUE_DEPTH`, default 2): when the first
12841291 lands whole the next lands right after it, on the tree it was gated on; when
src/checks/queue.ts +162 −12
@@ -32,6 +32,15 @@
3232 * stacked on, so a red re-check cuts off no batch of fresh pulls. It is
3333 * forgotten when it lands or its head changes.
3434 *
35 * A red batch is never formed again around the same head (#4999). With a
36 * check slot free, the head of a red batch of three or more — the pull
37 * every prefix of it holds — is a suspect, gated alone beside the line,
38 * while the rest of the batch goes back to the front of the line and gates
39 * without it, stacked as deep as the line goes. A pull in two red batches
40 * is a suspect too, never in a third. A suspect red alone is handed back;
41 * green alone it rides at the tail of a batch, never at its head, and the
42 * search finds it there if it is red on top of others.
43 *
3544 * What is queued is not stored: it is the pulls `landIfGreen` admitted, and
3645 * every sweep admits them again. The queue only orders them and remembers
3746 * what is at the gate and what its searches found, for the tick line and
@@ -159,6 +168,17 @@ interface Remembered {
159168 readonly together: readonly number[];
160169 }
161170
171 /** A pull in red batches at this head (#4999): gated alone once it is a suspect. */
172 interface Suspect {
173 readonly head: string;
174 /** Red batches it was in, less the ones a culprit found since explains. */
175 readonly reds: number;
176 /** Waits for a verdict of its own, gated alone beside the line. */
177 readonly alone: boolean;
178 /** Green alone at this head: never at the head of a batch again. */
179 readonly cleared: boolean;
180 }
181
162182 /** A first candidate's verdict, for the red-batch rate. */
163183 interface BatchVerdict {
164184 readonly at: number;
@@ -182,6 +202,8 @@ interface Line {
182202 gates: number;
183203 /** Pulls handed back red, by pull id: re-checked alone if they join again. */
184204 readonly reds: Map<string, Remembered>;
205 /** Pulls in red batches, by pull id, while their head is the same (#4999). */
206 readonly suspects: Map<string, Suspect>;
185207 /** The last hour's candidate verdicts, search prefixes and re-checks not counted. */
186208 batches: BatchVerdict[];
187209 draining: boolean;
@@ -236,6 +258,7 @@ function lineOf(repoId: string): Line {
236258 flights: new Set(),
237259 gates: 0,
238260 reds: new Map(),
261 suspects: new Map(),
239262 batches: [],
240263 draining: false,
241264 wake: undefined,
@@ -263,13 +286,17 @@ export function enqueue(
263286 const red = line.reds.get(entry.pull.id);
264287 if (red !== undefined && red.head !== entry.pull.headOid) line.reds.delete(entry.pull.id);
265288 const recheck = red !== undefined && red.head === entry.pull.headOid;
289 if (line.suspects.get(entry.pull.id)?.head !== entry.pull.headOid) line.suspects.delete(entry.pull.id);
266290 return new Promise<boolean>((resolve) =>
267291 line.waiting.push({
268292 entry,
269293 recheck,
270294 settle: (landed) => {
271295 line.holds.delete(entry.pull.id);
272 if (landed) line.reds.delete(entry.pull.id);
296 if (landed) {
297 line.reds.delete(entry.pull.id);
298 line.suspects.delete(entry.pull.id);
299 }
273300 resolve(landed);
274301 },
275302 }),
@@ -441,7 +468,9 @@ const ARRIVED = Symbol("arrived");
441468 * one place and one base branch; a sealed pull whose objects live
442469 * elsewhere is a batch of its own, and is not stacked on another. A batch
443470 * carries at most `MAX_CANDIDATE` pulls. A gate beyond the line's first
444 * starts only on a free check slot (`room`).
471 * starts only on a free check slot (`room`). A suspect is gated alone,
472 * beside the line and first (#4999); the line's first gate waits for a
473 * free slot while one is.
445474 */
446475 async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Promise<void> {
447476 line.draining = true;
@@ -449,19 +478,41 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
449478 const depth = Math.max(1, Math.floor(options.depth ?? DEFAULT_DEPTH));
450479 const room: Room = options.room ?? (() => 0);
451480 const ahead: Flight[] = [];
481 const solos = new Set<Promise<void>>();
452482 try {
453 while (line.waiting.length > 0 || ahead.length > 0) {
454 while (ahead.length < depth && line.waiting.length > 0 && (ahead.length === 0 || room() > 0)) {
483 while (line.waiting.length > 0 || ahead.length > 0 || solos.size > 0) {
484 for (const w of line.waiting.filter((w) => suspectOf(line, w)?.alone === true)) {
485 if (line.flights.size > 0 && room() <= 0) break;
486 line.waiting = line.waiting.filter((x) => x !== w);
487 const started = await solo(line, gated, w);
488 if (started === undefined) continue;
489 const done: Promise<void> = started.done.finally(() => {
490 solos.delete(done);
491 line.wake?.();
492 line.wake = undefined;
493 });
494 solos.add(done);
495 }
496 while (
497 ahead.length < depth &&
498 line.waiting.some((w) => suspectOf(line, w)?.alone !== true) &&
499 ((ahead.length === 0 && solos.size === 0) || room() > 0)
500 ) {
455501 const flight = await launch(line, gated, ahead.at(-1));
456502 if (flight === "apart") break;
457503 if (flight !== undefined) ahead.push(flight);
458504 }
459505 const head = ahead[0];
460 if (head === undefined) continue;
461506 const arrived =
462 ahead.length < depth && line.waiting.length === 0
507 head === undefined || ahead.length < depth || solos.size > 0
463508 ? new Promise<typeof ARRIVED>((resolve) => (line.wake = () => resolve(ARRIVED)))
464509 : undefined;
510 if (head === undefined) {
511 // Only suspects at the gate, or waiting on a slot: wait for one to come back.
512 if (solos.size > 0) await arrived;
513 line.wake = undefined;
514 continue;
515 }
465516 const verdict = arrived === undefined ? await head.verdict : await Promise.race([head.verdict, arrived]);
466517 line.wake = undefined;
467518 if (verdict === ARRIVED) continue;
@@ -482,23 +533,104 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
482533 }
483534 }
484535
536 /** What the line knows of a waiting pull's red batches, at its head. */
537 function suspectOf(line: Line, w: Waiting): Suspect | undefined {
538 const s = line.suspects.get(w.entry.pull.id);
539 return s?.head === w.entry.pull.headOid ? s : undefined;
540 }
541
542 /**
543 * Start a suspect's gate alone on the base's tip, beside the line (#4999):
544 * its answer is when the verdict is acted on. Red alone, it is handed back
545 * and the pulls of its batch are cleared of that red; green alone, it goes
546 * back to the line to ride at a batch's tail. Undefined when there is
547 * nothing to gate — it conflicts, or is already in — and the next sweep
548 * brings it up to date or closes it.
549 */
550 async function solo(line: Line, gated: Gated, w: Waiting): Promise<{ readonly done: Promise<void> } | undefined> {
551 const settled = (error: unknown): void => {
552 log.warn("merge queue round failed", { error: String(error) });
553 w.settle(false);
554 };
555 let candidate: Candidate | undefined;
556 try {
557 candidate = await build([w]);
558 } catch (error) {
559 settled(error);
560 return undefined;
561 }
562 if (candidate === undefined || candidate.steps.length === 0) {
563 w.settle(false);
564 return undefined;
565 }
566 const on = candidate;
567 const done = gated(on)
568 .then((verdict) => {
569 const pull = w.entry.pull;
570 if (verdict.lands) {
571 line.suspects.set(pull.id, { head: pull.headOid, reds: 0, alone: false, cleared: true });
572 line.waiting.unshift(w);
573 return;
574 }
575 if ("interrupted" in verdict) {
576 w.settle(false);
577 return;
578 }
579 line.suspects.delete(pull.id);
580 line.reds.set(pull.id, { head: pull.headOid, together: [] });
581 log.info("red alone", { repo: line.repoId, pull: pull.number });
582 w.entry.handBack(verdict, on.baseOid, []);
583 w.settle(false);
584 })
585 .catch(settled);
586 return { done };
587 }
588
589 /**
590 * Every pull of a red batch was in one more red batch (#4999). Its head is
591 * the suspect when `split`; a pull in two red batches is one too, unless it
592 * was green alone at this head.
593 */
594 function suspected(line: Line, steps: readonly Waiting[], split: boolean): void {
595 for (const [i, w] of steps.entries()) {
596 const was = suspectOf(line, w);
597 const reds = (was?.reds ?? 0) + 1;
598 const cleared = was?.cleared ?? false;
599 const alone = !cleared && (reds >= 2 || (split && i === 0));
600 line.suspects.set(w.entry.pull.id, { head: w.entry.pull.headOid, reds, alone, cleared });
601 }
602 }
603
604 /** A culprit found among `steps` explains the red batch they were in: one red each is forgiven. */
605 function explained(line: Line, steps: readonly Waiting[]): void {
606 for (const w of steps) {
607 const was = suspectOf(line, w);
608 if (was === undefined) continue;
609 const reds = Math.max(0, was.reds - 1);
610 line.suspects.set(w.entry.pull.id, { ...was, reds, alone: !was.cleared && reds >= 2 });
611 }
612 }
613
485614 /**
486615 * Take the next batch from the front of the line, build it — on `on`'s tip
487616 * when there is a flight to stack on — and start its gate. Fresh pulls go
488 * first; a re-check is a batch of one, once no fresh pull waits. `apart`
617 * first, a pull green alone after a red batch at their tail; a re-check is
618 * a batch of one, once no fresh pull waits. A suspect is never in a batch:
619 * it is gated alone (`solo`). `apart`
489620 * when the front of the line cannot stack on `on`, or `on` is a re-check;
490621 * undefined when nothing of the batch could be built.
491622 */
492623 async function launch(line: Line, gated: Gated, on: Flight | undefined): Promise<Flight | undefined | "apart"> {
493 const front = line.waiting.find((w) => !w.recheck) ?? line.waiting[0]!;
624 const cleared = (w: Waiting): boolean => suspectOf(line, w)?.cleared === true;
625 const open = line.waiting.filter((w) => suspectOf(line, w)?.alone !== true);
626 const front = open.find((w) => !w.recheck && !cleared(w)) ?? open.find((w) => !w.recheck) ?? open[0]!;
494627 const first = front.entry;
495628 const branch = first.pull.baseBranch;
496629 if (on !== undefined && (on.recheck || first.dir !== on.candidate.dir || branch !== on.branch)) return "apart";
630 const same = open.filter((w) => !w.recheck && w.entry.dir === first.dir && w.entry.pull.baseBranch === branch);
497631 const batch = front.recheck
498632 ? [front]
499 : line.waiting
500 .filter((w) => !w.recheck && w.entry.dir === first.dir && w.entry.pull.baseBranch === branch)
501 .slice(0, MAX_CANDIDATE);
633 : [...same.filter((w) => !cleared(w)), ...same.filter(cleared)].slice(0, MAX_CANDIDATE);
502634 line.waiting = line.waiting.filter((w) => !batch.includes(w));
503635 let candidate: Candidate | undefined;
504636 try {
@@ -560,7 +692,24 @@ async function settleHead(search: Search, head: Flight, verdict: LandVerdict, ah
560692 for (const w of head.steps) w.settle(false);
561693 return behind;
562694 }
563 return [...(await judged(search, candidate, head.steps, verdict, [])), ...behind];
695 // A red batch is not formed again around its head (#4999): with a slot
696 // free, the head is gated alone beside the line while the rest gate
697 // without it. Two steps are the search's own split: the head alone.
698 const { line } = search;
699 const split =
700 !head.recheck && head.steps.length > 2 && suspectOf(line, head.steps[0]!) === undefined && search.room() > 0;
701 suspected(line, head.steps, split);
702 if (split) {
703 log.info("red batch: its head is gated alone", {
704 repo: line.repoId,
705 pull: head.steps[0]!.entry.pull.number,
706 rest: head.steps.length - 1,
707 });
708 return [...head.steps, ...behind];
709 }
710 const left = await judged(search, candidate, head.steps, verdict, []);
711 if (head.steps.some((w) => line.reds.get(w.entry.pull.id)?.head === w.entry.pull.headOid)) explained(line, head.steps);
712 return [...left, ...behind];
564713 }
565714
566715 /**
@@ -654,6 +803,7 @@ async function judged(
654803 // them to tell which. Remembered with them either way (#4235).
655804 const together = landed.map((q) => q.pull.number);
656805 search.line.reds.set(culprit.pull.id, { head: culprit.pull.headOid, together });
806 search.line.suspects.delete(culprit.pull.id);
657807 log.info(together.length === 0 ? "red on its own merge" : "red on top of its candidate", {
658808 repo: search.line.repoId,
659809 pull: culprit.pull.number,
test/merge-queue-parallel.test.ts +9 −6
@@ -151,7 +151,7 @@ describe("the merge queue in parallel", () => {
151151 expect(git(repo, "rev-list", "--count", "refs/heads/main")).toBe("17");
152152 });
153153
154 test("ten pulls, one red: the culprit is found in one wave of prefixes, and the nine land by the third gate-time", async () => {
154 test("ten pulls, one red: the head is gated alone, the culprit is found in one wave of prefixes, and the nine land by the fourth gate-time", async () => {
155155 const repo = await autonomousRepo("ten-one-red");
156156 const pulls = [];
157157 for (let i = 0; i < 10; i++) pulls.push(await greenPull(repo, i === 4 ? `red-${i}.ts` : `p${i}.ts`));
@@ -159,14 +159,17 @@ describe("the merge queue in parallel", () => {
159159 const sweep = sweepGreen(app, repo);
160160 // Wave one: eight on main, the other two on top of them.
161161 await letGo(2);
162 // Wave two: the eight are red; the two on top are re-formed, and seven
163 // prefixes of the eight gate at once: four green, the fifth red.
162 // Wave two: the eight are red, so their head is gated alone (#4999),
163 // beside the other seven and the next on main and the last on top.
164 await letGo(3);
165 // Wave three: the head was green alone; the eight without it are red
166 // again, and seven prefixes of them gate at once: three green, the fourth red.
164167 await letGo(7);
165 // Wave three: the culprit is handed back without a gate of its own;
166 // the three after it and the two re-formed are one candidate.
168 // Wave four: the culprit is handed back without a gate of its own; the
169 // four after it, the last and the head (at the tail) are one candidate.
167170 await letGo(1);
168171 expect(await sweep).toBe(9);
169 expect(wave - first).toBe(3);
172 expect(wave - first).toBe(4);
170173 const red = pulls[4]!;
171174 for (const pull of pulls) expect(merged(repo, pull)).toBe(pull !== red);
172175 expect(latestDecision(app.db, red.id, "land")?.answer).toBe("red");
test/merge-queue-suspect.test.ts added +122
@@ -0,0 +1,122 @@
1 /**
2 * A red batch is never formed again around the same head (#4999). The head
3 * of a red batch is gated alone beside the line while the rest gate without
4 * it, so the good pulls land while the suspect is judged; a pull in two red
5 * batches waits for a verdict of its own and never heads a third. The gate
6 * here reads the candidate's tree: red when it holds `bad.txt`.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { type Candidate, enqueue, type Expect, type Queued } from "../src/checks/queue.ts";
13 import type { LandVerdict } from "../src/checks/tiers.ts";
14 import type { Pull } from "../src/core/pulls.ts";
15
16 describe("the merge queue judges a suspect alone", () => {
17 let dir: string;
18 /** Every hand-back: the pull, and the pulls it was red on top of. */
19 const told: { pull: number; together: number[] }[] = [];
20
21 const git = (...args: string[]) =>
22 Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim();
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-suspect-"));
26 Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]);
27 git("commit", "-q", "--allow-empty", "-m", "start");
28 });
29
30 afterAll(async () => {
31 await rm(dir, { recursive: true, force: true });
32 });
33
34 /** A pull's head: one file added on main as it is now. */
35 async function headAdding(branch: string, file: string): Promise<string> {
36 git("checkout", "-q", "-B", branch, "main");
37 git("reset", "-q", "--hard", "main");
38 await Bun.write(join(dir, file), `${file}\n`);
39 git("add", file);
40 git("commit", "-q", "-m", file);
41 git("checkout", "-q", "--detach");
42 return git("rev-parse", branch);
43 }
44
45 const verdictOf = (candidate: Candidate): LandVerdict =>
46 git("ls-tree", "--name-only", candidate.tipOid).split("\n").includes("bad.txt")
47 ? { lands: false, reopen: true, comment: "red", learn: [] }
48 : { lands: true };
49
50 /** A queued pull that lands the way the forge does: only on the base it was gated on. */
51 function entry(line: string, number: number, headOid: string): Queued {
52 return {
53 pull: { id: `${line}-${number}`, number, baseBranch: "main", headOid } as Pull,
54 dir,
55 land: async (expect: Expect) => {
56 if (git("rev-parse", "refs/heads/main") !== expect.baseOid) return undefined;
57 const tip = git("commit-tree", expect.treeOid, "-p", expect.baseOid, "-m", `#${number}`);
58 git("update-ref", "refs/heads/main", tip);
59 return tip;
60 },
61 release: () => {},
62 handBack: (_red, _base, together) => told.push({ pull: number, together: [...together] }),
63 };
64 }
65
66 test("one bad pull at the head, thirty good behind: the thirty land in two gate-times, the bad one is handed back after its solo red", async () => {
67 const bad = entry("thirty", 1, await headAdding("bad", "bad.txt"));
68 const good: Queued[] = [];
69 for (let i = 0; i < 30; i++) good.push(entry("thirty", 2 + i, await headAdding(`g${i}`, `g${i}.txt`)));
70 // The gate is held open and let go a wave at a time: "at once" is counted, never timed.
71 let pending: { candidate: Candidate; go: (v: LandVerdict) => void }[] = [];
72 const waves: number[][][] = [];
73 const gate = (candidate: Candidate) =>
74 new Promise<LandVerdict>((go) => pending.push({ candidate, go }));
75 const letGo = async (n: number) => {
76 for (let i = 0; i < 2000 && pending.length < n; i++) await Bun.sleep(5);
77 expect(pending).toHaveLength(n);
78 const going = pending;
79 pending = [];
80 waves.push(going.map((p) => p.candidate.steps.map((s) => s.entry.pull.number)));
81 for (const p of going) p.go(verdictOf(p.candidate));
82 };
83 const landed = enqueue("thirty", [bad, ...good], gate, { depth: 4, room: () => 8 });
84 // Gate-time one: four candidates, the first red on its head.
85 await letGo(4);
86 // Gate-time two: the head alone, beside the thirty in four candidates without it.
87 await letGo(5);
88 expect(await landed).toEqual([false, ...good.map(() => true)]);
89 expect(waves[1]!.filter((w) => w.includes(1))).toEqual([[1]]);
90 expect(waves[1]!.flat().sort((a, b) => a - b)).toEqual([1, ...good.map((q) => q.pull.number)]);
91 expect(told).toEqual([{ pull: 1, together: [] }]);
92 const files = git("ls-tree", "--name-only", "refs/heads/main").split("\n");
93 expect(files).toHaveLength(30);
94 expect(files).not.toContain("bad.txt");
95 });
96
97 test("a pull in two red batches never heads a third: it is gated alone, and handed back on its own red", async () => {
98 told.length = 0;
99 const bad = entry("twice", 1, await headAdding("bad2", "bad.txt"));
100 const [g1, g2] = [entry("twice", 2, await headAdding("h1", "h1.txt")), entry("twice", 3, await headAdding("h2", "h2.txt"))];
101 const gates: number[][] = [];
102 // The search is cut off twice (the forge stopping): the batch is red, and nothing is decided.
103 let cut = true;
104 const gate = async (candidate: Candidate): Promise<LandVerdict> => {
105 gates.push(candidate.steps.map((s) => s.entry.pull.number));
106 if (cut && candidate.steps.length < 3) return { lands: false, interrupted: true };
107 return verdictOf(candidate);
108 };
109 expect(await enqueue("twice", [bad, g1, g2], gate)).toEqual([false, false, false]);
110 expect(await enqueue("twice", [bad, g1, g2], gate)).toEqual([false, false, false]);
111 expect(gates.filter((g) => g.length === 3)).toHaveLength(2);
112 cut = false;
113 const before = gates.length;
114 expect(await enqueue("twice", [bad, g1, g2], gate)).toEqual([false, true, true]);
115 const after = gates.slice(before);
116 // Each was in two red batches: each is judged alone, and the bad one heads no batch.
117 expect(after.slice(0, 3)).toEqual([[1], [2], [3]]);
118 expect(after.every((g) => g[0] !== 1 || g.length === 1)).toBe(true);
119 expect(told).toEqual([{ pull: 1, together: [] }]);
120 expect(git("ls-tree", "--name-only", "refs/heads/main").split("\n")).not.toContain("bad.txt");
121 });
122 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs