fix: ne pas re-provisionner un compte sur un read 0-lignes dû au lag de sync
Cause racine mesurée (broker réel, access-log) : à la première resolveAccount d'une session, le record de compte tout juste persisté (ou d'une session antérieure) peut lire 0 lignes à cause du LAG DE SYNC broker. ensureAccount interprétait ce 0 comme « compte inexistant » et RE-PROVISIONNAIT un second jeu de docs de scope (docPublic/docProtected forkés) → les lectures d'une session tombaient sur un jeu, celles d'une autre (ou après drop de cache) sur l'autre jeu vide → données « perdues » à la reconnexion. Fix : `resolveAccountReliably` (store-registry.ts) — retry borné (ouverture du repo d'ancre shim + backoff plafonné, défaut 8 tentatives / ≲8.5s) AVANT que ensureAccount ne décide qu'un compte est neuf. Provisionne seulement si, après le budget, la lecture rend toujours 0 (compte réellement neuf). Budget injecté via StoreRegistryDeps.provisionRetry (polyfill.ts), ON en prod ; tests unitaires à provisionRetry synchrone (attempts:1, fake sans lag). Idempotence de session préservée par accountCache (hit court-circuite, déterministe). Portée : corrige le déterminisme de provisioning. NE suffit PAS à réparer la reconnexion (le read public à 0 same-session subsiste, cause distincte encore à mesurer de façon déterministe — le lag broker rend les mesures non-reproductibles). Complémentaire du commit open-repo précédent, pas redondant. gate : tsc --noEmit propre ; bun test 91 pass ; auth @data (vide/distinctes) verts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,20 @@ export interface StoreRegistryDeps {
|
||||
getSession: () => Promise<RegistrySession>;
|
||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* ANTI-FORK budget for account resolution (polyfill-era). Before provisioning
|
||||
* a "missing" account, the registry re-reads its shim record a bounded number
|
||||
* of times with backoff, so a record merely lagging by broker sync (fresh
|
||||
* session over a persistent wallet) is FOUND and REUSED instead of triggering
|
||||
* a second set of scope docs (the account fork). See the note in
|
||||
* store-registry.ts. Only if every attempt still reads 0 do we provision.
|
||||
*
|
||||
* Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1`
|
||||
* to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory
|
||||
* store (the unit fake `ng`) where a 0-row read is authoritative and the
|
||||
* backoff would only add dead time; there is no sync lag to wait out there.
|
||||
*/
|
||||
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
|
||||
export interface EventuallyConfig {
|
||||
@@ -84,6 +98,13 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
registryDeps = {
|
||||
getSession: deps.getSession,
|
||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||
// Production default: a real anti-fork budget. Consumers over a synchronous
|
||||
// store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps).
|
||||
provisionRetry: {
|
||||
attempts: deps.provisionRetry?.attempts ?? 8,
|
||||
baseMs: deps.provisionRetry?.baseMs ?? 300,
|
||||
maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getStoreRegistryDeps } from "./polyfill";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
|
||||
// --- provisioning anti-fork retry budget (polyfill-era) --------------------
|
||||
//
|
||||
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
|
||||
// On a fresh session over a persistent wallet, that store's repo may not have
|
||||
// synced yet from the broker: a targeted account resolve then reads 0 rows even
|
||||
// though the account WAS persisted in an earlier session. If ensureAccount took
|
||||
// that 0 at face value it would RE-PROVISION a second set of scope documents —
|
||||
// an account FORK: one session writes/reads one set, another (or the same after
|
||||
// a cache drop) the other, EMPTY set → the user "loses" their data on reconnect.
|
||||
//
|
||||
// Guard: before deciding an account is genuinely new, re-read it with a BOUNDED
|
||||
// backoff (open + await the anchor repo, then retry the targeted query). Only if
|
||||
// EVERY attempt within the budget still reads 0 do we treat it as a first-time
|
||||
// account and provision. A pre-existing account merely lagging by sync is thus
|
||||
// found on a later attempt and REUSES its docs (no fork); a truly new account
|
||||
// exhausts the budget cheaply and is provisioned exactly once. The budget is a
|
||||
// hard ceiling — never an unbounded poll — and is INJECTED (StoreRegistryDeps.
|
||||
// provisionRetry): production defaults to ~8 attempts / ≲8.5s; a synchronous
|
||||
// in-memory store (unit fake `ng`) passes `attempts: 1` to skip it (a 0-row read
|
||||
// is authoritative there — no lag to wait out). Disappears at the real
|
||||
// multi-store migration (deterministic per-user store NURIs make this moot).
|
||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
|
||||
@@ -257,6 +279,51 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE account, retrying a BOUNDED number of times before giving up, to
|
||||
* distinguish a genuinely-absent account from one whose shim record has simply
|
||||
* not synced yet on a fresh session (the account-FORK guard — see the
|
||||
* PROVISION_RETRY_* budget note at the top of this module).
|
||||
*
|
||||
* On the FIRST miss it opens/subscribes the shim anchor repo (the shared
|
||||
* wallet's private store, where the shim lives) so the broker pushes its state,
|
||||
* then re-reads with capped exponential backoff. Returns the record as soon as
|
||||
* any attempt finds it (populating `accountCache` via resolveAccount), or `null`
|
||||
* if the whole budget is exhausted with 0 rows — the only case in which the
|
||||
* caller may treat the account as new and provision it.
|
||||
*
|
||||
* No-op fast path: a cache hit returns immediately with no retries. With the
|
||||
* unit fake `ng` (no doc_subscribe, synchronous store) a present account is
|
||||
* found on attempt 0 and an absent one exhausts the (short-sleep) budget without
|
||||
* changing behaviour — the fork can only arise against a real, lagging broker.
|
||||
*/
|
||||
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
|
||||
// Cache hit → session-idempotent, no query, no fork risk.
|
||||
const cached = accountCache.get(accountKey(id));
|
||||
if (cached) return cached;
|
||||
|
||||
const budget = getStoreRegistryDeps().provisionRetry;
|
||||
const attempts = Math.max(1, budget.attempts ?? 8);
|
||||
const baseMs = budget.baseMs ?? 300;
|
||||
const maxStepMs = budget.maxStepMs ?? 1500;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const found = await resolveAccount(id);
|
||||
if (found) return found;
|
||||
if (attempt === attempts - 1) break; // budget exhausted → genuinely absent
|
||||
// The shim anchor repo may not be synced on a fresh session; open it once so
|
||||
// the broker pushes its state, then back off before the next targeted read.
|
||||
try {
|
||||
await ensureRepoOpen(await anchorNuri());
|
||||
} catch {
|
||||
/* tolerant: a failed open just leaves the next read to behave as before */
|
||||
}
|
||||
const step = Math.min(baseMs * 2 ** attempt, maxStepMs);
|
||||
await new Promise((r) => setTimeout(r, step));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** All known accounts (from the shim). */
|
||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
||||
return [...(await loadShim()).values()];
|
||||
@@ -278,7 +345,15 @@ 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.
|
||||
const existing = await resolveAccount(id);
|
||||
//
|
||||
// ANTI-FORK (polyfill-era): resolve with a BOUNDED retry, NOT a single read.
|
||||
// A single 0-row read on a fresh, still-syncing session would misfire as
|
||||
// "new account" and RE-PROVISION a second set of scope docs (the fork — see
|
||||
// the PROVISION_RETRY_* note at the top of this module). We only fall through
|
||||
// to provisioning once the whole retry budget has confirmed 0 rows. A cache
|
||||
// hit inside resolveAccountReliably keeps same-session resolves free and
|
||||
// deterministic (the cached record wins over any re-provision).
|
||||
const existing = await resolveAccountReliably(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user