fix(storage): fail safe on unreadable slot metadata during reclaim (#3374)

Trigger: reclaim counted commit-graph references with loadMeta, which
returns null for a torn or unreadable gitnexus.json as well as for a
missing one. A member whose metadata could not be read therefore
"referenced nothing", and the graph it still used was deleted. Separately,
in `clean --gc` an orphan slot that fs.rm could not delete threw out of
reclaim, aborting the collection of every store after it.

Fix: the reference loop reads slot metadata with a local loadMetaStrict.
Only absent metadata (ENOENT/ENOTDIR, same legacy-mirror fallback as
loadMeta) means no reference; any other read error or a parse failure
throws with the file path, like listDirStrict. Every caller already
treats a reclaim throw as best effort (analyze logs "skipped cleanup",
slot removal ignores it) or surfaces it (clean --gc). A failed orphan
slot delete is now kept: it stays a member, so the graph it names is
kept too, and it is reported in ReclaimResult.keptMembers and by a new
clean --gc line. loadMeta itself is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 07:42:11 +00:00
parent 62ffee3218
commit 7e9ca768e2
5 changed files with 136 additions and 6 deletions

View file

@ -200,6 +200,9 @@ const collectSharedStores = async (force: boolean): Promise<void> => {
graphs: result.removed.length,
}),
);
if (result.keptMembers.length > 0) {
console.log(t('clean.gc.keptMembers', { count: result.keptMembers.length }));
}
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
}

View file

@ -74,6 +74,8 @@ export const en = {
'Shared store: kept {{count}} unreferenced commit graph(s) that are still open; run `gitnexus clean --gc` later.',
'clean.shared.storeRemoved': 'Shared store: removed {{path}} (no checkouts remain).',
'clean.gc.none': 'No shared stores to collect.',
'clean.gc.keptMembers':
'Shared store: kept {{count}} checkout(s) it could not delete; run `gitnexus clean --gc --force` later.',
'clean.gc.store':
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
'clean.gc.preview':

View file

@ -70,6 +70,8 @@ export const zhCN = {
'共享存储:保留了 {{count}} 个仍处于打开状态的未引用提交图;请稍后运行 `gitnexus clean --gc`。',
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
'clean.gc.none': '没有可回收的共享存储。',
'clean.gc.keptMembers':
'共享存储:保留了 {{count}} 个无法删除的检出;请稍后运行 `gitnexus clean --gc --force`。',
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
'clean.gc.preview': '共享存储 {{path}}:将移除 {{members}} 个检出并删除 {{graphs}} 个提交图。',
'clean.localIndex.none': '此检出中没有残留的本地索引。',

View file

@ -19,13 +19,18 @@ import {
readRegistryStrictIfPresent,
registryPathEquals,
} from './repo-manager.js';
import { isMissingFilesystemError, loadMeta } from './repo-meta.js';
import { isMissingFilesystemError, loadMeta, type RepoMeta } from './repo-meta.js';
import {
SHARED_STORE_POINTER,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from './shared-store.js';
import { GITNEXUS_DIR, LBUG_DIRECTORY } from './storage-constants.js';
import {
GITNEXUS_DIR,
INDEX_METADATA_FILE,
LBUG_DIRECTORY,
LEGACY_METADATA_FILE,
} from './storage-constants.js';
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
@ -56,6 +61,8 @@ export interface ReclaimResult {
kept: string[];
/** Member slots dropped by garbage collection. */
droppedMembers: string[];
/** Orphaned member slots garbage collection could not delete; still counted as members. */
keptMembers: string[];
/** The store root was deleted because nothing remained. */
storeRemoved: boolean;
}
@ -73,6 +80,31 @@ const listDirStrict = (dir: string): Promise<string[]> =>
throw err;
});
/**
* Slot metadata for reclaim decisions, read like `loadMeta` (the legacy
* mirror only when `gitnexus.json` is absent). Only absent metadata means "no
* reference"; an unreadable or unparseable file aborts, because `loadMeta`'s
* null there would delete the commit graph the slot still points at.
*/
const loadMetaStrict = async (slot: string): Promise<RepoMeta | null> => {
for (const file of [INDEX_METADATA_FILE, LEGACY_METADATA_FILE]) {
const metaPath = path.join(slot, file);
let raw: string;
try {
raw = await fs.readFile(metaPath, 'utf-8');
} catch (err) {
if (isMissingFilesystemError(err)) continue;
throw new Error(`Cannot read ${metaPath}: ${(err as Error).message}`, { cause: err });
}
try {
return JSON.parse(raw) as RepoMeta;
} catch (err) {
throw new Error(`Cannot parse ${metaPath}: ${(err as Error).message}`, { cause: err });
}
}
return null;
};
/**
* Member slots that no registry entry uses any more: the checkout directory is
* gone, or its entry moved elsewhere (`--no-share`, sharing turned off). The
@ -116,7 +148,13 @@ export const reclaimSharedStoreLocked = async (
// Absolute, so commit dirs compare equal to the resolved graphPath parents
// even when GITNEXUS_HOME is relative.
const storeRoot = path.resolve(storeRootInput);
const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
const result: ReclaimResult = {
removed: [],
kept: [],
droppedMembers: [],
keptMembers: [],
storeRemoved: false,
};
const checkoutsDir = path.join(storeRoot, 'checkouts');
const commitsDir = path.join(storeRoot, 'commits');
const referenced = new Set<string>();
@ -140,7 +178,18 @@ export const reclaimSharedStoreLocked = async (
orphans.delete(slot);
continue;
}
if (!opts.dryRun) await fs.rm(slot, { recursive: true, force: true });
if (!opts.dryRun) {
try {
await fs.rm(slot, { recursive: true, force: true });
} catch {
// Like an undeletable graph below: keep it for the next collection
// rather than abort every store after this one. It stays a member,
// so whatever graph it still names is kept too.
orphans.delete(slot);
result.keptMembers.push(slot);
continue;
}
}
result.droppedMembers.push(slot);
} finally {
lock.release();
@ -149,7 +198,7 @@ export const reclaimSharedStoreLocked = async (
slots = slots.filter((slot) => !orphans.has(slot));
}
for (const slot of slots) {
const graphPath = (await loadMeta(slot))?.graphPath;
const graphPath = (await loadMetaStrict(slot))?.graphPath;
if (graphPath) referenced.add(path.dirname(path.resolve(graphPath)));
}
@ -201,7 +250,7 @@ export const reclaimSharedStore = async (
opts: { gc?: boolean; dryRun?: boolean } = {},
): Promise<ReclaimResult> => {
if (!existsSync(storeRoot)) {
return { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
return { removed: [], kept: [], droppedMembers: [], keptMembers: [], storeRemoved: false };
}
return withStoreLock({ root: storeRoot }, 'publish', () =>
reclaimSharedStoreLocked(storeRoot, opts),

View file

@ -490,6 +490,80 @@ describe('reclaimSharedStore', () => {
rm.mockRestore();
}
});
// A torn or unreadable gitnexus.json must not read as "references nothing":
// the graph it names would be deleted while the checkout still uses it.
it.each([
['invalid JSON', (file: string) => fs.writeFile(file, '{"graphPath": "/trunc')],
['an unreadable file', (file: string) => fs.mkdir(file)],
])('aborts instead of deleting graphs when a member has %s as metadata', async (_, corrupt) => {
const graph = await commitGraph('fffffff-6666666666666666');
const slot = path.join(layout().checkoutsDir, 'wt-000000000000');
await fs.mkdir(slot, { recursive: true });
await corrupt(path.join(slot, 'gitnexus.json'));
await member('wt-111111111111', {});
await expect(reclaimSharedStore(layout().root)).rejects.toThrow(/wt-000000000000/);
expect(existsSync(graph)).toBe(true);
});
it('counts a member with no metadata as referencing nothing', async () => {
const graph = await commitGraph('fffffff-6666666666666666');
const slot = path.join(layout().checkoutsDir, 'wt-000000000000');
await fs.mkdir(slot, { recursive: true });
const result = await reclaimSharedStore(layout().root);
expect(result.removed).toEqual([graph]);
expect(existsSync(graph)).toBe(false);
});
it('clean --gc keeps a member it cannot delete and still collects every store', async () => {
const gone = '/nonexistent/checkout';
const stuckGraph = await commitGraph('aaaaaaa-1111111111111111');
const unreferenced = await commitGraph('bbbbbbb-2222222222222222');
const stuck = await member('wt-000000000000', {
repoPath: gone,
graphPath: path.join(stuckGraph, 'lbug'),
});
const dropped = await member('wt-111111111111', { repoPath: gone });
const other = sharedStoreLayout('repo-fedcba987654', '/tmp/wt');
const otherGraph = path.join(other.commitsDir, 'ccccccc-3333333333333333');
await fs.mkdir(otherGraph, { recursive: true });
const otherSlot = path.join(other.checkoutsDir, 'wt-000000000000');
await fs.mkdir(otherSlot, { recursive: true });
await saveMeta(otherSlot, { lastCommit: '', indexedAt: '', repoPath: gone });
const realRm = fs.rm;
const rm = vi.spyOn(fs, 'rm').mockImplementation((async (
target: string,
...rest: unknown[]
) => {
if (String(target) === stuck) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
return (realRm as (...a: unknown[]) => Promise<unknown>)(target, ...rest);
}) as typeof fs.rm);
const { cleanCommand } = await import('../../src/cli/clean.js');
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
let lines: string[];
try {
await cleanCommand({ gc: true, force: true });
lines = log.mock.calls.map((c) => String(c[0]));
} finally {
log.mockRestore();
rm.mockRestore();
}
// The stuck member stays a member, so the graph it names stays live.
expect(existsSync(stuck)).toBe(true);
expect(existsSync(stuckGraph)).toBe(true);
expect(existsSync(dropped)).toBe(false);
expect(existsSync(unreferenced)).toBe(false);
// The store after it was still collected, down to its root.
expect(existsSync(other.root)).toBe(false);
expect(lines).toEqual(
expect.arrayContaining([expect.stringMatching(/kept 1 checkout\(s\) it could not delete/)]),
);
});
});
describe('private graph copies (#3352)', () => {