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

Bigger merge batches: queue.batch as a capacity setting (was the constant 8), and the batch grows after green gates and halves after a red #6675

Merged opened this in answer to

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

Merged

torque/forge:torque/6674-bigger-merge-batches-queue-batch-as-a-ca → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/6674-bigger-merge-batches-queue-batch-as-a-ca was merged into main as 7f47be7.

Review

Sign in to review this change.
7 files changed +372 −23
docs/CREW.md +13 −2
@@ -167,8 +167,14 @@ word) and `issue.parked = 1` carries it, so raw SQL never reads it as done.
167167 its repository's queue (`src/checks/queue.ts`). The queue builds one
168168 candidate: main, then each queued pull's squash on top of the one before, in
169169 queue order, leaving out any pull that conflicts or has nothing left to land.
170 A candidate carries at most eight pulls (`MAX_CANDIDATE`), so a gate stays
171 near one full tier and a red bisects in three; the rest wait their turn.
170 A candidate carries at most `queue.batch` pulls (Settings → Capacity,
171 `FORGE_QUEUE_BATCH`, 1–32, default 8); the rest wait their turn. Each line
172 adapts below it (#6674): a red batch halves its size (`[queue] batch halves
173 to N`), two green batches in a row double it back (`[queue] batch grows to
174 N`), never past the setting, so a clean line drains in big batches and a
175 flaky one stays small. Pulls that come back to the line from a batch that
176 gated go again together, whatever the size. The tick line's `batch=` says
177 the size: `8`, or `4 of 16` after a red.
172178 The log says `[queue] gating N pulls since HH:MM` as a gate starts and
173179 `[queue] gated N pulls: green|red|cut off` as it ends.
174180 The full gate runs once on that candidate. Green lands every pull in order,
@@ -178,6 +184,11 @@ gated tree. Red is searched: prefixes of the candidate are gated on main
178184 — the front half, plus one more cut per free check slot, all at once — the
179185 longest green prefix lands, the search goes on in the steps up to the first
180186 red prefix, and the red pull is handed back with the failing tests named.
187 However few slots are free, the search cuts at least √n − 1 prefixes, and
188 a red run of three or more is then searched by leaving one out: one gate
189 per pull of the run, each carrying everything not yet landed but that pull.
190 The green one lands, and the pull it left out is gated alone. One bad pull
191 in 16 costs two rounds: 3 prefixes, then 4 gates.
181192 A red batch is never formed again around the same head: with a check slot
182193 free, the head of a red batch of three or more is gated alone beside the
183194 line (`[queue] red batch: its head is gated alone`) while the rest gates
src/checks/queue.ts +155 −13
@@ -134,12 +134,18 @@ type Schedule = (fire: () => void, ms: number) => ReturnType<typeof setTimeout>;
134134 let schedule: Schedule = setTimeout;
135135
136136 /**
137 * The most pulls one candidate carries (#3430): eight keep a gate near one
138 * full tier and a red's bisection three gates deep. The rest of the line
139 * waits for the next candidate, in order.
137 * The most pulls one candidate carries when the line is not told (#3430):
138 * eight keep a gate near one full tier. The rest of the line waits for the
139 * next candidate, in order. A forge sets it as `queue.batch` (#6674).
140140 */
141141 export const MAX_CANDIDATE = 8;
142142
143 /** The largest batch a line takes, whatever it is told (`queue.batch`'s range). */
144 export const MAX_BATCH = 32;
145
146 /** Green gates in a row that double a shrunk batch (#6674). */
147 const GREENS_TO_GROW = 2;
148
143149 /** Candidates gating at once by default: one on main, the next on top of it. */
144150 export const DEFAULT_DEPTH = 2;
145151
@@ -147,6 +153,13 @@ export const DEFAULT_DEPTH = 2;
147153 export interface QueueOptions {
148154 /** How many candidates may gate at once, each on the one before. */
149155 readonly depth?: number;
156 /**
157 * The most pulls a candidate carries (`queue.batch`, #6674): the line
158 * starts here, halves after a red batch and doubles back after two green
159 * ones in a row, never past it. Read on every enqueue, so a sweep brings a
160 * change to a line already draining.
161 */
162 readonly batch?: number;
150163 /**
151164 * Check slots free now for a gate beyond a line's first, the pull
152165 * checks' reserve already kept back; none when not said.
@@ -301,6 +314,17 @@ interface Line {
301314 readonly suspects: Map<string, Suspect>;
302315 /** The last hour's candidate verdicts, search prefixes and re-checks not counted. */
303316 batches: BatchVerdict[];
317 /** The most pulls a batch may carry, as the last enqueue was told (#6674). */
318 ceiling: number;
319 /** The batch size after a red, below the ceiling; undefined when at it (#6674). */
320 size: number | undefined;
321 /** Green batch verdicts in a row since the last red or the last doubling. */
322 greens: number;
323 /**
324 * Pulls back in the line from a batch that gated: they gate together
325 * again up to the ceiling, past a size a red halved (#6674).
326 */
327 readonly back: Set<Waiting>;
304328 draining: boolean;
305329 /** Wakes a drain waiting on a gate when pulls join the line. */
306330 wake: (() => void) | undefined;
@@ -405,6 +429,10 @@ function lineOf(repoId: string, store: QueueStore | undefined): Line {
405429 reds,
406430 suspects: new Map(),
407431 batches: [],
432 ceiling: MAX_CANDIDATE,
433 size: undefined,
434 greens: 0,
435 back: new Set(),
408436 draining: false,
409437 wake: undefined,
410438 store,
@@ -469,6 +497,7 @@ export async function enqueue(
469497 options: QueueOptions = {},
470498 ): Promise<boolean[]> {
471499 const line = lineOf(repoId, options.store);
500 line.ceiling = batchCeiling(options.batch);
472501 // A red mark names the base it went red on; the tip is read only when a
473502 // mark is there to compare it with.
474503 const tips = new Map<string, string | undefined>();
@@ -485,20 +514,22 @@ export async function enqueue(
485514 if (red !== undefined && !held) clearRed(line, entry.pull.id);
486515 const recheck = held;
487516 if (line.suspects.get(entry.pull.id)?.head !== entry.pull.headOid) line.suspects.delete(entry.pull.id);
488 return new Promise<boolean>((resolve) =>
489 line.waiting.push({
517 return new Promise<boolean>((resolve) => {
518 const waiting: Waiting = {
490519 entry,
491520 recheck,
492521 settle: (landed) => {
493522 line.holds.delete(entry.pull.id);
523 line.back.delete(waiting);
494524 if (landed) {
495525 clearRed(line, entry.pull.id);
496526 line.suspects.delete(entry.pull.id);
497527 }
498528 resolve(landed);
499529 },
500 }),
501 );
530 };
531 line.waiting.push(waiting);
532 });
502533 });
503534 if (!line.draining) void drain(line, gate, options);
504535 else {
@@ -508,6 +539,49 @@ export async function enqueue(
508539 return Promise.all(answers);
509540 }
510541
542 /** `queue.batch` as a line takes it: a whole number from 1 to `MAX_BATCH`, else the default. */
543 function batchCeiling(batch: number | undefined): number {
544 if (batch === undefined || !Number.isFinite(batch)) return MAX_CANDIDATE;
545 return Math.max(1, Math.min(MAX_BATCH, Math.floor(batch)));
546 }
547
548 /** How many pulls the line's next batch carries: its adapted size, never past the ceiling. */
549 function batchSize(line: Line): number {
550 return Math.min(line.ceiling, line.size ?? line.ceiling);
551 }
552
553 /**
554 * A batch verdict moves the size (#6674): a red halves it, never below one;
555 * two greens in a row double a shrunk one, back to the ceiling at most. A
556 * clean line drains in big batches, a flaky one stays small.
557 */
558 function adapt(line: Line, red: boolean): void {
559 const was = batchSize(line);
560 if (red) {
561 line.greens = 0;
562 line.size = Math.max(1, Math.floor(was / 2));
563 } else {
564 line.greens++;
565 if (line.greens < GREENS_TO_GROW) return;
566 line.greens = 0;
567 if (line.size === undefined) return;
568 line.size = line.size * 2 >= line.ceiling ? undefined : line.size * 2;
569 }
570 const now = batchSize(line);
571 if (now !== was) log.info(`batch ${now > was ? "grows" : "halves"} to ${now}`, { repo: line.repoId, from: was });
572 }
573
574 /**
575 * The size of a repository's next batch, for the tick line (#6674): `8`, or
576 * `4 of 16` while a red keeps it below `ceiling`, the setting in force.
577 */
578 export function batchWords(repoId: string, ceiling: number): string {
579 const line = lines.get(repoId);
580 const top = batchCeiling(ceiling);
581 const size = Math.min(top, line?.size ?? top);
582 return size < top ? `${size} of ${top}` : String(top);
583 }
584
511585 /** The pulls waiting in a repository's line, not yet in a candidate: as they joined. */
512586 export function waitingPulls(repoId: string): Pull[] {
513587 return (lines.get(repoId)?.waiting ?? []).map((w) => w.entry.pull);
@@ -789,7 +863,7 @@ const ARRIVED = Symbol("arrived");
789863 * first as its verdict comes. A batch is the front of the line that shares
790864 * one place and one base branch; a sealed pull whose objects live
791865 * elsewhere is a batch of its own, and is not stacked on another. A batch
792 * carries at most `MAX_CANDIDATE` pulls. A gate beyond the line's first
866 * carries at most the line's batch size (`batchSize`). A gate beyond the line's first
793867 * starts only on a free check slot (`room`). A suspect is gated alone,
794868 * beside the line and first (#4999); the line's first gate waits for a
795869 * free slot while one is.
@@ -852,6 +926,7 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
852926 left = await discard(ahead);
853927 }
854928 // What waits for the next candidate goes back to the front.
929 for (const w of left) line.back.add(w);
855930 line.waiting = [...left, ...line.waiting];
856931 }
857932 } finally {
@@ -896,6 +971,7 @@ async function solo(line: Line, gated: Gated, w: Waiting): Promise<{ readonly do
896971 if (line.stopped) return w.settle(false);
897972 if (verdict.lands) {
898973 line.suspects.set(pull.id, { head: pull.headOid, reds: 0, alone: false, cleared: true });
974 line.back.add(w);
899975 line.waiting.unshift(w);
900976 return;
901977 }
@@ -975,9 +1051,12 @@ async function launch(line: Line, gated: Gated, on: Flight | undefined): Promise
9751051 const branch = first.pull.baseBranch;
9761052 if (on !== undefined && (on.recheck || first.dir !== on.candidate.dir || branch !== on.branch)) return "apart";
9771053 const same = open.filter((w) => !w.recheck && w.entry.dir === first.dir && w.entry.pull.baseBranch === branch);
1054 // A halved size holds fresh pulls back; what came back from a batch gates together.
1055 const size = Math.min(line.ceiling, Math.max(batchSize(line), same.filter((w) => line.back.has(w)).length));
9781056 const batch = front.recheck
9791057 ? [front]
980 : [...same.filter((w) => !cleared(w)), ...same.filter(cleared)].slice(0, MAX_CANDIDATE);
1058 : [...same.filter((w) => !cleared(w)), ...same.filter(cleared)].slice(0, size);
1059 for (const w of batch) line.back.delete(w);
9811060 line.waiting = line.waiting.filter((w) => !batch.includes(w));
9821061 let candidate: Candidate | undefined;
9831062 line.forming++;
@@ -1092,6 +1171,7 @@ async function settleHead(search: Search, head: Flight, verdict: LandVerdict, ah
10921171 }
10931172 }
10941173 if (verdict.lands) {
1174 if (!head.recheck) adapt(search.line, false);
10951175 if (await land(candidate, head.steps)) return [];
10961176 return discard(ahead);
10971177 }
@@ -1109,6 +1189,9 @@ async function settleHead(search: Search, head: Flight, verdict: LandVerdict, ah
11091189 const split =
11101190 !head.recheck && head.steps.length > 2 && suspectOf(line, head.steps[0]!) === undefined && search.room() > 0;
11111191 suspected(line, head.steps, split);
1192 // A red halves the batch (#6674) — but not a split: its rest gates again
1193 // as the one batch it was, and halves the size if it is red too.
1194 if (!head.recheck && !split) adapt(line, true);
11121195 if (split) {
11131196 log.info("red batch: its head is gated alone", {
11141197 repo: line.repoId,
@@ -1206,6 +1289,14 @@ async function round(
12061289 * red one wait, in line order, for the next candidate. No green verdict is
12071290 * thrown away: a disjoint segment gated on the base alone was void once
12081291 * another landed (#6353).
1292 *
1293 * However few slots are free, a search gates at least √n − 1 prefixes, so
1294 * the red run holds at most √n steps (#6674): a red batch blocks its line
1295 * and the flights stacked on it were stopped, so their slots are the
1296 * search's. That run is then searched by leaving one out (`leftOut`): one
1297 * gate per step of it, each carrying everything the search has not landed
1298 * but that step. With one bad pull, 16 red ones land their 15 good in two
1299 * rounds: 3 prefixes, then 4 gates.
12091300 */
12101301 async function judged(
12111302 search: Search,
@@ -1233,10 +1324,11 @@ async function judged(
12331324 return [];
12341325 }
12351326 const n = steps.length;
1236 // One gate per free slot and one more, at least the front half, never the
1237 // whole: it is known red.
1327 // One gate per free slot and one more, at least the front half and √n − 1
1328 // prefixes, never the whole: it is known red.

Width can exceed free slots by up to ~5 at n=32. Cap it by the reserve kept for pull checks, or prove in a test that the reserve survives.

12381329 const free = Math.max(0, room());
1239 const runs = split(steps, Math.min(n - 1, 1 + free));
1330 const width = Math.min(n - 1, Math.max(1 + free, Math.ceil(Math.sqrt(n)) - 1));
1331 const runs = split(steps, width);
12401332 const ends: number[] = [];
12411333 for (const run of runs) ends.push((ends.at(-1) ?? 0) + run.length);
12421334 const prefixes = ends.slice(0, -1).map((end) => prefixOf(candidate, end));
@@ -1253,10 +1345,60 @@ async function judged(
12531345 if (green > 0) await land(prefixes[bad - 1]!, steps.slice(0, green));
12541346 const now = [...landed, ...steps.slice(0, green).map((w) => w.entry)];
12551347 const after = runs.slice(bad + 1).flat();
1256 const left = await round(search, runs[bad]!, known, now);
1348 const suspects = runs[bad]!;
1349 // Leaving one out pays when the run is longer than two: of two, the front
1350 // one gated alone settles both, in line order.
1351 if (suspects.length > 2 && suspects.length <= width + 1) {
1352 const left = await leftOut(search, suspects, after);
1353 if (left !== undefined) return left;
1354 }
1355 const left = await round(search, suspects, known, now);
12571356 return [...left, ...after];
12581357 }
12591358
1359 /**
1360 * The red run `suspects` searched by leaving one out (#6674): for each of
1361 * its steps, everything left of the search but that step — the rest of the
1362 * run, then `after` — built on the base's tip, all gated at once. The first
1363 * green one lands, and the step it left out is a suspect gated alone beside
1364 * the line (#4999), handed back on its own red. Undefined when none is
1365 * green (more than one bad pull): the run is searched by prefixes as
1366 * before. Each answer is what waits for the next candidate.
1367 */
1368 async function leftOut(search: Search, suspects: readonly Waiting[], after: readonly Waiting[]): Promise<Waiting[] | undefined> {
1369 const { line, gated } = search;
1370 const tries: { out: Waiting; batch: Waiting[]; candidate: Candidate }[] = [];
1371 for (const out of suspects) {
1372 const batch = [...suspects.filter((w) => w !== out), ...after];
1373 const candidate = await build(batch);
1374 if (candidate !== undefined && candidate.steps.length > 0) tries.push({ out, batch, candidate });
1375 }
1376 const verdicts = await Promise.all(tries.map((t) => gated(t.candidate)));
1377 if (line.stopped || verdicts.some((v) => !v.lands && "interrupted" in v)) {
1378 for (const w of [...suspects, ...after]) w.settle(false);
1379 return [];
1380 }
1381 const found = verdicts.findIndex((v) => v.lands);
1382 if (found < 0) return undefined;
1383 const { out, batch, candidate } = tries[found]!;
1384 log.info("red run: every pull but one lands, the one left out is gated alone", {
1385 repo: line.repoId,
1386 pull: out.entry.pull.number,
1387 landing: candidate.steps.length,
1388 });
1389 const was = suspectOf(line, out);
1390 line.suspects.set(out.entry.pull.id, {
1391 head: out.entry.pull.headOid,
1392 reds: was?.reds ?? 1,
1393 alone: true,
1394 cleared: false,
1395 });
1396 const steps = inCandidate(batch, candidate);
1397 // A step that did not land was settled by `land`; the one left out waits.
1398 await land(candidate, steps);
1399 return [out];
1400 }
1401
12601402 /** The candidate's chain cut after its first `end` steps: the tree a prefix gates. */
12611403 function prefixOf(candidate: Candidate, end: number): Candidate {
12621404 const last = candidate.steps[end - 1]!;
src/core/capacity.ts +13
@@ -20,6 +20,7 @@ export type CapacityKey =
2020 | "checks.concurrency"
2121 | "checks.shards"
2222 | "queue.depth"
23 | "queue.batch"
2324 | "children.max"
2425 | "cells.egress"
2526 | "guard_new_accounts"
@@ -72,6 +73,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
7273 fallback: "2",
7374 help: "How many merge-queue candidates of one repository gate at once, each on top of the one before: while one gates on main the next gates on it, and lands right after it if both are green. 1 gates one candidate at a time. A gate beyond the first takes a free check slot only, never one of the two kept for pull checks.",
7475 },
76 "queue.batch": {
77 label: "Merge queue batch size",
78 env: "FORGE_QUEUE_BATCH",
79 kind: { type: "count", min: 1, max: 32 },
80 fallback: "8",
81 help: "The most pulls one merge-queue candidate carries. A bigger batch lands more pulls per green gate; a red one costs more search gates to find its culprit (one bad pull in 16 is found in two rounds, at most √16 = 4 gates each). The queue starts each line here, halves the batch after a red gate and doubles it back after two green gates in a row, never past this. Read on every sweep.",
82 },
7583 "children.max": {
7684 label: "Forges this one may send",
7785 env: "FORGE_CHILDREN_MAX",
@@ -318,6 +326,11 @@ export function queueDepth(db: Database): number {
318326 return Number(capacityValue(db, "queue.depth"));
319327 }
320328
329 /** The most pulls one merge-queue candidate carries (#6674); the line adapts below it. */
330 export function queueBatch(db: Database): number {
331 return Number(capacityValue(db, "queue.batch"));
332 }
333
321334 /** Whether the platform edge's identity is read at all; on every request. */
322335 export function edgeSigninOn(db: Database): boolean {
323336 return capacityValue(db, "signin.edge") !== "off";
src/crew/autonomy.ts +7 −4
@@ -25,9 +25,9 @@ import type { AccessError } from "../actions/access.ts";
2525 import { adoptGate, gateCandidate, releaseOnLand } from "../checks/landing.ts";
2626 import { queueStore } from "../checks/queue_store.ts";
2727 import { addLearned } from "../checks/learned.ts";
28 import { dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
28 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
2929 import { checksFor } from "../checks/service.ts";
30 import { queueDepth } from "../core/capacity.ts";
30 import { queueBatch, queueDepth } from "../core/capacity.ts";
3131 import { readConflictLane, readConflictRoom } from "./conflict_room.ts";
3232 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
3333 import { CLOSED_NOTE } from "../core/done_on_main.ts";
@@ -1321,6 +1321,8 @@ export async function autonomyTick(
13211321 ...(approved === undefined ? {} : { approved }),
13221322 // What the merge queue is doing now, not what the sweep admitted (#3430).
13231323 queue: queueWords(current.id),
1324 // The size of the next batch: `8`, or `4 of 16` after a red (#6674).
1325 batch: batchWords(current.id, queueBatch(app.db)),
13241326 // Pulls the queue found red on top of others of their candidate (#4235).
13251327 clashes: queueClashes(current.id),
13261328 spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`,
@@ -2053,14 +2055,15 @@ export function regateDue(red: Decision, now: number): boolean {
20532055 }
20542056
20552057 /**
2056 * How the merge queue may use the forge's check slots (#4053): its depth as
2057 * an admin set it, and the slots the checks service has spare. Each judged
2058 * How the merge queue may use the forge's check slots (#4053): its depth and
2059 * batch size as an admin set them, and the slots the checks service has spare. Each judged
20582060 * batch is kept for the red-batch rate (#6427); its gates and red marks are
20592061 * kept in the database, and a gate a roll left running adopted (#6611).
20602062 */
20612063 function queueOptions(app: App, repo: Repo): QueueOptions {
20622064 return {
20632065 depth: queueDepth(app.db),
2066 batch: queueBatch(app.db),
20642067 room: () => checksFor(app)?.spareSlots?.() ?? 0,
20652068 onBatch: (repoId, red) => keepBatch(app.db, repoId, red),
20662069 store: queueStore(app.db),
test/merge-queue-batch.test.ts added +148
@@ -0,0 +1,148 @@
1 /**
2 * The batch size (#6674): `queue.batch` caps how many pulls a candidate
3 * carries; a red batch halves the line's size and two greens in a row
4 * double it back; and a red batch of 16 with one bad pull lands its 15 good
5 * in two rounds — √16 − 1 prefixes, then one gate per pull of the red run,
6 * each leaving that pull out. The gate is held open and let go a wave at a
7 * time, so "at once" is counted, never timed; it reads the candidate's tree:
8 * red when it holds `bad.txt`.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { batchWords, type Candidate, enqueue, type Expect, type Queued } from "../src/checks/queue.ts";
15 import type { LandVerdict } from "../src/checks/tiers.ts";
16 import type { Pull } from "../src/core/pulls.ts";
17
18 describe("the merge queue's batch size", () => {
19 let dir: string;
20 const told: number[] = [];
21 let landings = 0;
22
23 const git = (...args: string[]) =>
24 Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim();
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-batch-"));
28 Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]);
29 git("commit", "-q", "--allow-empty", "-m", "start");
30 });
31
32 afterAll(async () => {
33 await rm(dir, { recursive: true, force: true });
34 });
35
36 async function headAdding(branch: string, file: string): Promise<string> {
37 git("checkout", "-q", "-B", branch, "main");
38 git("reset", "-q", "--hard", "main");
39 await Bun.write(join(dir, file), `${file}\n`);
40 git("add", file);
41 git("commit", "-q", "-m", file);
42 git("checkout", "-q", "--detach");
43 return git("rev-parse", branch);
44 }
45
46 const verdictOf = (candidate: Candidate): LandVerdict =>
47 git("ls-tree", "--name-only", candidate.tipOid).split("\n").includes("bad.txt")
48 ? { lands: false, reopen: true, comment: "red", learn: [] }
49 : { lands: true };
50
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 landings++;
60 return tip;
61 },
62 release: () => {},
63 handBack: () => told.push(number),
64 };
65 }
66
67 async function pulls(line: string, from: number, count: number, bad?: number): Promise<Queued[]> {
68 const out: Queued[] = [];
69 for (let n = from; n < from + count; n++)
70 out.push(entry(line, n, await headAdding(`${line}${n}`, n === bad ? "bad.txt" : `${line}${n}.txt`)));
71 return out;
72 }
73
74 /** A gate that answers at once and keeps how many pulls each candidate carried. */
75 function counting() {
76 const sizes: number[] = [];
77 const gate = async (candidate: Candidate) => {
78 sizes.push(candidate.steps.length);
79 return verdictOf(candidate);
80 };
81 return { gate, sizes };
82 }
83
84 test("the setting is honoured: 4 a candidate when told 4, 16 when told 16, never past 32", async () => {
85 const four = counting();
86 expect(await enqueue("told-four", await pulls("f", 1, 10), four.gate, { depth: 1, batch: 4 })).toEqual(
87 Array(10).fill(true),
88 );
89 expect(four.sizes).toEqual([4, 4, 2]);
90 const sixteen = counting();
91 await enqueue("told-sixteen", await pulls("s", 1, 20), sixteen.gate, { depth: 1, batch: 16 });
92 expect(sixteen.sizes).toEqual([16, 4]);
93 expect(batchWords("told-sixteen", 16)).toBe("16");
94 expect(batchWords("told-sixteen", 99)).toBe("32");
95 });
96
97 test("a red halves the batch, and two greens in a row double it back to the setting", async () => {
98 told.length = 0;
99 const { gate, sizes } = counting();
100 // Eight, the first bad: red. The search finds it; the seven land.
101 const first = await enqueue("adapts", await pulls("a", 1, 8, 1), gate, { depth: 1, batch: 8 });
102 expect(first).toEqual([false, ...Array(7).fill(true)]);
103 expect(told).toEqual([1]);
104 expect(batchWords("adapts", 8)).toBe("4 of 8");
105 // Sixteen good: two of four, both green, so the third is eight again.
106 sizes.length = 0;
107 expect(await enqueue("adapts", await pulls("a", 101, 16), gate, { depth: 1, batch: 8 })).toEqual(
108 Array(16).fill(true),
109 );
110 expect(sizes).toEqual([4, 4, 8]);
111 expect(batchWords("adapts", 8)).toBe("8");
112 });
113
114 test("a red 16-batch with one bad pull lands its 15 good within 2 rounds, then the bad one is handed back alone", async () => {
115 told.length = 0;
116 landings = 0;
117 let pending: { candidate: Candidate; go: (v: LandVerdict) => void }[] = [];
118 const seen: number[][][] = [];
119 const gate = (candidate: Candidate) => new Promise<LandVerdict>((go) => pending.push({ candidate, go }));
120 const letGo = async (n: number) => {
121 for (let i = 0; i < 2000 && pending.length < n; i++) await Bun.sleep(5);
122 expect(pending).toHaveLength(n);
123 const going = pending;
124 pending = [];
125 seen.push(going.map((p) => p.candidate.steps.map((s) => s.entry.pull.number)));
126 for (const p of going) p.go(verdictOf(p.candidate));
127 };
128 const line = await pulls("r", 1, 16, 6);
129 // No free slot: the search still gates √16 − 1 = 3 prefixes.
130 const landed = enqueue("red-sixteen", line, gate, { depth: 1, room: () => 0, batch: 16 });
131 await letGo(1); // the sixteen: red

Polling loops with sleep are flake-prone. Await a gate-count promise resolved from the gate callback instead.

132 await letGo(3); // round one: 4, 8 and 12 deep; the first four land, the red run is 5–8
133 expect(seen[1]!.map((c) => c.length)).toEqual([4, 8, 12]);
134 for (let i = 0; i < 2000 && pending.length < 4; i++) await Bun.sleep(5);
135 expect(landings).toBe(4);
136 await letGo(4); // round two: everything left but one of 5–8 each; the one without 6 lands
137 expect(seen[2]!.map((c) => c.length)).toEqual([11, 11, 11, 11]);
138 for (let i = 0; i < 2000 && landings < 15; i++) await Bun.sleep(5);
139 expect(landings).toBe(15);
140 expect(git("ls-tree", "--name-only", "refs/heads/main").split("\n").filter((f) => /^r\d+\.txt$/.test(f))).toHaveLength(15);
141 await letGo(1); // the one left out, alone: red, handed back
142 expect(seen[3]).toEqual([[6]]);
143 expect(await landed).toEqual(line.map((q) => q.pull.number !== 6));
144 expect(told).toEqual([6]);
145 expect(git("ls-tree", "--name-only", "refs/heads/main").split("\n")).not.toContain("bad.txt");
146 expect(batchWords("red-sixteen", 16)).toBe("8 of 16");
147 });
148 });
test/merge-queue-clash.test.ts +9 −3
@@ -11,7 +11,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1111 import { mkdtemp, rm } from "node:fs/promises";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
14 import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate, redMark } from "../src/checks/queue.ts";
14 import { batchWords, type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate, redMark } from "../src/checks/queue.ts";
1515 import type { Pull } from "../src/core/pulls.ts";
1616
1717 describe("the merge queue remembers clashes", () => {
@@ -177,12 +177,18 @@ describe("the merge queue remembers clashes", () => {
177177 expect(redMark("no-such-line", "clash-2", head)).toBeUndefined();
178178 });
179179
180 test("a push is a new head: B is forgotten, batched with the rest, and lands", async () => {
180 test("a push is a new head: B is forgotten, gated as a fresh pull, and lands", async () => {
181181 const pushed = entry(2, await headAdding("b2", "b2.txt"));
182182 const g = entry(7, await headAdding("g", "g.txt"));
183183 const before = gates.length;
184184 expect(await enqueue(repoId, [pushed, g], gate)).toEqual([true, true]);
185 expect(gates.slice(before)).toEqual([{ pulls: [2, 7], ok: true }]);
185 // Not a re-check: B goes first, as fresh. Its red batches halved this
186 // line's batch to one (#6674); two greens in a row double it.
187 expect(gates.slice(before)).toEqual([
188 { pulls: [2], ok: true },
189 { pulls: [7], ok: true },
190 ]);
191 expect(batchWords(repoId, 8)).toBe("2 of 8");
186192 expect(queueClashes(repoId)).toBe(0);
187193 });
188194 });
test/merge-queue.test.ts +27 −1
@@ -13,8 +13,9 @@ import { createApp, type App } from "../src/app.ts";
1313 import { mergePull, openPull } from "../src/actions/pulls.ts";
1414 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1515 import { readLearned } from "../src/checks/learned.ts";
16 import { enqueue, gateDeadlineForTest, MAX_CANDIDATE, gateRuns, queueStatus, queueWords, type Queued } from "../src/checks/queue.ts";
16 import { batchWords, enqueue, gateDeadlineForTest, MAX_CANDIDATE, gateRuns, queueStatus, queueWords, type Queued } from "../src/checks/queue.ts";
1717 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
18 import { queueBatch, setCapacity, validCapacity } from "../src/core/capacity.ts";
1819 import { latestDecision } from "../src/core/decisions.ts";
1920 import { getPull, type Pull } from "../src/core/pulls.ts";
2021 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -225,6 +226,31 @@ describe("the merge queue", () => {
225226 expect(queue.some((l) => l.includes("[queue] gated 2 pulls: green"))).toBe(true);
226227 }, SLOW);
227228
229 test("queue.batch is read on each sweep: three per candidate when it says 3, and /settings/capacity explains the trade-off (#6674)", async () => {
230 const repo = await autonomousRepo("batch-of-three");
231 const base = git(repo, "rev-parse", "refs/heads/main");
232 const pulls = [];
233 for (let i = 0; i < 7; i++) pulls.push(await greenPull(repo, `b${i}.ts`));
234 expect(validCapacity("queue.batch", "33")).toBe(false);
235 expect(validCapacity("queue.batch", "0")).toBe(false);
236 setCapacity(app.db, "queue.batch", "3", alice.id);
237 try {
238 expect(await sweepGreen(app, repo)).toBe(7);
239 } finally {
240 setCapacity(app.db, "queue.batch", null, alice.id);
241 }
242 const carried = gatesOf(repo)
243 .map((g) => Number(git(repo, "rev-list", "--count", `${base}..${g.commit}`)))
244 .sort((a, b) => a - b);
245 expect(carried).toEqual([3, 6, 7]);
246 for (const pull of pulls) expect(merged(repo, pull)).toBe(true);
247 expect(batchWords(repo.id, queueBatch(app.db))).toBe("8");
248 const page = await fetch(`${servers.url}/settings/capacity`, { headers: { cookie: await login() } });
249 const html = await page.text();
250 expect(html).toContain("Merge queue batch size");
251 expect(html).toContain("a red one costs more search gates");
252 }, SLOW);
253
228254 test("after a red batch, the good pulls go back into ONE candidate up to the cap, not one gate each (#4053)", async () => {
229255 const repo = await autonomousRepo("rebatch");
230256 const pulls = [];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs