fix: une panne n'est plus mise en cache comme une absence de capacité
This commit is contained in:
@@ -33,19 +33,27 @@ function inject() {
|
||||
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
|
||||
return undefined;
|
||||
});
|
||||
const sparql_query = mock(async (...a: unknown[]) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
|
||||
.map((q) => ({ c: { value: q.o } })),
|
||||
},
|
||||
}));
|
||||
// Documents the emulated broker cannot be asked about right now — an outage, a repo
|
||||
// this session has not synced, a rejected read. Scoped to ONE anchor on purpose: the
|
||||
// connection work `setCurrentUser` fires reads on its own account, and an outage that
|
||||
// took those down would be testing something else.
|
||||
const unreachable = new Set<string>();
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
if (unreachable.has(a[3] as string)) throw new Error("emulated broker: cannot serve that overlay");
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
|
||||
.map((q) => ({ c: { value: q.o } })),
|
||||
},
|
||||
};
|
||||
});
|
||||
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetCaps();
|
||||
resetPublicStoreFetches();
|
||||
setCurrentUser(null);
|
||||
return { sparql_query, quads };
|
||||
return { sparql_query, quads, unreachable };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -186,3 +194,90 @@ test("resetting the caps forgets the memo — a stale yes would hand back what i
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
|
||||
});
|
||||
|
||||
// ── A failure is not an answer ───────────────────────────────────────────────
|
||||
// REGRESSION (2026-09-15). `downloadReadCap` caught its own errors and returned the same
|
||||
// `null` as a genuine absence, which the memo then held for the whole session: ONE
|
||||
// transient outage recorded "no capability" for that document until the session ended.
|
||||
// Deposits being consumed, the deposit was retired before its arm read anything — and
|
||||
// every re-deposit sent to repair it met the same memoised `null` and was destroyed too.
|
||||
|
||||
/** How many times the network was asked ABOUT `nuri` — anchored reads only, so the
|
||||
* connection work happening in the background on other documents does not count. */
|
||||
function asksAbout(sparql_query: { mock: { calls: unknown[][] } }, nuri: string): number {
|
||||
return sparql_query.mock.calls.filter((c) => c[3] === nuri).length;
|
||||
}
|
||||
|
||||
test("a fetch that FAILED is asked again, and the cap arrives once the broker recovers", async () => {
|
||||
const { sparql_query, unreachable } = inject();
|
||||
setCurrentUser("alice");
|
||||
await aliceExposesHerNote();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
unreachable.add(PUB); // the broker cannot answer for this document
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(asksAbout(sparql_query, PUB)).toBe(1);
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
|
||||
unreachable.delete(PUB); // …and recovers
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(asksAbout(sparql_query, PUB)).toBe(2); // asked AGAIN — the outage was not an answer
|
||||
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
||||
});
|
||||
|
||||
// The two negatives in one test, so neither half can be "fixed" by sacrificing the other:
|
||||
// an absence was READ off the Header branch and is kept; a failure was never read at all.
|
||||
test("an absence is remembered, a failure is not — the two negatives are not the same", async () => {
|
||||
const { sparql_query, unreachable } = inject();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
const absent = "did:ng:o:exposes-nothing" as Nuri;
|
||||
expect(await fetchReadCap(absent)).toBe(false);
|
||||
expect(await fetchReadCap(absent)).toBe(false);
|
||||
expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands
|
||||
|
||||
const unreached = "did:ng:o:cannot-ask" as Nuri;
|
||||
unreachable.add(unreached);
|
||||
expect(await fetchReadCap(unreached)).toBe(false);
|
||||
expect(await fetchReadCap(unreached)).toBe(false);
|
||||
expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time
|
||||
});
|
||||
|
||||
// The memo's OTHER job. Losing it would turn one cold read by N holders into N round-trips.
|
||||
test("concurrent askers share ONE failing round-trip, and exactly one re-ask follows", async () => {
|
||||
const { sparql_query, unreachable } = inject();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
unreachable.add(PUB);
|
||||
const answers = await Promise.all([fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB)]);
|
||||
expect(answers).toEqual([false, false, false]);
|
||||
expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three
|
||||
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind
|
||||
});
|
||||
|
||||
// `fetchReadCap` files for whoever is connected NOW, on EVERY call — the memo spares the
|
||||
// round-trip, not the filing. A re-attempt after a failure must obey that too, or the
|
||||
// recovered cap would land in the ring of whoever happened to hit the outage.
|
||||
test("a cap obtained after a failure is filed for whoever is connected THEN", async () => {
|
||||
const { unreachable } = inject();
|
||||
setCurrentUser("alice");
|
||||
await aliceExposesHerNote();
|
||||
armEmulation();
|
||||
|
||||
setCurrentUser("bob");
|
||||
unreachable.add(PUB);
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
|
||||
unreachable.delete(PUB);
|
||||
setCurrentUser("carol");
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // carol asked, carol holds it
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user