mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
feat(analyze): keep one parse cache per shared store (#3352)
Linked worktrees read and write the parse cache and durable ParsedFile store under the store's caches/ directory. Before pruning, a run folds in the chunk keys recorded by every member slot and commit graph, and the fold, prune and save run under a store-wide cache lock so one member never evicts another's live chunks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7681b1a55
commit
22ad5e312d
3 changed files with 194 additions and 12 deletions
|
|
@ -169,8 +169,10 @@ import { resolveSharedStore, type SharedStoreLayout } from '../storage/shared-st
|
|||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import {
|
||||
ensurePrivateSharedGraph,
|
||||
listStoreMetaRoots,
|
||||
publishSharedGraph,
|
||||
seedSharedSlot,
|
||||
withStoreLock,
|
||||
} from './shared-store-analyze.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
|
||||
import {
|
||||
|
|
@ -1324,6 +1326,9 @@ async function runFullAnalysisInner(
|
|||
// does not own the flat slot. See resolveWriteTarget for the full contract.
|
||||
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
|
||||
writeTarget;
|
||||
// Content-addressed caches live once per shared store (#3352), else in the
|
||||
// flat slot shared by its branch slots (#2106 KTD7).
|
||||
const cacheRoot = writeTarget.sharedStore?.cachesDir ?? storagePath;
|
||||
let storageWritable: Promise<void> | undefined;
|
||||
const ensureWritableStorage = (): Promise<void> => {
|
||||
storageWritable ??= ensureStoragePathWritable(storagePath);
|
||||
|
|
@ -2425,13 +2430,13 @@ async function runFullAnalysisInner(
|
|||
// after success. Unique because index locks are per branch slot while this
|
||||
// cache root is shared across branches.
|
||||
if (options.useParseCache === false) {
|
||||
coldParseRebuildDir = await createColdParseRebuildDir(storagePath);
|
||||
coldParseRebuildDir = await createColdParseRebuildDir(cacheRoot);
|
||||
forgetCreatedParseCacheDir(coldParseRebuildDir);
|
||||
}
|
||||
const parseCache =
|
||||
options.useParseCache === false
|
||||
? emptyParseCache(coldParseRebuildDir)
|
||||
: await loadParseCache(storagePath);
|
||||
: await loadParseCache(cacheRoot);
|
||||
|
||||
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
|
||||
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
|
||||
|
|
@ -4839,15 +4844,25 @@ async function runFullAnalysisInner(
|
|||
// so the cache file size stays bounded across runs (chunks whose
|
||||
// composition no longer matches anything in the current scan are dead
|
||||
// weight; the parse phase populates `usedKeys` as it processes chunks).
|
||||
try {
|
||||
const saveCaches = async (): Promise<void> => {
|
||||
// #2106 R6: the parse cache + durable store are shared across branches.
|
||||
// Before pruning to this run's keys, fold in the OTHER branches' recorded
|
||||
// chunk keys so a branch switch doesn't evict their still-live shards.
|
||||
// Adding to usedKeys makes them survive pruneCache AND land in the saved
|
||||
// index (saveParseCache builds the index from usedKeys). Excludes this
|
||||
// run's own meta dir, so a single-branch repo folds in nothing → prune
|
||||
// set byte-identical to today.
|
||||
const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
|
||||
// set byte-identical to today. A shared store (#3352) folds in every
|
||||
// member checkout and commit graph the same way.
|
||||
const keyRoots = writeTarget.sharedStore
|
||||
? await listStoreMetaRoots(writeTarget.sharedStore)
|
||||
: [storagePath];
|
||||
const siblingKeys = new Set<string>();
|
||||
let complete = true;
|
||||
for (const root of keyRoots) {
|
||||
const folded = await collectBranchCacheKeys(root, metaDir);
|
||||
for (const k of folded.keys) siblingKeys.add(k);
|
||||
if (!folded.complete) complete = false;
|
||||
}
|
||||
if (complete) {
|
||||
for (const k of siblingKeys) parseCache.usedKeys.add(k);
|
||||
} else {
|
||||
|
|
@ -4860,7 +4875,7 @@ async function runFullAnalysisInner(
|
|||
if (pruned > 0) {
|
||||
log(`Parse cache: pruned ${pruned} stale chunk entries`);
|
||||
}
|
||||
const savedKeys = await saveParseCache(storagePath, parseCache);
|
||||
const savedKeys = await saveParseCache(cacheRoot, parseCache);
|
||||
// Prune the durable ParsedFile store to EXACTLY the parse cache's
|
||||
// surviving keys (#2038 warm-cache coverage), so the two content-addressed
|
||||
// stores stay coherent: a chunk is "cached" iff both its parse-cache shard
|
||||
|
|
@ -4871,11 +4886,16 @@ async function runFullAnalysisInner(
|
|||
// durable-store write must never
|
||||
// break an otherwise successful run (next run treats it as a miss).
|
||||
await mergeStagedDurableParsedFileStore(
|
||||
storagePath,
|
||||
parseCache.storagePath ?? storagePath,
|
||||
cacheRoot,
|
||||
parseCache.storagePath ?? cacheRoot,
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
};
|
||||
try {
|
||||
await (writeTarget.sharedStore
|
||||
? withStoreLock(writeTarget.sharedStore, 'cache', saveCaches)
|
||||
: saveCaches());
|
||||
} catch (e) {
|
||||
log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,18 +250,42 @@ export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise<
|
|||
return true;
|
||||
};
|
||||
|
||||
const withPublishLock = async <T>(layout: SharedStoreLayout, fn: () => Promise<T>): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', 'publish');
|
||||
/**
|
||||
* Serialize one kind of store-wide write (`publish`, `cache`) across
|
||||
* checkouts. Each checkout's own slot is already covered by its index lock.
|
||||
*/
|
||||
export const withStoreLock = async <T>(
|
||||
layout: SharedStoreLayout,
|
||||
name: 'publish' | 'cache',
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', name);
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
const lock = await acquireIndexLock(lockDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store publish lock at ${lockDir}.`);
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
|
||||
return await fn();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Every directory in the store whose metadata may record parse-cache keys:
|
||||
* each checkout slot (its branch slots are read by the caller's per-root
|
||||
* fold) and each commit graph.
|
||||
*/
|
||||
export const listStoreMetaRoots = async (layout: SharedStoreLayout): Promise<string[]> => {
|
||||
const roots: string[] = [];
|
||||
for (const dir of [layout.checkoutsDir, layout.commitsDir]) {
|
||||
const names = await fs.readdir(dir).catch(() => [] as string[]);
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('.')) roots.push(path.join(dir, name));
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a successful analyze of the flat slot: publish or reuse the commit
|
||||
* graph when the checkout is clean at HEAD, and make sure the registry points
|
||||
|
|
@ -286,7 +310,7 @@ export const publishSharedGraph = async (
|
|||
if (shareable) {
|
||||
const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta));
|
||||
const targetGraph = path.join(target, LBUG_DIRECTORY);
|
||||
const published = await withPublishLock(layout, async () => {
|
||||
const published = await withStoreLock(layout, 'publish', async () => {
|
||||
if (await exists(targetGraph)) {
|
||||
await wipeLbugDbFiles(own);
|
||||
return true;
|
||||
|
|
|
|||
138
gitnexus/test/integration/shared-store-cache.test.ts
Normal file
138
gitnexus/test/integration/shared-store-cache.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { withStoreLock } from '../../src/core/shared-store-analyze.js';
|
||||
import { loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #3352 U5 — linked worktrees keep one parse cache and ParsedFile store per
|
||||
* shared store, and one member's prune never evicts chunks another member
|
||||
* still records.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const indexedCacheKeys = async (layout: SharedStoreLayout): Promise<string[]> => {
|
||||
const raw = await fs.readFile(path.join(layout.cachesDir, 'parse-cache', 'index.json'), 'utf-8');
|
||||
return (JSON.parse(raw) as { keys: string[] }).keys;
|
||||
};
|
||||
|
||||
describe('shared store caches (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-cache-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-cache-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
const main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
await fs.writeFile(path.join(main, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(main, 'init');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('keeps one cache tree in the store and none in any checkout', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await fs.writeFile(path.join(wtB, 'c.ts'), 'export function gamma() { return 3; }\n');
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
|
||||
const layout = layoutOf(wtA);
|
||||
expect(existsSync(path.join(layout.cachesDir, 'parse-cache'))).toBe(true);
|
||||
expect(existsSync(path.join(layout.cachesDir, 'parsedfile-cache'))).toBe(true);
|
||||
for (const checkout of [wtA, wtB]) {
|
||||
for (const dir of [layoutOf(checkout).checkoutSlot, path.join(checkout, '.gitnexus')]) {
|
||||
expect(existsSync(path.join(dir, 'parse-cache'))).toBe(false);
|
||||
expect(existsSync(path.join(dir, 'parsedfile-cache'))).toBe(false);
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
it("keeps chunks another member records when one member's file set changes", async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
const layout = layoutOf(wtA);
|
||||
const commitDir = (await fs.readdir(layout.commitsDir)).find((n) => !n.startsWith('.'));
|
||||
const keysA = (await loadMeta(path.join(layout.commitsDir, commitDir as string)))?.cacheKeys;
|
||||
expect(keysA?.length).toBeGreaterThan(0);
|
||||
|
||||
await fs.rm(path.join(wtB, 'b.ts'));
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
const keysB = (await loadMeta(layoutOf(wtB).checkoutSlot))?.cacheKeys;
|
||||
expect(keysB?.length).toBeGreaterThan(0);
|
||||
expect(keysB).not.toEqual(keysA);
|
||||
|
||||
const indexed = await indexedCacheKeys(layout);
|
||||
for (const key of [...(keysA ?? []), ...(keysB ?? [])]) expect(indexed).toContain(key);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe('withStoreLock', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-store-lock-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('runs same-named sections one at a time', async () => {
|
||||
const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout');
|
||||
const events: string[] = [];
|
||||
const section = (name: string) => async () => {
|
||||
events.push(`${name}:start`);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
events.push(`${name}:end`);
|
||||
};
|
||||
await Promise.all([
|
||||
withStoreLock(layout, 'cache', section('one')),
|
||||
withStoreLock(layout, 'cache', section('two')),
|
||||
]);
|
||||
expect(events[1]).toBe(`${events[0].split(':')[0]}:end`);
|
||||
expect(events[3]).toBe(`${events[2].split(':')[0]}:end`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue