Files
orca/src/shared/git-status-line-stats-cache.ts
T
Jinjing f1d2fe5c65 perf(git-status): duty-cycle, cancel, and cache status polling to cut idle git load (#8922)
* feat(git-status): batch, cancel, and cache git status polling to cut idl

- Add a single duty-cycled refresh scheduler (activity debounce + 60s
  safety timer) replacing multiple overlapping intervals, so status
  polling no longer runs near-continuously on large repos (#7983).
- Let safety refreshes reuse cached numstat line counts instead of
  re-running diff --numstat every cycle, invalidated by head change,
  known mutations, and a bounded TTL.
- Thread AbortSignal/request-token cancellation through IPC, RPC, and
  relay layers so a superseded or backgrounded git:status call is
  killed instead of finishing wastefully.
- Fix automatic upstream/status apply ordering so a slow, older
  refresh can no longer clobber a newer result, and so an earlier
  refresh still applies when a later one fails.

* Fix aborted git status scans being mistaken for completed empty results

- An aborted scan/numstat pass now always rejects instead of silently
  resolving, so a cancelled request can't look like a valid empty status.
- Stop clearing the line-stats cache key on abort, since an aborted pass
  never wrote to it — clearing was evicting a concurrent scan's healthy
  snapshot and forcing a redundant numstat recompute.

* Fix aborted git status scans resolving as completed results

Cancelled scans could still resolve with partial or stale data instead of
rejecting, letting callers treat an aborted refresh as a valid status. Also
stop counting aborted scan duration toward catch-up refresh pacing, which
was stretching the next refresh interval by the full length of a cancelled
(often slow) scan.

* Add cancellable, generation-aware git status polling to cut stale scans

- Route git.status through an abortable subscription per requestToken so
  cancelStatus can actually abort the remote scan instead of being a
  no-op, preventing wasted work and stale responses overwriting fresher
  state.
- Bump the git status polling generation on push-target changes so an
  in-flight refresh against the old remote/branch can't apply stale
  upstream data to the new one.
- Guard the stale-conflict poller against writes after unmount.
- Retire pre-purge line-stat scans in the cache so an older in-flight
  scan can't repopulate a key after a token-scoped purge.
2026-07-15 19:09:14 -07:00

257 lines
8.1 KiB
TypeScript

type GitStatusLineStatsEntry = {
path?: unknown
status?: unknown
area?: unknown
oldPath?: unknown
conflictKind?: unknown
conflictStatus?: unknown
conflictStatusSource?: unknown
submodule?: {
commitChanged?: boolean
trackedChanges?: boolean
untrackedChanges?: boolean
}
added?: number
removed?: number
}
type CachedLineStats = {
identity: string
storedAt: number
stats: { added?: number; removed?: number }[]
}
export type GitStatusLineStatsWriteToken = {
cacheKey: string
globalGeneration: number
keyGeneration: number
beginSeq: number
}
// Why: the TTL is the sole staleness backstop when file contents change while
// the porcelain identity stays "modified" (added/removed are excluded from the
// reuse identity), so a missed watcher signal pins counts for at most this long.
export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000
const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128
const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024
const lineStatsByWorktree = new Map<string, CachedLineStats>()
// Why: mutation invalidation must retire scans that began before it. A scan
// captures these generations at begin; a mismatch at store/clear time means an
// invalidation happened mid-scan and the derived stats may be pre-mutation.
let globalInvalidationGeneration = 0
const keyInvalidationGenerationByWorktree = new Map<string, number>()
// Why: overlapping recomputes must resolve latest-begun-wins without letting a
// reuse-only read (which never stores) starve an older recompute's store.
const lastStoredBeginSeqByWorktree = new Map<string, number>()
let nextBeginSeq = 0
// Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL.
const monotonicNowMs = (): number => performance.now()
function bumpBoundedKeyMap(map: Map<string, number>, cacheKey: string, value: number): void {
map.delete(cacheKey)
map.set(cacheKey, value)
while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) {
const oldestKey = map.keys().next().value
if (oldestKey === undefined) {
return
}
map.delete(oldestKey)
}
}
export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken {
return {
cacheKey,
globalGeneration: globalInvalidationGeneration,
keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0,
beginSeq: ++nextBeginSeq
}
}
function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean {
return (
token.globalGeneration === globalInvalidationGeneration &&
token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) &&
token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0)
)
}
function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string {
return JSON.stringify([
head ?? null,
entries.map((entry) => [
entry.path,
entry.status,
entry.area,
entry.oldPath ?? null,
entry.conflictKind ?? null,
entry.conflictStatus ?? null,
entry.conflictStatusSource ?? null,
entry.submodule?.commitChanged ?? null,
entry.submodule?.trackedChanges ?? null,
entry.submodule?.untrackedChanges ?? null
])
])
}
function trimLineStatsCache(now: number): void {
for (const [key, cached] of lineStatsByWorktree) {
if (now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS) {
lineStatsByWorktree.delete(key)
}
}
while (lineStatsByWorktree.size > GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES) {
const oldestKey = lineStatsByWorktree.keys().next().value
if (oldestKey === undefined) {
return
}
lineStatsByWorktree.delete(oldestKey)
}
}
export function applyCachedGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
now?: number
}): boolean {
const now = input.now ?? monotonicNowMs()
const cached = lineStatsByWorktree.get(input.cacheKey)
if (!cached) {
return false
}
if (
now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS ||
cached.identity !== createInputIdentity(input.head, input.entries)
) {
lineStatsByWorktree.delete(input.cacheKey)
return false
}
lineStatsByWorktree.delete(input.cacheKey)
lineStatsByWorktree.set(input.cacheKey, cached)
input.entries.forEach((entry, index) => {
const stats = cached.stats[index]
if (stats?.added !== undefined) {
entry.added = stats.added
}
if (stats?.removed !== undefined) {
entry.removed = stats.removed
}
})
return true
}
export function storeGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
now?: number
writeToken?: GitStatusLineStatsWriteToken
}): void {
const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey)
if (!isWriteTokenCurrent(writeToken)) {
return
}
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq)
const now = input.now ?? monotonicNowMs()
lineStatsByWorktree.delete(input.cacheKey)
lineStatsByWorktree.set(input.cacheKey, {
identity: createInputIdentity(input.head, input.entries),
storedAt: now,
stats: input.entries.map((entry) => ({
...(entry.added === undefined ? {} : { added: entry.added }),
...(entry.removed === undefined ? {} : { removed: entry.removed })
}))
})
trimLineStatsCache(now)
}
/**
* Shared post-status line-stat step for every host that executes Git. Reuses
* the cached snapshot only for hinted safety reads; otherwise recomputes and
* stores. `recompute` returns false when the counts are incomplete (e.g. a
* transient numstat failure) so a failed pass never replaces or pins a
* snapshot, and the previous good counts stay reusable.
*/
function createGitStatusLineStatsAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
export async function reuseOrRecomputeGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
writeToken: GitStatusLineStatsWriteToken
reuse: boolean
isAborted: () => boolean
recompute: () => Promise<boolean>
}): Promise<void> {
if (input.isAborted()) {
// Why: reject rather than resolve — a cancelled scan must not look like a
// completed status result (including a cache-hit reuse path).
throw createGitStatusLineStatsAbortError()
}
if (
input.reuse &&
applyCachedGitStatusLineStats({
cacheKey: input.cacheKey,
head: input.head,
entries: input.entries
})
) {
if (input.isAborted()) {
throw createGitStatusLineStatsAbortError()
}
return
}
const complete = await input.recompute()
if (input.isAborted()) {
// Why: an aborted pass never reached storeGitStatusLineStats, so there is
// nothing partial to undo; clearing here would instead evict a concurrent
// scan's healthy snapshot and force a redundant numstat on the next read.
// Reject so the caller cannot treat this pass as a successful status.
throw createGitStatusLineStatsAbortError()
}
if (!complete) {
return
}
storeGitStatusLineStats({
cacheKey: input.cacheKey,
head: input.head,
entries: input.entries,
writeToken: input.writeToken
})
}
export function clearGitStatusLineStatsCache(): void {
globalInvalidationGeneration += 1
lineStatsByWorktree.clear()
keyInvalidationGenerationByWorktree.clear()
lastStoredBeginSeqByWorktree.clear()
}
export function clearGitStatusLineStatsCacheKey(
cacheKey: string,
writeToken?: GitStatusLineStatsWriteToken
): void {
if (writeToken !== undefined && !isWriteTokenCurrent(writeToken)) {
return
}
if (writeToken === undefined) {
bumpBoundedKeyMap(
keyInvalidationGenerationByWorktree,
cacheKey,
(keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1
)
} else {
// Why: a token-scoped purge must retire scans that began before it, so an
// older in-flight scan can't store pre-purge counts and repopulate this key.
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq)
}
lineStatsByWorktree.delete(cacheKey)
}