Files
orca/src/shared/repo-ref-maintenance.ts
T
Neil d7123591ce perf(git): pack the loose refs Orca's own fetches leave behind (#17857)
* perf(git): pack the loose refs Orca's own fetches leave behind

Orca strips git's auto-maintenance off every fetch it issues
(GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so
nothing in an Orca-driven checkout ever packs refs. One real machine
reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and
every worktree create pays for it.

Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the
fetches that create the debt. It runs only after ten minutes of quiet on
that repo, only above 1000 loose refs (probed with a walk bounded by that
threshold, not by the backlog), one at a time across the whole app, at
the background admission tier, and never while an agent is working, a
create is prepared or in flight, a worktree removal is deleting refs, the
app is quitting, or the machine is on battery. A user who set
`maintenance.auto=false` or `gc.auto=0` has opted out.

Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44):
`show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms.

Also fixes a pre-existing bug the split exposed: `--path-format=absolute`
is ignored before git 2.31, and taking rev-parse's stdout raw collapsed
every repo on such a host onto one fetch-serialization key.

Refs #17828

* perf(git): make idle ref maintenance preemptible and cheaper to probe

The idle veto was one-directional: it stopped a pack from starting during
a create, removal, or agent work, but nothing stopped those from starting
during a pack. A user-clicked Fetch, a branch delete, or a worktree
removal that needed `packed-refs.lock` mid-rewrite could fail with
`unable to create packed-refs.lock` -- a git error with no visible cause.

Make the pack cancellable end to end. An AbortSignal now reaches the
`pack-refs` child and both pre-pack probes, and `pause()` aborts what is
running, waits for it to actually stop, and holds a suspension count so
nothing new starts until the caller releases. Every entry point that
deletes a ref takes that pause: gitFetch, gitPull, gitFastForward,
removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout,
addWorktree. Five more triggers close the rest of the window: battery
drop, window focus, quit, the attempt deadline, and any other git command
queueing for an admission slot.

Judge a pack by re-probing the backlog rather than by the child's exit
code. Measured in the field: another Orca session moved a branch
mid-pack, git reported `cannot lock ref`, skipped that ref and packed the
rest -- 36,688 loose refs down to 3. On a machine running several
sessions that is the normal case, and retrying it would be wrong.

Probe with one batched `readdir` per directory instead of streaming
`opendir`, which issues a thread-pool round trip every 32 entries: 177ms
-> 23ms on a real 36,600-ref repository, with half the event-loop lag.
The walk stays strictly sequential so it can never occupy more than one
of libuv's four filesystem threads.

`PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and
only reclaims one when a marker exists, the lock is older than any
pack-refs could run for, and the recorded process is gone.

Refs #17828

* fix(git): wait out the packed-refs lock instead of killing the pack

Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all
--prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of
it. The other ~95% is the prune phase, during which a concurrent `fetch
--prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks
last microseconds and git retries for `core.filesRefLockTimeout`.

So the abort-on-everything design was strictly harmful. SIGTERM into the
prune loop strands an empty `refs/**/*.lock` about one time in five
(9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before
`activate_tempfile()` links it into the list the signal handler walks,
and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref
fails with `cannot lock ref ... File exists`, permanently. On Windows
`taskkill /f` never runs git's handlers at all, so an abort inside the
rewrite strands `packed-refs.lock` every time.

Never signal the child. `packRefs` no longer takes an abort signal; it
polls `packed-refs.lock` and reports the window through a
`PackedRefsLockReporter`. `pause()` resolves when the lock is released --
bounded, and free during the prune -- while the suspension counter still
blocks new attempts. Battery and window-focus become do-not-start rather
than stop-what-is-running, and quit waits for the lock and lets the child
finish orphaned.

For strands that already exist, `PackRefsLockOwnership` now also reclaims
`refs/**/*.lock` under the same three conditions plus a 0-byte check, and
a lock carrying our own not-yet-reclaimable marker records `locked` with
a 30min retry instead of the 6h failure cooldown -- so a Windows strand
self-heals in half an hour rather than six.

Reverts the git admission-scheduler event bus, which existed only to
drive the abort this removes.

Refs #17828

* test(git): make the ref-maintenance waits survive a loaded runner

CI shard 4/8 failed on `restarts every armed countdown when the user does
ref work themselves`, which passes locally. The `until()` helper spun a
fixed 200 event-loop turns and then returned silently, so on a contended
runner the filesystem probe had not finished and the assertion that
followed failed with an unrelated message.

Bound the wait by wall clock instead and throw a named error, which
immediately exposed a second latent bug: the single-flight test's second
wait could never succeed, because the deferred repo's retry is on a faked
`setTimeout` that spinning the real loop never advances. It had been
passing only because the old helper gave up quietly. Add a timer-aware
variant for those, and have the countdown test await a signal the fake
pack resolves rather than polling at all.

Verified stable across five sequential runs and once under load average
32 with six concurrent suites.

Refs #17828
2026-09-01 19:06:44 -07:00

367 lines
13 KiB
TypeScript

import { countLooseRefs } from './loose-ref-count'
import { PackedRefsLockGate } from './packed-refs-lock-gate'
import {
LOOSE_REF_PACK_THRESHOLD,
REF_MAINTENANCE_ATTEMPT_DEADLINE_MS,
REF_MAINTENANCE_CLEAN_COOLDOWN_MS,
REF_MAINTENANCE_FAILURE_COOLDOWN_MS,
REF_MAINTENANCE_PACKED_COOLDOWN_MS,
REF_MAINTENANCE_QUIET_PERIOD_MS,
PACKED_REFS_LOCK_WAIT_MS,
REF_MAINTENANCE_LOCKED_COOLDOWN_MS,
RefMaintenanceInterrupted,
RefMaintenanceRepoLocked,
type RefMaintenanceOutcome,
type RefMaintenanceSpan,
type RepoRefMaintenanceOptions,
type RepoRefMaintenanceTarget
} from './repo-ref-maintenance-policy'
/**
* The scheduler half of idle loose-ref packing: when to probe, when to pack,
* when to stand down. The thresholds and the host contract it works against
* live in `./repo-ref-maintenance-policy`.
*/
/** Give up until the next real activity rather than re-arming forever. */
const MAX_DEFERRALS = 6
/** Each deferral doubles the wait, so a busy app is retried rarely, not hammered. */
const MAX_DEFERRAL_BACKOFF_MULTIPLIER = 8
/** Armed repos are evicted oldest-first past this; the next write on one re-arms it. */
const MAX_TRACKED_REPOS = 64
type TrackedRepo = {
target: RepoRefMaintenanceTarget
timer: ReturnType<typeof setTimeout> | null
deferrals: number
}
const noopSpan: RefMaintenanceSpan = { setAttribute: () => {} }
/** A deadline means something is stuck: back off instead of retrying straight away. */
function hitDeadline(signal: AbortSignal): boolean {
return signal.reason instanceof RefMaintenanceInterrupted && signal.reason.deadline
}
export class RepoRefMaintenance {
private readonly tracked = new Map<string, TrackedRepo>()
private readonly cooldownUntil = new Map<string, number>()
private readonly now: () => number
private readonly isAppBusy: () => boolean
private readonly observe: NonNullable<RepoRefMaintenanceOptions['observe']>
private readonly quietPeriodMs: number
private readonly looseRefThreshold: number
private readonly onError: (error: unknown) => void
// Why: at most one pack-refs anywhere. It holds a general git admission slot
// for its whole run, and two at once would halve git throughput on a small host.
// The slot is never released while a pack that could hold `packed-refs.lock`
// is still running -- an interrupt cancels the work and waits for it to stop.
private inFlight: Promise<void> | null = null
private inFlightAbort: AbortController | null = null
private readonly lockGate = new PackedRefsLockGate()
// Why a count, not a flag: several ref-touching operations overlap routinely
// (a create's fetch inside a create), and the last one out reopens the window.
private suspensions = 0
private lastAttempt: Promise<void> = Promise.resolve()
private disposed = false
constructor(options: RepoRefMaintenanceOptions = {}) {
this.now = options.now ?? Date.now
this.isAppBusy = options.isBusy ?? (() => false)
this.observe = options.observe ?? ((attempt) => attempt(noopSpan))
this.quietPeriodMs = options.quietPeriodMs ?? REF_MAINTENANCE_QUIET_PERIOD_MS
this.looseRefThreshold = options.looseRefThreshold ?? LOOSE_REF_PACK_THRESHOLD
this.onError = options.onError ?? (() => {})
}
/**
* Record a write to `target`'s repo and (re)start its quiet-period countdown.
* Every call pushes the attempt further out, so a burst of fetches or a
* worktree create can never be interrupted by maintenance it triggered.
*/
arm(target: RepoRefMaintenanceTarget): void {
if (this.disposed) {
return
}
const existing = this.tracked.get(target.key)
if (existing?.timer) {
clearTimeout(existing.timer)
}
const tracked: TrackedRepo = { target, timer: null, deferrals: existing?.deferrals ?? 0 }
this.tracked.delete(target.key)
this.evictOldestBeyondCap()
this.tracked.set(target.key, tracked)
this.schedule(target.key, tracked)
}
/** Resolves once the attempt started by the most recent timer has settled. */
whenAttemptSettled(): Promise<void> {
return this.lastAttempt
}
/**
* Wait out the `packed-refs` rewrite, if one is in progress.
*
* Deliberately not a kill. The lock is held for 0.03-1.37s of a 23-32s pack;
* the rest is the prune phase, during which a concurrent `fetch --prune`,
* `branch -D` or `update-ref` measurably succeeds because per-ref locks last
* microseconds and Git retries for `core.filesRefLockTimeout`. Signalling the
* child there buys nothing and strands a lock file about one time in five.
*
* Free when no pack is running, which is almost always.
*/
awaitPackedRefsLockRelease(): Promise<void> {
return this.lockGate.whenReleased(PACKED_REFS_LOCK_WAIT_MS)
}
/**
* Push every armed repository's attempt out by a full quiet period.
*
* User-initiated ref work is evidence the user is active in the app, not just
* in one repo, and it is free -- no key to resolve, no subprocess, nothing at
* all when nothing is armed.
*/
postponeAll(): void {
if (this.disposed) {
return
}
for (const [key, tracked] of this.tracked) {
if (tracked.timer) {
clearTimeout(tracked.timer)
}
tracked.deferrals = 0
this.schedule(key, tracked)
}
}
/**
* Hold the repository open for work that is about to touch refs.
*
* Two things at once: no *new* attempt can start for any repository until the
* returned release is called, and the caller waits out any `packed-refs`
* rewrite already in progress. A prune already running is left alone to
* finish -- it does not block the caller.
*/
async pause(_reason: string): Promise<() => void> {
this.suspensions += 1
let released = false
try {
await this.awaitPackedRefsLockRelease()
} catch {
// The wait cannot reject, but a release must exist even if it did.
}
return () => {
if (!released) {
released = true
this.suspensions -= 1
}
}
}
dispose(): void {
this.disposed = true
this.inFlightAbort?.abort(new RefMaintenanceInterrupted('disposed'))
for (const tracked of this.tracked.values()) {
if (tracked.timer) {
clearTimeout(tracked.timer)
}
}
this.tracked.clear()
this.cooldownUntil.clear()
}
private isBusy(tracked: TrackedRepo): boolean {
return this.isAppBusy() || (tracked.target.isBusy?.() ?? false)
}
private schedule(key: string, tracked: TrackedRepo, delayMs = this.quietPeriodMs): void {
const timer = setTimeout(() => {
tracked.timer = null
this.lastAttempt = this.attempt(key).catch((error) => this.onError(error))
}, delayMs)
// Never hold the process open for maintenance.
timer.unref?.()
tracked.timer = timer
}
private evictOldestBeyondCap(): void {
while (this.tracked.size >= MAX_TRACKED_REPOS) {
const oldest = this.tracked.keys().next()
if (oldest.done) {
return
}
const evicted = this.tracked.get(oldest.value)
if (evicted?.timer) {
clearTimeout(evicted.timer)
}
this.tracked.delete(oldest.value)
}
}
/**
* `counted` spends the give-up budget. Waiting behind another repository's
* pack, or yielding to work Orca asked us to yield to, does not: both end on
* their own, so charging for them would let a busy machine starve a repo
* until its next fetch. Only "the app is busy" is charged.
*/
private defer(key: string, tracked: TrackedRepo, counted: boolean): void {
// A fetch that landed while this attempt was probing already re-armed the
// repo; that entry is fresher, so the deferral must not overwrite it.
if (this.disposed || this.tracked.has(key)) {
return
}
if (counted) {
if (tracked.deferrals >= MAX_DEFERRALS) {
return
}
tracked.deferrals += 1
}
this.tracked.set(key, tracked)
const multiplier = Math.min(2 ** tracked.deferrals, MAX_DEFERRAL_BACKOFF_MULTIPLIER)
this.schedule(key, tracked, this.quietPeriodMs * multiplier)
}
private async attempt(key: string): Promise<void> {
const tracked = this.tracked.get(key)
if (!tracked || this.disposed) {
return
}
this.tracked.delete(key)
const cooldownUntil = this.cooldownUntil.get(key)
if (cooldownUntil !== undefined && this.now() < cooldownUntil) {
return
}
if (this.inFlight !== null) {
this.defer(key, tracked, false)
return
}
if (this.suspensions > 0 || this.isBusy(tracked)) {
this.defer(key, tracked, true)
return
}
const abort = new AbortController()
const deadline = setTimeout(
() => abort.abort(new RefMaintenanceInterrupted('attempt deadline', true)),
REF_MAINTENANCE_ATTEMPT_DEADLINE_MS
)
deadline.unref?.()
const run = this.observe((span) => this.packIfNeeded(key, tracked, span, abort.signal))
this.inFlight = run
this.inFlightAbort = abort
try {
await run
} finally {
clearTimeout(deadline)
if (this.inFlight === run) {
this.inFlight = null
this.inFlightAbort = null
}
}
}
private async packIfNeeded(
key: string,
tracked: TrackedRepo,
span: RefMaintenanceSpan,
signal: AbortSignal
): Promise<void> {
span.setAttribute('repo.maintenance_key', key)
// Every await below carries the signal, so a caller waiting in `pause()` is
// never stuck behind a probe that has already been told to stop.
if (await tracked.target.isOptedOut?.(signal)) {
this.settle(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS)
return
}
if (signal.aborted) {
this.yieldTo(key, tracked, span, signal)
return
}
const refsDirectory = await tracked.target.resolveRefsDirectory(signal)
if (!refsDirectory) {
this.settle(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS)
return
}
const budget = this.looseRefThreshold + 1
const before = await countLooseRefs(refsDirectory, budget, signal)
if (signal.aborted) {
this.yieldTo(key, tracked, span, signal)
return
}
span.setAttribute('git.loose_ref_count', before.count)
span.setAttribute('git.loose_ref_threshold', this.looseRefThreshold)
// A saturated walk stopped early, so `count` is a floor -- never read it as "clean".
if (!before.saturated && before.count < this.looseRefThreshold) {
this.settle(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS)
return
}
// The quiet window can close while the probe walks; re-check before spending a git slot.
if (this.suspensions > 0 || this.isBusy(tracked)) {
span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome)
this.defer(key, tracked, true)
return
}
const startedAt = this.now()
let partial = false
try {
// No signal: the pack runs to completion. Callers that need the refs wait
// out the rewrite window through `pause()` instead of killing it.
await tracked.target.packRefs(this.lockGate)
} catch (error) {
span.setAttribute('repo.maintenance_error', String(error))
if (error instanceof RefMaintenanceRepoLocked) {
this.settle(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS)
return
}
partial = true
} finally {
this.lockGate.setHeld(false)
}
span.setAttribute('git.pack_refs_ms', this.now() - startedAt)
// Judge by the backlog, not by the exit code. On a machine running several
// Orca sessions a branch moving mid-pack is the normal case, and Git's
// response -- leave that one ref loose, pack the rest -- is the correct one.
// Measured in the field: 36,688 loose refs down to 3, reported as an error.
const after = await countLooseRefs(refsDirectory, budget, signal)
span.setAttribute('git.loose_ref_count_after', after.count)
if (partial && (after.saturated || after.count >= this.looseRefThreshold)) {
this.settle(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS)
return
}
span.setAttribute('git.pack_refs_partial', partial)
this.settle(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS)
}
/** Record an aborted attempt: retry soon if Orca yielded, back off if it stalled. */
private yieldTo(
key: string,
tracked: TrackedRepo,
span: RefMaintenanceSpan,
signal: AbortSignal
): void {
if (hitDeadline(signal)) {
this.settle(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS)
return
}
span.setAttribute('repo.maintenance_outcome', 'interrupted' satisfies RefMaintenanceOutcome)
this.defer(key, tracked, false)
}
private settle(
key: string,
span: RefMaintenanceSpan,
outcome: RefMaintenanceOutcome,
cooldownMs: number
): void {
span.setAttribute('repo.maintenance_outcome', outcome)
// Re-insert so Map order stays newest-last and the eviction below drops the oldest.
this.cooldownUntil.delete(key)
this.cooldownUntil.set(key, this.now() + cooldownMs)
if (this.cooldownUntil.size > MAX_TRACKED_REPOS * 4) {
const oldest = this.cooldownUntil.keys().next()
if (!oldest.done) {
this.cooldownUntil.delete(oldest.value)
}
}
}
}