Repository navigation
[Bug]: Storage cleanup spends about a minute per sweep on Git status and size walks when nothing needs removing #17814
Description
Before submitting
- I searched existing issues and did not find a duplicate. (Related: [Bug]: Worktree cleanup runs sweeps back to back and stalls the server on large databases #17479 and its fix fix(server): worktree cleanup no longer runs sweeps back to back #17480 cover how often sweeps run and the per-worktree snapshot re-read. This is about what one sweep costs when it has nothing to remove. [Bug]: Deleting a thread within 30 min of its last run leaves its provider session "ready" forever, so "Delete worktrees with deleted threads" never removes the worktree #16457 / fix(server): released provider sessions are marked stopped after their thread detaches #17481 cover the stale
readysessions mentioned below.) - I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Have a few dozen thread worktrees, some of them with
node_modulesinstalled. Turn on the worktree rules in Settings → Storage (this install has remove after 14 days, on merge, on delete, and unchanged, keeping worktrees with tracked changes). - Leave at least one otherwise-removable worktree with a provider session that is not
stopped(see [Bug]: Deleting a thread within 30 min of its last run leaves its provider session "ready" forever, so "Delete worktrees with deleted threads" never removes the worktree #16457 for how those accumulate). - Click Delete now, or wait for an automatic sweep, when nothing is due for removal.
- Sum the child spans of
StorageCleanup.cleanWorktreesinserver.trace.ndjson.
The causes, in apps/server/src/storageCleanup.ts on main:
git.statusDetailsLocalis used only for the branch name. Lines 418 to 420 readstatus.isRepoandstatus.branchand nothing else, but the call runsgit status, a numstat diff and an ahead-count against the base branch.localChangesReasonthen runs its owngit statusstraight after, and that second one is the real dirty check. The same heavy call is repeated before removal at line 530.measureWorktreeruns before the checks that most often cancel a removal. Line 483 walks the whole worktree, and only afterwards does the code re-read threads, check the outbox, and check for a non-stopped provider session (lines 486 to 529). A worktree kept as "Provider session is still open" is fully walked on every sweep and then kept.- The walk is slow per entry. It does
readLink,realPathandstatfor every file. A 50,000-file worktree took 15 to 20 s per sweep; a plainlstatwalk of the same tree takes 0.5 s. - All of this is sequential, and the worktree's lease is held throughout, including during the walk and the pull request lookup, so a turn starting in that worktree waits.
Expected behavior
A sweep that removes nothing costs little more than one cheap Git call and one dirty check per worktree. Checks that need no Git and no filesystem walk (open provider session, thread state, age) run before the ones that do, and a worktree is only measured once it is actually going to be removed.
Actual behavior
Fourteen consecutive sweeps did identical work and removed nothing, at 55 to 69 s each. One representative 54.5 s sweep over 78 worktrees that reached Git:
| Cost | Time | Share | Notes |
|---|---|---|---|
statusDetailsLocal |
21.4 s | 39% | 78 calls, avg 274 ms, max 3.5 s. Inside it: status 8.8 s, numstat 7.1 s, ahead-count 4.3 s |
measureWorktree on worktrees that were then kept |
~16 s | 29% | 3 worktrees, all with a ready session several days old; one accounts for 15 to 20 s |
localChangesReason |
6.9 s | 13% | 68 calls; the dirty check that is actually needed |
branchPullRequest with refresh: true |
4.2 s | 8% | 5 calls |
containsProjectRoot |
2.7 s | 5% | 81 calls |
fetchRemoteTrackingBranch |
1.3 s | 2% | 2 repositories |
| Remaining Git calls | ~2 s | 4% | resolveCommit, remote and default-branch lookups, merge-base |
About two thirds of the sweep is the first two rows. That is close to 900 Git processes per sweep to conclude that nothing changed.
Combined with #17479 this ran once per turn start. After #17480 it still applies to every hourly, settings-change and manual sweep.
Impact
Major degradation or frequent failure
Version or commit
T3 Code Nightly 0.0.46-nightly.20261010.2922. Code read at main @ f7664fd. Also checked the head of #17480 (eac6d35): statusDetailsLocal and the position of measureWorktree are unchanged there.
Environment
Linux desktop host server. 3.6 GB statev2.sqlite, 867 threads, about 160 worktrees across 7 projects, pnpm projects.
Logs or stack traces
Span order for one of the three repeatedly measured worktrees, offsets from sweep start:
+20.02s statusDetailsLocal dur=81ms
+20.11s StorageCleanup.localChangesReason dur=11ms
+20.12s resolveCommit dur=10ms
+20.16s StorageCleanup.integratedBranch dur=10ms
+20.17s branchPullRequest dur=977ms
(no span: measureWorktree, ~20.6 s)
+41.80s StorageCleanup.readThreads dur=72ms
+41.88s sqlite SELECT payload_json FROM orchestration_v2_projection_provider_sessions WHERE status != 'stopped'
+41.88s (next worktree starts: kept)
Workaround
None for the cost itself. Turning off Delete worktrees with deleted threads reduces how often it is paid until #17480 lands.
Suggested direction
- Replace the two
statusDetailsLocalcalls with a branch-name read (git symbolic-ref --short HEADor equivalent). - Load non-stopped provider sessions once per sweep and skip those worktrees before any Git call, keeping the existing recheck immediately before removal.
- Move
measureWorktreeto after the recheck, directly beforegit clean/git worktree remove.
Bounded concurrency looks unnecessary once those are done. If it is added later, the lease is per worktree path so parallel inspection is lease-safe, but the per-repository fetch memo and concurrent git worktree remove in one repository would need care.
Activity
perf(server): storage cleanup no longer runs a full status or size wa…