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>;
|
getSession: () => Promise<RegistrySession>;
|
||||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||||
normalizeId?: (id: string) => string;
|
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 {
|
export interface EventuallyConfig {
|
||||||
@@ -84,6 +98,13 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
|||||||
registryDeps = {
|
registryDeps = {
|
||||||
getSession: deps.getSession,
|
getSession: deps.getSession,
|
||||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
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 { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||||
import { getStoreRegistryDeps } from "./polyfill";
|
import { getStoreRegistryDeps } from "./polyfill";
|
||||||
import { ensureRepoOpen } from "./open-repo";
|
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 { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||||
import type { Nuri, Scope } from "./types";
|
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). */
|
/** 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()];
|
||||||
@@ -278,7 +345,15 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
|||||||
const key = accountKey(id);
|
const key = accountKey(id);
|
||||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
// 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.
|
// 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;
|
if (existing) return existing;
|
||||||
|
|
||||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ function inject() {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
provisionRetry: { attempts: 1 },
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ const TARGET = "did:ng:o:host-inbox";
|
|||||||
function inject() {
|
function inject() {
|
||||||
const ng = makeFakeNg();
|
const ng = makeFakeNg();
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
configureStoreRegistry({ getSession: async () => SESSION });
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ function inject() {
|
|||||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
||||||
};
|
};
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } });
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
resetCaps();
|
resetCaps();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
provisionRetry: { attempts: 1 },
|
||||||
});
|
});
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,8 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
provisionRetry: { attempts: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ function inject() {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
provisionRetry: { attempts: 1 },
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
return ng;
|
return ng;
|
||||||
@@ -213,6 +215,8 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
|||||||
protectedStoreId: "PROT",
|
protectedStoreId: "PROT",
|
||||||
publicStoreId: "PUB",
|
publicStoreId: "PUB",
|
||||||
}),
|
}),
|
||||||
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
provisionRetry: { attempts: 1 },
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||||
@@ -358,7 +362,8 @@ test("injection: a malicious id still round-trips through the shim", async () =>
|
|||||||
test("normalizeId defaults to trim when not provided", async () => {
|
test("normalizeId defaults to trim when not provided", async () => {
|
||||||
const ng = makeFakeNg();
|
const ng = makeFakeNg();
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
configureStoreRegistry({ getSession: async () => SESSION });
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
const a = await ensureAccount(" Ivy ");
|
const a = await ensureAccount(" Ivy ");
|
||||||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ function makeFakeNg(failFor: Set<string> = new Set()) {
|
|||||||
function inject(failFor?: Set<string>) {
|
function inject(failFor?: Set<string>) {
|
||||||
const ng = makeFakeNg(failFor);
|
const ng = makeFakeNg(failFor);
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
configureStoreRegistry({ getSession: async () => SESSION });
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
|
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user