fix(storage): give concurrent sibling clones one deterministic founder key (#3374)

Trigger: two registered clones of one origin, both still on local storage,
analyzing at the same time each saw no sibling store yet and founded a store
keyed on their own checkout path. registeredStore() then kept each clone in
its own store for good, so they never shared a graph.

Fix: when no sibling store exists, siblingCloneStore keys the new store on
the canonical path that sorts first among this clone and its registered
siblings (case-folded on Windows, like registryPathEquals), so every sibling
computes the same key. An existing sibling store still wins. Clones that
already diverged into two stores are not migrated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 08:08:36 +00:00
parent 22f34296db
commit a882494e36
2 changed files with 103 additions and 7 deletions

View file

@ -491,10 +491,14 @@ const registeredStore = (
/**
* Store for a clone with no store of its own: the store a registered sibling
* clone (same normalized `origin` URL, checkout still present) already uses,
* or, when siblings exist but none shares yet, a new store keyed on this
* clone. Graphs are keyed by commit and feature key, so clones only ever share
* a graph built from the same commit with the same settings. A lone clone
* keeps its repository-local index.
* or, when siblings exist but none shares yet, a new store keyed on the
* canonical path that sorts first among this clone and its siblings. Every
* sibling computes that same founder key, so clones founding the store
* concurrently still land in one store (#3374); the key is only a name, so the
* store keeps working if the founder's checkout is later deleted. Graphs are
* keyed by commit and feature key, so clones only ever share a graph built
* from the same commit with the same settings. A lone clone keeps its
* repository-local index.
*/
const siblingCloneStore = (
entries: readonly RegistryEntry[],
@ -515,7 +519,14 @@ const siblingCloneStore = (
.filter((root): root is string => root !== null)
.map((root) => path.basename(root))
.sort();
return sharedStoreLayout(keys[0] ?? cloneStoreKey(repoPath), repoPath);
if (keys[0]) return sharedStoreLayout(keys[0], repoPath);
// Order the way the registry compares paths: case-folded on Windows, whose
// slot names hash the folded form too.
const fold = (p: string): string => (registryPathEquals('A', 'a') ? p.toLowerCase() : p);
const founder = [self, ...siblings.map((e) => canonicalizePath(e.path))].reduce((a, b) =>
fold(b) < fold(a) ? b : a,
);
return sharedStoreLayout(cloneStoreKey(founder), repoPath);
};
/**

View file

@ -3,8 +3,19 @@ import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getStoragePaths, listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js';
import { resolveOptedInStore } from '../../src/core/shared-store-analyze.js';
import { getRemoteUrl } from '../../src/storage/git.js';
import {
getStoragePaths,
listRegisteredRepos,
registerRepo,
} from '../../src/storage/repo-manager.js';
import {
cloneStoreKey,
resolveSharedStore,
sharedStoreLayout,
type SharedStoreLayout,
} from '../../src/storage/shared-store.js';
import { createTempDir } from '../helpers/test-db.js';
// These suites exercise sharing; an inherited opt-out would silently disable it.
@ -225,3 +236,77 @@ describe('shared store clone sharing (#3352)', () => {
);
}, 240_000);
});
/**
* #3374 S7 — registered sibling clones that found a store at the same time
* must pick the same key, or each keeps its own store forever. Resolves the
* store directly (no analyze): only the registry and the clones' remotes matter.
*/
describe('shared store founder key for concurrent sibling clones (#3374)', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
let root: string;
/** Clone `source` as `name` and register it at local storage, or at `storeKey`'s slot. */
const cloneAndRegister = async (name: string, storeKey?: string): Promise<string> => {
const clone = path.join(root, name);
git(root, 'clone', '-q', path.join(root, 'source'), clone);
git(clone, 'remote', 'set-url', 'origin', REMOTE);
const storagePath = storeKey ? sharedStoreLayout(storeKey, clone).checkoutSlot : undefined;
await registerRepo(
clone,
{
repoPath: clone,
storagePath,
lastCommit: git(clone, 'rev-parse', 'HEAD'),
indexedAt: new Date(0).toISOString(),
remoteUrl: getRemoteUrl(clone),
},
storagePath ? { storagePath } : undefined,
);
return clone;
};
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-test-founder-home-');
tmpRepo = await createTempDir('gitnexus-test-founder-repo-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
root = await fs.realpath(tmpRepo.dbPath);
const source = path.join(root, 'source');
await fs.mkdir(source);
git(source, 'init', '-q', '-b', 'main');
await fs.writeFile(path.join(source, 'a.ts'), 'export const a = 1;\n');
git(source, 'add', '-A');
git(source, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpRepo.cleanup();
await tmpHome.cleanup();
});
it('two registered local clones resolve the same new store, keyed on the first path', async () => {
// Registered in reverse path order so registry order cannot pick the key.
const second = await cloneAndRegister('zeta');
const first = await cloneAndRegister('alpha');
const fromFirst = await resolveOptedInStore(first, undefined);
const fromSecond = await resolveOptedInStore(second, undefined);
expect(fromFirst?.key).toBe(cloneStoreKey(first));
expect(fromSecond?.key).toBe(cloneStoreKey(first));
});
it('a later clone joins the existing store rather than founding one', async () => {
const existing = cloneStoreKey(path.join(root, 'zz-founder'));
await cloneAndRegister('member', existing);
await cloneAndRegister('other');
const joiner = await cloneAndRegister('aaa-joiner');
expect((await resolveOptedInStore(joiner, undefined))?.key).toBe(existing);
});
});