Public: anyone can read this; the crew can change it.
GET …/pulls answers in under 1 s with 50 open pulls right after main moves #8166
Check · Review · Land · Live done — Landed and live in production.
`GET …/pulls` now waits at most 300 ms (`MERGE_BUDGET_MS`) for merge readings it doesn't already have. Readings that miss the budget keep running in the background, so the next list has them. Before this, every landing moved main and made the next list re-read every open pull before it answered: 71 s with 47 pulls.
shipped production is serving it deploy f0961d3
GET …/pulls now waits at most 300 ms (MERGE_BUDGET_MS) for merge readings it doesn't already have. Readings that miss the budget keep running in the background, so the next list has them. Before this, every landing moved main and made the next list re-read every open pull before it answered: 71 s with 47 pulls.
Which answer a pull gives while its reading runs (pullMergeStates in src/core/pulls.ts, and the comment there):
- Only the base moved (same head): it gives the last reading the forge took for that head. How this head merged one landing ago is the best available guess.
- The head moved, or the pull was never read: it gives
null. No reading of those commits exists, so any answer would be made up.
Background readings:
- A queue shared by every list runs at most
READ_AT_ONCE(4) readings at a time. - An in-flight map keyed by
dir:head:baseallows one reading per pair; two lists asking at once share it. - A pair already in the reading cache answers at once and never waits behind slow readings.
mergeReadingsSettled()waits until no reading is queued or running. The tests use it to wait for background readings.pullMergeStatesaccepts{ budgetMs, check }so the tests can pass an infinite budget and a slow stand-in for git's merge check.
Risk of a stale mergeable: true: it can't cause a bad merge. mergePull calls pullStatus, which waits for a full reading of the current pair with no budget, and refuses when the pull is not mergeable. Only the list can be briefly stale.
Tests:
- New file
test/pulls-merge-budget.test.ts, with 50 pulls and a merge check that takes 200 ms longer than git's:
- After main moves, two lists sent at the same time each answer in under 1 s. Without this change one list takes about 2.6 s, so the test would fail.
- Every pair was read exactly once, never more than 4 at a time.
- The next list answers within 100 ms with fresh results, including the 10 pulls that now conflict on a.txt.
- A pull whose head moved and wasn't read within the budget answers null.
- Changed
test/pull-merge-states-cache.test.tsandtest/api-pulls-timing.test.ts: they count git processes, so they now wait formergeReadingsSettled()before counting. Their assertions are unchanged; the background work is what moved. bun scripts/gate.ts,bun x tsc --noEmitand the new and changed test files pass, plus 14 test files on pull lists, MCP pulls and merging (67 tests, all pass).scripts/fast.tsnamed 1472 files, too broad to run here, so the forge's full check covers the rest.
Server-Timing for GET /api/v1/repos/alice/r/pulls on a fixture with 50 open pulls and real git (no fake delay). The preview doesn't exist until this pull is opened.
cold merge;dur=115.0 (app;dur=132.3)
warm merge;dur=2.2 (app;dur=9.5)
main moved merge;dur=94.8 (app;dur=99.2)
after readings merge;dur=1.2 (app;dur=5.2)
Git is fast here, so all 50 readings finished inside the budget. The budget only cuts in when git is slow, as in production; the test with the slow check covers that case.
Not done: the list doesn't flag that an answer is a stale reading. That would change the API's response shape and its OpenAPI contract, and mergePull already protects landing.
🤖 Generated with Claude Code
GET …/pulls now waits at most MERGE_BUDGET_MS (300 ms) for merge readings it doesn't have. The readings it doesn't get in time keep running in the background, one per base/head pair and four at a time. Until a pair's reading lands, its pull answers with its last reading when its head hasn't moved. When the head has moved it answers null, not a guess. Before this, 47 open pulls after main moved cost merge;dur=71458.8.
The change is in src/core/pulls.ts. test/pulls-merge-budget.test.ts covers it with 50 pulls, a merge check slowed by 200 ms, and main moving so that 10 pulls conflict:
- Two lists asked at once both answer under 1 s, and neither has a blank.
- Once the background readings finish, each pair has been checked exactly once, with never more than four running at a time.
- The next list has fresh answers, the conflicting file is named, and it costs no further merge checks.
The check on the previous push failed for two reasons:
- My test broke the flake rules (fixed). It held the cached list to
performance.now() - t1 < 100ms, which fails "no tight wall-clock bound" and "no raw wall-clock budget" (#4429, #7824). I removed that stopwatch; the count that follows it,callsstaying at N, already proves the answer came from the cache. The test was wrong, not the code. - A snapshot-drill test failed that this branch doesn't touch. It is "a 400 MB snapshot's drill > a store that stalls at the 31st object…" in
test/drill-budget-size.test.ts. It bounds a simulated store's time to its budget plus 40 ms, and that slack looks like it gave out on a loaded shard. It passes here and I left it as it is.
The gate, bun x tsc --noEmit, and the tests for the merge budget, the merge-state cache, the pulls timing walk, the flake rules and the drill (40 tests) pass locally.
🤖 Generated with Claude Code
No security findings. The change adds no input, write path or output. The merge budget is bounded, and in-flight readings are deduped per dir:head:base, so a burst of lists cannot fork-bomb git. Stale reads are safe because mergePull re-reads the current pair. Failed readings are not cached, and late readings cannot mutate an answer already returned, since results are copied. Tests cover the slow-check case, one read per pair, the 4-at-once cap, the null answer for a moved head, and a closed pull. Minor, not blocking: the mergeReadingsSettled comment says "and shutdown", but only tests call it.
GET …/pullsnow waits at most 300 ms (MERGE_BUDGET_MS) for merge readings it doesn't already have. Readings that miss the budget keep running in the background, so the next list has them. Before this, every landing moved main and made the next list re-read every open pull before it answered: 71 s with 47 pulls.Which answer a pull gives while its reading runs (
pullMergeStatesinsrc/core/pulls.ts, and the comment there):null. No reading of those commits exists, so any answer would be made up.Background readings:
READ_AT_ONCE(4) readings at a time.dir:head:baseallows one reading per pair; two lists asking at once share it.mergeReadingsSettled()waits until no reading is queued or running. The tests use it to wait for background readings.pullMergeStatesaccepts{ budgetMs, check }so the tests can pass an infinite budget and a slow stand-in for git's merge check.Risk of a stale
mergeable: true: it can't cause a bad merge.mergePullcallspullStatus, which waits for a full reading of the current pair with no budget, and refuses when the pull is not mergeable. Only the list can be briefly stale.Tests:
test/pulls-merge-budget.test.ts, with 50 pulls and a merge check that takes 200 ms longer than git's:- After main moves, two lists sent at the same time each answer in under 1 s. Without this change one list takes about 2.6 s, so the test would fail. - Every pair was read exactly once, never more than 4 at a time. - The next list answers within 100 ms with fresh results, including the 10 pulls that now conflict on
a.txt. - A pull whose head moved and wasn't read within the budget answersnull.test/pull-merge-states-cache.test.tsandtest/api-pulls-timing.test.ts: they count git processes, so they now wait formergeReadingsSettled()before counting. Their assertions are unchanged; the background work is what moved.bun scripts/gate.ts,bun x tsc --noEmitand the new and changed test files pass, plus 14 test files on pull lists, MCP pulls and merging (67 tests, all pass).scripts/fast.tsnamed 1472 files, too broad to run here, so the forge's full check covers the rest.Server-Timing for
GET /api/v1/repos/alice/r/pullson a fixture with 50 open pulls and real git (no fake delay). The preview doesn't exist until this pull is opened.Git is fast here, so all 50 readings finished inside the budget. The budget only cuts in when git is slow, as in production; the test with the slow check covers that case.
Not done: the list doesn't flag that an answer is a stale reading. That would change the API's response shape and its OpenAPI contract, and
mergePullalready protects landing.🤖 Generated with Claude Code
Closes #8156