fix: trois defauts trouves par la revue adverse sur le registre derive

Chacun reproduit avant d'etre corrige, chacun epingle par un test qui
echoue si la correction est retiree.

1. Une barriere pouvait survivre a la tentative qui l'avait produite.
`getSyncState` repondait `"synced"` sur le seul `barrierCrossed`, alors
que son propre commentaire disait « sur le canal qui tient MAINTENANT » :
`reportSetupFailure` efface `establishing` et laisse `lastState`, donc un
`doc_subscribe` qui poussait un `State` puis echouait laissait une
autorite - et `noRowsMeansAbsent` prend une lecture a zero ligne pour une
absence definitive. Le registre precedent repondait `"timed-out"` la ;
strict est la lecture de cette famille, son cout est la latence, jamais
la perte.

2. `release` ne repondait pas « est-ce que j'ecoute encore ».
`resetDocSubscriptions()` largue les auditeurs et supprime l'entree sans
rien dire, donc le handle continuait de paraitre vivant ; si autre chose
souscrivait d'abord au meme document, le court-circuit sautait l'ouverture
due et le document restait tenu par un auditeur libre de partir. Le
fan-out numerote desormais ses incarnations, et l'ouverture compare.

3. Une tentative sans reponse TIENT quand meme. Elle ne comptait pas, si
bien que chaque lecture d'un document dont le `doc_subscribe` ne repond
jamais repayait le repli borne - 8 s en production, sur le chemin de la
lecture ancree. Un second `doc_subscribe` est de toute facon impossible
tant qu'un est en vol, donc la tentative fraiche n'achetait rien.

Les trois questions sont maintenant une fonction nommee, `stillStanding`,
et chacune est posee a qui detient la reponse.
This commit is contained in:
Sylvain Duchesne
2026-09-24 13:17:10 +02:00
parent 2cf42a244b
commit dcf3814446
3 changed files with 191 additions and 15 deletions
+98
View File
@@ -367,6 +367,104 @@ describe("ensureRepoOpen", () => {
expect(getSyncState(doc)).toBe("synced");
});
// A barrier must not outlive the attempt that produced it.
//
// `subscribe.ts` clears `establishing` on a refused establish and LEAVES the remembered
// `State`, so a call that pushed the barrier and then failed leaves the fan-out with a
// barrier and no channel. Reading `"synced"` off that is AUTHORITY - `index-deposit.ts`
// `noRowsMeansAbsent` calls a zero-row read over it a definitive absence, which destroys a
// deposit - and the registry this replaced answered `"timed-out"` there. Strict is the
// reading: the cost of it is latency, never loss.
it("a barrier pushed by a subscribe that then FAILED is not authority", async () => {
const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => {
cb({ V0: { State: {} } }); // the barrier arrives...
throw new Error("RepoNotFound: after the push"); // ...and the call then fails
});
const ng = {
doc_subscribe,
sparql_query: mock(async () => ({ results: { bindings: [] } })),
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
};
inject(ng as unknown as ReturnType<typeof makeFakeNgWithSubscribe>);
setOpenTimeoutForTests(20);
const doc = "did:ng:o:pushed-then-failed";
// Something else holds the fan-out, so the refusal does not take the entry with it and the
// remembered barrier survives the attempt that pushed it.
const other = subscribeDoc(doc, () => {});
await ensureRepoOpen(doc);
await new Promise((r) => setTimeout(r, 10));
expect(docChannel(doc).barrierCrossed).toBe(true); // the barrier IS remembered...
expect(docChannel(doc).state).toBe("none"); // ...and nothing stands behind it
expect(getSyncState(doc)).toBe("timed-out");
other();
});
// `release` does not answer "am I still listening", and treating it as though it did leaves the
// document held by somebody else's listener.
//
// `resetDocSubscriptions()` - which the PUBLISHED `configure()` calls - drops every listener
// and deletes the entry without telling this module, so its handle goes on looking live. If
// anything else subscribes to the same document first, the channel is `"open"` again and a
// short-circuit that asks only "do I hold a handle" skips the open it owes.
it("a second configure() re-holds the document even when something else subscribed first", async () => {
const ngA = makeFakeNgWithSubscribe({});
inject(ngA);
const doc = "did:ng:o:foreign-first";
await ensureRepoOpen(doc);
expect(getSyncState(doc)).toBe("synced");
const ngB = makeFakeNgWithSubscribe({});
inject(ngB); // resetDocSubscriptions(): every listener dropped, this module not told
const foreign = subscribeDoc(doc, () => {}); // ...and something else gets there first
await new Promise((r) => setTimeout(r, 10));
expect(docChannel(doc).state).toBe("open");
await ensureRepoOpen(doc);
await new Promise((r) => setTimeout(r, 10));
// The document must survive the foreign listener leaving, which it can only do if the open
// above took a listener of its own.
foreign();
expect(docChannel(doc).state).toBe("open");
expect(getSyncState(doc)).toBe("synced");
});
// An attempt that has not ANSWERED still STANDS, so a repeat open costs nothing.
//
// A second `doc_subscribe` cannot be placed while one is in flight - the fan-out dedupes it,
// and upstream it would EVICT the first - so making a fresh attempt buys nothing and costs the
// caller the whole bounded fallback again: 8s in production, on every read of a document whose
// subscribe never answers, and the anchored read path awaits it.
it("a subscribe that never answers costs the fallback ONCE, not once per read", async () => {
const doc_subscribe = mock(async () => await new Promise<never>(() => {}));
const ng = {
doc_subscribe,
sparql_query: mock(async () => ({ results: { bindings: [] } })),
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
};
inject(ng as unknown as ReturnType<typeof makeFakeNgWithSubscribe>);
setOpenTimeoutForTests(60);
const doc = "did:ng:o:never-answers";
let at = Date.now();
await ensureRepoOpen(doc);
const paid = Date.now() - at;
at = Date.now();
await ensureRepoOpen(doc);
const again = Date.now() - at;
expect(paid).toBeGreaterThanOrEqual(50); // the first read waits the barrier out
expect(again).toBeLessThan(25); // every later one does not
expect(doc_subscribe).toHaveBeenCalledTimes(1); // and no second call was ever placed
// Not authority either way: nothing has been heard from this document.
expect(getSyncState(doc)).toBe("timed-out");
resetOpenedRepos();
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],