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 ----------------------------------------------------------------
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +86,16 @@ function makeFakeNg() {
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
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>`)) {
|
||||
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>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:username`) rec.username = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
|
||||
@@ -97,10 +97,16 @@ function makeFakeNg() {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
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>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
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:docPublic") rec.docPublic = q.o;
|
||||
|
||||
Reference in New Issue
Block a user