fix(client): dé-dupliquer les ensureAccount concurrents au cold-start (fork résolu)
Dernière couche du bug de reconnexion : au cold-start, `watchShape` public + protected + l'effet owned-events appellent `ensureAccount(A)` quasi-simultanément AVANT la sync du shim → chacun lit 0 → chacun provisionne un nouveau jeu de docs (fork par-appelant) → la résolution déterministe canonique fait alors diverger lecteur et écrivain sur le docProtected → `readScopeIndex` vide. Fix : `ensureInFlight` (map de promesses) dé-duplique les provisions concurrentes en UNE seule ; `discovery.readIndex` ouvre son repo au cold-start (`ensureRepoOpen`). Avec la résolution canonique déjà committée, écrivain et lecteur convergent. Mesuré (levier isSuccess) : la participation protected converge `isSuccess=true, data=1` sur la page fraîche (plus « vide à 30s »). gate : tsc 0 ; bun test 123 ; test:e2e 42/42. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -381,56 +381,101 @@ async function createDoc(): Promise<Nuri> {
|
||||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT
|
||||
* `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision.
|
||||
*
|
||||
* ── Why this is load-bearing (the reconnection FORK root cause) ─────────────
|
||||
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
|
||||
* `ensureAccount(A)` near-simultaneously: `watchShape('public')` and
|
||||
* `watchShape('protected')` each resolve their doc set (`listMyEntityDocs` →
|
||||
* `ensureAccount`) and subscribe their container (`scopeIndexDoc` →
|
||||
* `ensureAccount`), plus the app's owned-events effect. Each runs BEFORE the shim
|
||||
* has synced, so each `resolveAccountReliably` reads 0 rows and, independently,
|
||||
* PROVISIONS a brand-new set of scope docs — an account FORK PER caller. Measured:
|
||||
* a single new account accrued EIGHT distinct `docPublic`/`docProtected` values in
|
||||
* one session. `canonicalDoc` then makes a fresh reader pick the lexicographic-min
|
||||
* doc, which differs from the (cached, freshly-created) doc the WRITER wrote into →
|
||||
* the reader lists an empty scope index → the home/participation reads come back
|
||||
* empty (the reconnection bug's last layer).
|
||||
*
|
||||
* De-duping concurrent provisions collapses those N racing provisions into ONE:
|
||||
* the first caller resolves-or-provisions; every concurrent caller awaits the SAME
|
||||
* promise and gets the SAME record, so no duplicate docs are ever minted and writer
|
||||
* and reader converge on one canonical scope doc. Not polling: a bounded in-memory
|
||||
* promise map (mirrors open-repo.ts `inFlight`), cleared the instant it settles.
|
||||
*/
|
||||
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
|
||||
|
||||
/**
|
||||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||||
* first sight. Idempotent — returns the existing record if already present.
|
||||
* Concurrency-safe: concurrent calls for the same account share one provision
|
||||
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
|
||||
*/
|
||||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
const key = accountKey(id);
|
||||
// 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.
|
||||
//
|
||||
// ANTI-FORK: resolve via the BOUNDED retry, so a transient 0 rows (the shim not
|
||||
// yet synced on a fresh page over the persistent wallet) is not mistaken for
|
||||
// "account genuinely absent" → a fork. Only after the whole retry budget still
|
||||
// reads 0 do we treat the account as new and provision. See resolveAccountReliably.
|
||||
const existing = await resolveAccountReliably(id);
|
||||
if (existing) return existing;
|
||||
// A completed provision/resolve is cached → no query, no fork risk.
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
// A concurrent provision for the SAME account is already running → await it,
|
||||
// instead of racing a second (forking) provision. This is the anti-fork guard.
|
||||
const pending = ensureInFlight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||
const p = (async (): Promise<AccountRecord> => {
|
||||
// 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.
|
||||
//
|
||||
// ANTI-FORK: resolve via the BOUNDED retry, so a transient 0 rows (the shim not
|
||||
// yet synced on a fresh page over the persistent wallet) is not mistaken for
|
||||
// "account genuinely absent" → a fork. Only after the whole retry budget still
|
||||
// reads 0 do we treat the account as new and provision. See resolveAccountReliably.
|
||||
const existing = await resolveAccountReliably(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const subj = accountSubject(id);
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
||||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
||||
// literals here, so they are escaped as literals too (defence in depth).
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> "${escapeLiteral(id)}" ;
|
||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
||||
}
|
||||
}`;
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||
|
||||
const s = await session();
|
||||
const anchor = await anchorNuri();
|
||||
const subj = accountSubject(id);
|
||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
||||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
||||
// literals here, so they are escaped as literals too (defence in depth).
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${assertNuri(anchor)}> {
|
||||
<${subj}> a <${P.type}> ;
|
||||
<${P.id}> "${escapeLiteral(id)}" ;
|
||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
||||
}
|
||||
// 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;
|
||||
})();
|
||||
|
||||
ensureInFlight.set(key, p);
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
||||
} catch (error) {
|
||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
||||
return await p;
|
||||
} finally {
|
||||
ensureInFlight.delete(key);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// --- resolvers ------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user