mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
22f34296db
commit
a882494e36
2 changed files with 103 additions and 7 deletions
|
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue