socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
352 lines • 14 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* In-memory `SymbolGraphCache` for a project. Backed by the sharded Qdrant
* store in `symbol-graph-store.ts`.
*
* Loading strategy:
* - `meta` — eager (tiny).
* - `nameIndex` — eager on first symbol-name query (all 27 shards).
* - `reverseFileIndex` — eager on first impact query (all 256 shards).
* - `fileDataLru` — lazy per-file payloads, LRU-bounded.
*
* Critical invariant: no query loads every symbol or every edge into memory.
*/
import { SYMBOL_FILE_LRU_SIZE, SYMBOL_REVERSE_SHARDS } from "../constants.js";
import { logger } from "./logger.js";
import { allNameShardKeys, LEGACY_SYMBOL_GRAPH_GENERATION, loadFilePayload, loadNameShard, loadReverseShard, loadSymbolGraphMeta, releaseReader, resetGenerationLifecycleState, retainReader, setActiveSymbolGraphGeneration, } from "./symbol-graph-store.js";
// ── Tiny LRU (handwritten, ~20 lines) ────────────────────────────────────
export class LRUCache {
capacity;
map = new Map();
constructor(capacity) {
this.capacity = capacity;
}
get(key) {
const v = this.map.get(key);
if (v === undefined)
return undefined;
this.map.delete(key);
this.map.set(key, v);
return v;
}
set(key, value) {
if (this.map.has(key))
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined)
this.map.delete(oldest);
}
}
delete(key) {
return this.map.delete(key);
}
has(key) {
return this.map.has(key);
}
clear() {
this.map.clear();
}
get size() {
return this.map.size;
}
keys() {
return this.map.keys();
}
}
export class SymbolGraphCache {
projectId;
meta;
/** name → list of symbol refs (lazy-loaded as a whole) */
nameIndex = null;
/** calleeSymbolId → set of caller symbol IDs (lazy-loaded as a whole) */
reverseSymbolIndex = null;
/** calleeFile → set of caller files (derived from reverseSymbolIndex) */
reverseFileIndex = null;
/** lazy per-file payloads, LRU-bounded */
fileDataLru;
stats = {
fileLruSize: 0,
fileLruHits: 0,
fileLruMisses: 0,
nameIndexLoaded: false,
reverseIndexLoaded: false,
};
activeReaders = 0;
activeReaderTokens = new Map();
constructor(projectId, meta, lruCapacity = SYMBOL_FILE_LRU_SIZE) {
this.projectId = projectId;
this.meta = meta;
this.fileDataLru = new LRUCache(lruCapacity);
}
/**
* Acquire a reader lease for this cache instance.
* While any reader lease is held, the generation backing this cache cannot be cleaned up.
*/
acquireReader(operationToken) {
const generation = this.meta.generation ?? LEGACY_SYMBOL_GRAPH_GENERATION;
const continuesOwnedOperation = operationToken !== undefined
&& this.activeReaderTokens.has(operationToken);
const token = continuesOwnedOperation ? operationToken : Symbol("symbol-graph-reader");
// Once another generation is active, only nested reads carrying the token
// of an operation already holding this cache may continue. An unrelated
// request cannot borrow another request's lease merely because the cache
// has active readers.
retainReader(this.projectId, generation, continuesOwnedOperation);
this.activeReaders++;
this.activeReaderTokens.set(token, (this.activeReaderTokens.get(token) ?? 0) + 1);
let released = false;
const release = (() => {
if (released)
return;
released = true;
this.activeReaders--;
const tokenCount = (this.activeReaderTokens.get(token) ?? 1) - 1;
if (tokenCount <= 0)
this.activeReaderTokens.delete(token);
else
this.activeReaderTokens.set(token, tokenCount);
releaseReader(this.projectId, generation);
});
Object.defineProperty(release, "token", { value: token });
return release;
}
get activeReaderCount() {
return this.activeReaders;
}
/** Get the full name index, loading all shards on first access. */
async getNameIndex(operationToken) {
const release = this.acquireReader(operationToken);
try {
if (this.nameIndex)
return this.nameIndex;
const merged = new Map();
const shardKeys = allNameShardKeys();
const shards = await Promise.all(shardKeys.map((k) => loadNameShard(this.projectId, k, this.meta.generation ?? LEGACY_SYMBOL_GRAPH_GENERATION)));
for (const shard of shards) {
if (!shard)
continue;
for (const [name, refs] of Object.entries(shard)) {
const existing = merged.get(name);
if (existing) {
existing.push(...refs);
}
else {
merged.set(name, [...refs]);
}
}
}
this.nameIndex = merged;
this.stats.nameIndexLoaded = true;
return merged;
}
finally {
release();
}
}
/** Get the full reverse symbol index (calleeSymbolId -> Set<callerSymbolId>), loading all shards on first access. */
async getReverseSymbolIndex(operationToken) {
const release = this.acquireReader(operationToken);
try {
if (this.reverseSymbolIndex)
return this.reverseSymbolIndex;
const merged = new Map();
const buckets = [];
for (let i = 0; i < SYMBOL_REVERSE_SHARDS; i++)
buckets.push(i);
const shards = await Promise.all(buckets.map((b) => loadReverseShard(this.projectId, b, this.meta.generation ?? LEGACY_SYMBOL_GRAPH_GENERATION)));
for (const shard of shards) {
if (!shard)
continue;
for (const [calleeKey, callerList] of Object.entries(shard)) {
const existing = merged.get(calleeKey);
if (existing) {
for (const f of callerList)
existing.add(f);
}
else {
merged.set(calleeKey, new Set(callerList));
}
}
}
this.reverseSymbolIndex = merged;
this.stats.reverseIndexLoaded = true;
return merged;
}
finally {
release();
}
}
/** Get the full reverse-call file index, derived from reverseSymbolIndex. */
async getReverseFileIndex(operationToken) {
const release = this.acquireReader(operationToken);
try {
if (this.reverseFileIndex)
return this.reverseFileIndex;
const symIndex = await this.getReverseSymbolIndex(release.token);
const fileIndex = new Map();
for (const [calleeKey, callerList] of symIndex.entries()) {
const calleeFile = calleeKey.includes("::") ? calleeKey.split("::")[0] : calleeKey;
let callerSet = fileIndex.get(calleeFile);
if (!callerSet) {
callerSet = new Set();
fileIndex.set(calleeFile, callerSet);
}
for (const callerId of callerList) {
const callerFile = callerId.includes("::") ? callerId.split("::")[0] : callerId;
if (callerFile !== calleeFile) {
callerSet.add(callerFile);
}
}
}
this.reverseFileIndex = fileIndex;
return fileIndex;
}
finally {
release();
}
}
/** Get a per-file payload, hitting the LRU first then Qdrant. */
async getFilePayload(relativePath, operationToken) {
const release = this.acquireReader(operationToken);
try {
const cached = this.fileDataLru.get(relativePath);
if (cached) {
this.stats.fileLruHits++;
return cached;
}
this.stats.fileLruMisses++;
const payload = await loadFilePayload(this.projectId, relativePath, this.meta.generation ?? LEGACY_SYMBOL_GRAPH_GENERATION);
if (payload)
this.fileDataLru.set(relativePath, payload);
this.stats.fileLruSize = this.fileDataLru.size;
return payload;
}
finally {
release();
}
}
/** Invalidate cached state for a file (called by watcher on file changes). */
invalidateFile(relativePath) {
this.fileDataLru.delete(relativePath);
}
/** Patch the in-memory name index for an updated file payload. */
patchNameIndexForFile(oldPayload, newPayload) {
if (!this.nameIndex)
return;
if (oldPayload) {
for (const sym of oldPayload.symbols) {
const refs = this.nameIndex.get(sym.name);
if (!refs)
continue;
const filtered = refs.filter((r) => r.id !== sym.id);
if (filtered.length === 0)
this.nameIndex.delete(sym.name);
else
this.nameIndex.set(sym.name, filtered);
}
}
for (const sym of newPayload.symbols) {
const ref = { file: sym.file, id: sym.id };
const refs = this.nameIndex.get(sym.name);
if (refs)
refs.push(ref);
else
this.nameIndex.set(sym.name, [ref]);
}
}
/** Patch the in-memory reverse-symbol and reverse-file indices for an updated file payload. */
patchReverseFileIndexForFile(oldPayload, newPayload) {
if (this.reverseSymbolIndex) {
if (oldPayload) {
for (const e of oldPayload.outgoingCalls) {
for (const calleeId of e.calleeCandidates) {
const callers = this.reverseSymbolIndex.get(calleeId);
if (!callers)
continue;
callers.delete(e.callerId);
if (callers.size === 0)
this.reverseSymbolIndex.delete(calleeId);
}
}
}
for (const e of newPayload.outgoingCalls) {
for (const calleeId of e.calleeCandidates) {
const callers = this.reverseSymbolIndex.get(calleeId);
if (callers)
callers.add(e.callerId);
else
this.reverseSymbolIndex.set(calleeId, new Set([e.callerId]));
}
}
}
this.reverseFileIndex = null; // Re-derive lazily
}
/** Replace the cached payload for a file (used after rebuild of one file). */
setFilePayload(payload) {
this.fileDataLru.set(payload.file, payload);
this.stats.fileLruSize = this.fileDataLru.size;
}
getStats() {
return { ...this.stats, fileLruSize: this.fileDataLru.size };
}
}
/** Extract the file portion from a SymbolNode.id (`file::qname#line`). */
export function symbolIdToFile(id) {
const idx = id.indexOf("::");
return idx > 0 ? id.slice(0, idx) : null;
}
// ── Cache registry per project ───────────────────────────────────────────
const cacheRegistry = new Map();
const cacheLoadPromises = new Map();
/** Get or build the cache for a project (loads meta from Qdrant lazily). */
export async function getSymbolGraphCache(projectId) {
const cached = cacheRegistry.get(projectId);
if (cached)
return cached;
const inFlight = cacheLoadPromises.get(projectId);
if (inFlight)
return inFlight;
const promise = (async () => {
const meta = await loadSymbolGraphMeta(projectId);
if (!meta)
return null;
const cache = new SymbolGraphCache(projectId, meta);
setActiveSymbolGraphGeneration(projectId, meta.generation);
cacheRegistry.set(projectId, cache);
return cache;
})();
cacheLoadPromises.set(projectId, promise);
try {
return await promise;
}
finally {
cacheLoadPromises.delete(projectId);
}
}
/** Replace (or insert) the cache for a project — used after a fresh rebuild. */
export function setSymbolGraphCache(cache) {
setActiveSymbolGraphGeneration(cache.projectId, cache.meta.generation);
cacheRegistry.set(cache.projectId, cache);
}
/** Remove a cached generation only if it is still the registry entry. */
export function dropSymbolGraphCacheGeneration(projectId, generation) {
const cached = cacheRegistry.get(projectId);
if (cached
&& (cached.meta.generation ?? LEGACY_SYMBOL_GRAPH_GENERATION) === generation) {
cacheRegistry.delete(projectId);
}
}
/** Remove a project's cache from the registry. */
export function dropSymbolGraphCache(projectId) {
cacheRegistry.delete(projectId);
}
/** Reset all caches (testing only). */
export function resetSymbolGraphCacheRegistry() {
cacheRegistry.clear();
resetGenerationLifecycleState();
logger.debug("Symbol graph cache registry cleared");
}
//# sourceMappingURL=symbol-graph-cache.js.map