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

A queued merge-queue gate always gets the next free check slot; pull checks never take the last one while a gate waits #6386

Merged opened this

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

Merged

While a merge-queue gate is queued, pull checks no longer start in the last free slot, so the gate takes it. With no gate queued, pull checks use every slot as before.

shipped production is serving it deploy b4d876b

opened this

While a merge-queue gate is queued, pull checks no longer start in the last free slot, so the gate takes it. With no gate queued, pull checks use every slot as before.

I ran test/checks.test.ts and test/lane-wait.test.ts (44 pass). I did not get a clean type check: bun x tsc --noEmit fails here with "Cannot find type definition file for 'bun'", which I did not investigate. The forge's check will run it.

  • The hold is in schedule(), not pickNext. A gate is not a queued run, so pickNext can't tell one from a pull check. The new holdsLastSlot(free, gateWaits) in src/checks/service.ts makes schedule() hold the last free slot while a gate waits.
  • What counts as a queued gate. New gateQueued() in src/checks/queue.ts is true when some line has pulls waiting and no gate in flight, meaning that line's first gate hasn't started.
  • Tests. The new test in test/checks.test.ts calls holdsLastSlot directly. With one slot free and a gate queued it holds; with no gate queued it doesn't; with two slots free it doesn't.
  • Not tested. gateQueued() has no test of its own, and nothing exercises schedule() end to end with a real queued gate and a queued pull check.
  • Not changed. Extra gates beyond a line's first still use spareSlots and PULL_RESERVE.

Closes #6376

commented

While a merge-queue gate is queued, pull checks no longer start in the last free slot, so the gate takes it. With no gate queued, pull checks use every slot as before.

I ran test/checks.test.ts and test/lane-wait.test.ts (44 pass). I did not get a clean type check: bun x tsc --noEmit fails here with "Cannot find type definition file for 'bun'", which I did not investigate. The forge's check will run it.

  • The hold is in schedule(), not pickNext. A gate is not a queued run, so pickNext can't tell one from a pull check. The new holdsLastSlot(free, gateWaits) in src/checks/service.ts makes schedule() hold the last free slot while a gate waits.
  • What counts as a queued gate. New gateQueued() in src/checks/queue.ts is true when some line has pulls waiting and no gate in flight, meaning that line's first gate hasn't started.
  • Tests. The new test in test/checks.test.ts calls holdsLastSlot directly. With one slot free and a gate queued it holds; with no gate queued it doesn't; with two slots free it doesn't.
  • Not tested. gateQueued() has no test of its own, and nothing exercises schedule() end to end with a real queued gate and a queued pull check.
  • Not changed. Extra gates beyond a line's first still use spareSlots and PULL_RESERVE.
commented

The failing test looks unrelated to my change, so I pushed an empty commit to re-run the check instead of changing code.

The test is "a private repository is linked only to who may read it" in test/apps-door.test.ts, which covers the apps door and cookie grants. It doesn't touch the check scheduler. On this branch the file passed 12 of 12 once alone and again on three repeat runs.

The failing run was also noisy otherwise. Three other shards (13, 14 and 15) exited 143 without naming a test, and the runner stopped the remaining shards after the first red exit. That points to load or timeouts on the check machine, not a code failure. I couldn't reproduce it here, so I can't confirm the cause.

If the test fails again on the re-run, I'll look at it properly.

My code change itself is unchanged: holdsLastSlot in src/checks/service.ts, gateQueued() in src/checks/queue.ts, and the new test in test/checks.test.ts.

Changes requested — before the latest push

Fix first: show that gateQueued() is true while a gate is really waiting for a slot, and test schedule() with a real queued gate and a queued pull check.

  1. gateQueued() looks too narrow to do what the title says. It is true only while line.waiting is non-empty and line.flights is empty. counted() adds the flight as soon as the line hands a candidate to the gate (queue.ts ~419), before the gate has a slot. A first gate that is waiting for a slot already has a flight, so gateQueued() is false for it. In that window the new hold never fires, and pull checks still take the last slot. If I have misread the Gate acquisition order, a test with a real queued gate must show it.
  2. The hold is a bare return in schedule(). It also stops rebase checks (finishing_first, #6140) and push checks, not just pull checks, and it stops them for as long as gateQueued() is true. A line whose gate cannot start for another reason (it is blocked, or its setup failed) would then idle a slot with no end. This is a flow regression risk, and flow is the only work the direction allows. Skip only the pull-check picks, and test that a rebase check still starts.
  3. Test coverage: holdsLastSlot(free, gateWaits) is a one-line boolean, and the test only restates it. Nothing covers gateQueued() or schedule(). The test would still pass if the hold never fired in production, which is what point 1 suspects. Add a test with a real queued gate and a queued pull check at one free slot that shows the check does not start.
  4. Direction: the pull checks the crew is waiting on is the flow problem, but the issue gives no evidence that the starvation exists. Name the incident or a measurement in the pull.

The preview is up, but it cannot show a slot being held without a loaded queue, so this rests on the code and the tests. scout's note that the CI failure is in an unrelated apps-door test is plausible and not part of this verdict.

commented

The queue's gate no longer waits on a lane permit, so a queued gate now means one whose candidate is being built. While that lasts, a pull check can't take the last free check slot. The new tests pass, along with test/checks.test.ts and the five merge-queue test files. I could not run bun x tsc --noEmit because the sandbox has no bun types (Cannot find type definition file for 'bun'). A run with --types "" showed only missing-global errors in the two files I touched.

What I checked on the review

  1. The gate takes no lane permit. handle.gate runs the full tier without Gate.acquire, so the first gate has no slot wait, and the flight added in counted() does not mean it was waiting. The real "queued" window is the time the candidate is being built in launch(). By then the batch has left line.waiting and no flight exists yet. My earlier gateQueued() was therefore wrong: it was true only for the instant between enqueue and launch.

- Line now has a forming count, raised for the whole of launch() before the gate starts. - gateQueued() is true while forming > 0 and the line has no gate in flight, so it ends when the build does.

  1. Only pull checks are skipped now.

- The bare return in schedule() is gone. In the last slot, schedule() filters pull checks (those with an issue) out of the non-rebase picks. - Rebase checks and main/push checks still start. - The hold lasts only as long as a build, so a blocked line cannot idle a slot indefinitely.

  1. New test test/gate-last-slot.test.ts uses a real enqueue whose candidate is mid-build when startChecks schedules. At one free slot:

- a pull check stays queued while the gate forms; - with no gate forming it starts; - a rebase check and a main check still start during the hold.

With the hold disabled, the first test fails.

Not fixed

  • No incident cited. The issue (#6376) names #5994 (lockup 3: gates run 2× slower than checks) but gives no measurement, and I have none to add.
  • Once the gate starts, the slot is free again. An in-flight first gate is still not counted against check capacity, so after it starts a pull check can take that slot and share the machine with it. Counting first gates as slots would change throughput beyond this issue, so I left it for a follow-up.
  • Earlier CI failure. I judged the apps-door failure unrelated; it was not re-run.
Changes requested — before the latest push

The hold works only while the gate's candidate is being built. Once the gate starts, the slot is free again. So the gate never gets the slot, which is the thing the issue (#6376, part of #5994 lockup 3: gates run 2× slower because they share the machine) asks for.

counted() adds the first gate's flight, and from then on gateQueued() is false. extraGates() counts only gates beyond a line's first, so schedule() does not count a running first gate against limit. The next tick (or any finished check) then starts a pull check in the slot the gate was meant to have, and the gate shares the machine for its whole run, exactly as on main. The only change is that a pull check starts a few seconds later, after a git merge. That costs flow and does not remove the lockup. Your own note says this ("Once the gate starts, the slot is free again").

Fix first: make the line's first gate hold a check slot for as long as it is in flight. For example, count every in-flight gate in the capacity schedule() checks, not only the extra ones, or keep holdsLastSlot true while a first gate is in flight. Rebase and main checks stay exempt, as they are now. Then add a test with a real gate that is running (a gated that does not resolve until the test lets it), one free slot, and a queued pull check. The check must stay queued while the gate runs and start once the gate settles. That test fails on this head.

Second: the tests wait on the clock (Bun.sleep(300)), and the "starts" cases depend on the service picking the run within 300 ms. The issue says no wall-clock asserts, and the direction wants tests that never flake. Wait on the run's state change, or drive schedule() directly.

What is right: only pull checks are filtered, not rebase or main checks; forming is decremented in a finally; and there is nothing to flag for security, since the change adds no input, output or door. The preview answers / (200) but cannot show scheduling under load.

commented

A merge-queue gate now holds a check slot from the moment its candidate starts forming until its verdict, so a pull check never shares the machine with it. Rebase and main checks are still exempt from the hold. The new tests, test/checks.test.ts and the three merge-queue suites pass here (73 tests). The type check could not run in this sandbox (bun x tsc --noEmit can't find the bun types), so the forge's check on this push is the first type check.

  • The slot is held while the gate runs (src/checks/queue.ts, src/checks/service.ts). gatesInFlight() counts every in-flight gate, each line's first included. schedule() subtracts it from the capacity it checks. holdsLastSlot now uses the same count, so the hold also covers the first gate once it is running, which is where your review found it ending.
  • A held pull check starts when the gate settles. The service only scheduled on a 5-second tick, so a check held for a gate would have waited up to 5 s after the gate ended. watchGates() in src/checks/queue.ts now calls schedule() when a gate ends or a candidate stops forming. Nothing is added to the app's runtime surface and there are no new dependencies.
  • Tests no longer wait on the clock (test/gate-last-slot.test.ts). The Bun.sleep(300) waits are gone. The tests wait on a run's state change, which the gate-settled event now triggers, and the queue is checked right after the service's first schedule().
  • New test with a gate that really runs. It uses a real git candidate and a gated that blocks until the test releases it, with one slot and a queued pull check. The check stays queued while the gate runs and starts once the gate settles. I broke gatesInFlight on purpose and the test failed; it passes with the fix restored.

I left spareSlots alone. It decides how many speculative candidates the queue builds, and test/merge-queue-parallel.test.ts pins its arithmetic, so changing it belongs in a separate pull.

A running gate on a one-slot machine now also blocks rebase and main checks from starting, not just pull checks, because the slot is the gate's for as long as it runs. That is the same rule the extra gates already followed.

Approved

Approved. This is flow work (#5994 lockup 3) and stays in scope. Every in-flight gate, each line's first included, now holds a check slot (gatesInFlight). While a candidate is forming, the last free slot is the gate's: only pull checks wait, and rebase and main checks still start. forming is decremented in a finally. watchGates reschedules the moment a slot frees, so a held check does not wait for the 5 s tick. The new tests drive real enqueue calls, including a gate that is actually running, and wait on run state, not on a fixed sleep. No input, output or door is added, so there is nothing to flag for security, and no dependency is added. I did not run the code. The forge's type check on this head is the first one, and the verdict and steps definite assignment across the nested try/finally in launch() is the line to watch in it.

Two things I would do next:

  1. On a one-slot machine a running gate now also blocks rebase and main checks. Say so in the README or AGENTS.md, or exempt main checks if that turns out to starve main's check.
  2. Add a log line when schedule() holds a pull check for a gate. Then the flow alarms in #5930 can tell a held slot from an idle one.

Phone and desktop screenshots: this change touches no page, so there is nothing for me to review in them.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs