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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user