perf(client): targeted shim account resolution (no full-scan on hot paths)

loadShim() read EVERY account record in the shim (SELECT over the whole anchor
graph). On a shim that accumulates accounts (the shared wallet grows), that is
O(accounts) and hangs the hot path (~90s at ensureAccount → loadShim). Same
principle as per-doc reads: never scan a shared structure.

Add resolveAccount(username): a BOUNDED SELECT anchored on the single subject
accountSubject(username) → O(1), independent of account count. Cache in a
per-account Map (cleared by resetRegistryCache). Hot paths now use it:
ensureAccount (existence check), indexInboxNuri/@index (discovery), resolveInbox
Anchor, resolveWriteGraph, createEntityDoc, listMyEntityDocs. loadShim kept only
for genuine all-accounts needs (allAccounts, the listEntityDocs fan-out fallback).

The 90s ensureAccount/loadShim hang is gone. 93 tests pass; tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 11:30:50 +02:00
parent 5717e08f6d
commit c85c635f63
3 changed files with 83 additions and 8 deletions
+70 -6
View File
@@ -134,12 +134,19 @@ async function anchorNuri(): Promise<Nuri> {
// --- cache ---------------------------------------------------------------- // --- cache ----------------------------------------------------------------
// In-memory cache of the shim, keyed by normalized username. // In-memory cache of the FULL shim (all accounts), keyed by account key. Set
// only once loadShim() has read every account — used by the all-accounts paths.
let cache: Map<string, AccountRecord> | null = null; let cache: Map<string, AccountRecord> | null = null;
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
// targeted resolve never forces a full shim scan. Both are cleared together.
const accountCache = new Map<string, AccountRecord>();
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void { export function resetRegistryCache(): void {
cache = null; cache = null;
accountCache.clear();
} }
// --- SPARQL result helpers ------------------------------------------------ // --- SPARQL result helpers ------------------------------------------------
@@ -182,12 +189,16 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
for (const row of readBindings(result)) { for (const row of readBindings(result)) {
const username = bindingValue(row, "username"); const username = bindingValue(row, "username");
if (!username) continue; if (!username) continue;
map.set(accountKey(username), { const key = accountKey(username);
const record: AccountRecord = {
username, username,
docPublic: bindingValue(row, "docPublic"), docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"), docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"), docPrivate: bindingValue(row, "docPrivate"),
}); };
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
} }
} catch (error) { } catch (error) {
console.error("[storeRegistry] loadShim failed:", error); console.error("[storeRegistry] loadShim failed:", error);
@@ -196,6 +207,55 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
return map; return map;
} }
/**
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
* the account record at its known subject (`accountSubject(username)`) directly,
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
* record or `null` if it does not exist yet.
*
* Cached per account (in `accountCache`); a hit skips the query entirely, so
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
*/
export async function resolveAccount(username: string): Promise<AccountRecord | null> {
const key = accountKey(username);
const cached = accountCache.get(key);
if (cached) return cached;
const s = await session();
const anchor = await anchorNuri();
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
const subj = accountSubject(username);
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.username}> ?username ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
const rows = readBindings(result);
if (rows.length === 0) return null;
const row = rows[0]!;
const record: AccountRecord = {
username: bindingValue(row, "username") || username,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
};
accountCache.set(key, record);
return record;
} catch (error) {
console.error("[storeRegistry] resolveAccount failed:", error);
return null;
}
}
/** All known accounts (from the shim). */ /** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> { export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()]; return [...(await loadShim()).values()];
@@ -214,9 +274,10 @@ async function createDoc(): Promise<Nuri> {
* first sight. Idempotent — returns the existing record if already present. * first sight. Idempotent — returns the existing record if already present.
*/ */
export async function ensureAccount(username: string): Promise<AccountRecord> { export async function ensureAccount(username: string): Promise<AccountRecord> {
const map = await loadShim();
const key = accountKey(username); const key = accountKey(username);
const existing = map.get(key); // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
const existing = await resolveAccount(username);
if (existing) return existing; if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([ const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -248,7 +309,10 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
} catch (error) { } catch (error) {
console.error("[storeRegistry] ensureAccount persist failed:", error); console.error("[storeRegistry] ensureAccount persist failed:", error);
} }
map.set(key, record); // Feed the per-account cache, and the full-shim cache if it is already loaded
// (so allAccounts / the fan-out see the freshly-created account too).
accountCache.set(key, record);
cache?.set(key, record);
return record; return record;
} }
+6 -1
View File
@@ -86,11 +86,16 @@ function makeFakeNg() {
const sparql_query = mock(async (...a: unknown[]) => { const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[3] as string | undefined; const anchor = a[3] as string | undefined;
// Shim account SELECT. // Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one
// subject — honour that subject filter so the bounded query is O(1)/exact.
if (query.includes(`<${SHIM}:username>`)) { if (query.includes(`<${SHIM}:username>`)) {
const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
if (q.g !== anchor) continue; if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {}; const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:username`) rec.username = q.o; if (q.p === `${SHIM}:username`) rec.username = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
+7 -1
View File
@@ -97,10 +97,16 @@ function makeFakeNg() {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[3] as string | undefined; const anchor = a[3] as string | undefined;
if (query.includes("<urn:ng-eventually:shim:username>")) { if (query.includes("<urn:ng-eventually:shim:username>")) {
// Account SELECT, scoped to the anchor graph. // Account SELECT, scoped to the anchor graph. Two shapes: the full scan
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) which binds one subject — honour that subject so the bounded
// query returns exactly that account (or nothing).
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
if (q.g !== anchor) continue; if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {}; const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o; if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;