Skip to content

[Bug]: Storage cleanup spends about a minute per sweep on Git status and size walks when nothing needs removing #17814

Closed

Description

@tris203

Before submitting

Area

apps/server

Steps to reproduce

  1. Have a few dozen thread worktrees, some of them with node_modules installed. 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).
  2. 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).
  3. Click Delete now, or wait for an automatic sweep, when nothing is due for removal.
  4. Sum the child spans of StorageCleanup.cleanWorktrees in server.trace.ndjson.

The causes, in apps/server/src/storageCleanup.ts on main:

  • git.statusDetailsLocal is used only for the branch name. Lines 418 to 420 read status.isRepo and status.branch and nothing else, but the call runs git status, a numstat diff and an ahead-count against the base branch. localChangesReason then runs its own git status straight after, and that second one is the real dirty check. The same heavy call is repeated before removal at line 530.
  • measureWorktree runs 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, realPath and stat for every file. A 50,000-file worktree took 15 to 20 s per sweep; a plain lstat walk 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 statusDetailsLocal calls with a branch-name read (git symbolic-ref --short HEAD or 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 measureWorktree to after the recheck, directly before git 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions