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 ----------------------------------------------------------------
// 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;
// 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. */
export function resetRegistryCache(): void {
cache = null;
accountCache.clear();
}
// --- SPARQL result helpers ------------------------------------------------
@@ -182,12 +189,16 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
for (const row of readBindings(result)) {
const username = bindingValue(row, "username");
if (!username) continue;
map.set(accountKey(username), {
const key = accountKey(username);
const record: AccountRecord = {
username,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
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) {
console.error("[storeRegistry] loadShim failed:", error);
@@ -196,6 +207,55 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
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). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
@@ -214,9 +274,10 @@ async function createDoc(): Promise<Nuri> {
* first sight. Idempotent — returns the existing record if already present.
*/
export async function ensureAccount(username: string): Promise<AccountRecord> {
const map = await loadShim();
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;
const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -248,7 +309,10 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
} catch (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;
}