diigo-cache: dispose RPC stubs after each fetch; one fetch per miss

Two bugs in page()'s miss path, found while building the fill job:

- the webapp stubs (app, pathjoin, query) were never disposed, which
  kept the DO from being evicted (evictDurableObject hung after a single
  miss); they are now disposed once the page is in hand
- a miss on page n > 1 also fetched page 1 and threw it away, doubling
  diigo calls; every page carries `total`, so only page n is fetched

Regression test: a miss, then eviction, then a cache hit. tsconfig adds
the ESNext.Disposable lib for Symbol.dispose.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 04:25:23 -05:00
1 parent cf5ccce4e2
commit 530942f5d9
3 files changed
+30 -13

No files matched your search

+18 -12
View File
@@ -72,6 +72,12 @@ export type FillStatus = {
error?: string;
};
/** release an RPC stub, if it is one (plain objects are left alone) */
function disposeStub(stub: unknown): void {
const dispose = (stub as { [Symbol.dispose]?: () => void } | null)?.[Symbol.dispose];
if (typeof dispose === "function") dispose.call(stub);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
@@ -145,8 +151,8 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
/**
* read page `pageNum` (`count` per page, diigo's updated-desc order):
* storage first; on miss, fetch through the webapp instance (etching
* page 1 alongside — it carries `total`) and etch. Cache provenance
* storage first; on miss, fetch that one page through the webapp
* instance (every page carries `total`) and etch. Cache provenance
* rides in the result (`cached`).
*/
async page(pageNum: number, count: number): Promise<CachePage> {
@@ -170,17 +176,17 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
return hit;
}
// RPC stubs (the app and each pipelined step) are disposed once the
// page is in hand: undisposed stubs keep this DO from being evicted
const app = await this.env.WEBAPP.at(init.webappId);
const fetchPage = (n: number): Promise<{ total: number; items: Item[] }> =>
app
.pathjoin("load_user_items")
.query({ sort: "updated", count, page_num: n })
.get()
.then((t) => JSON.parse(t)) as Promise<{ total: number; items: Item[] }>;
// a miss on page n>1 etches page 1 first (it carries `total`)
const first = await fetchPage(1);
const data = pageNum === 1 ? first : await fetchPage(pageNum);
const joined = app.pathjoin("load_user_items");
const queried = joined.query({ sort: "updated", count, page_num: pageNum });
let data: { total: number; items: Item[] };
try {
data = JSON.parse(await queried.get()) as { total: number; items: Item[] };
} finally {
for (const stub of [queried, joined, app]) disposeStub(stub);
}
const kept = this.keep(toRows(data.items, console));
// fetched vs kept: the public cache's drop of private rows shows here
console.log({ event: "cache", kind: this.kind, page: pageNum, outcome: "miss", fetched: data.items.length, kept: kept.length });
+11 -1
View File
@@ -1,5 +1,5 @@
import { env } from "cloudflare:workers";
import { runInDurableObject } from "cloudflare:test";
import { evictDurableObject, runInDurableObject } from "cloudflare:test";
import { describe, expect, it, vi } from "vitest";
import worker from "../src/index.js";
import { toWebkey } from "../src/webkeys.js";
@@ -68,6 +68,16 @@ describe("PublicCache", () => {
});
});
describe("eviction", () => {
it("a cache miss leaves no undisposed RPC stubs to block eviction", async () => {
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
await stub.setup({ webappId: WEBAPP_ID });
await stub.page(1, 24);
await evictDurableObject(stub);
expect((await stub.page(1, 24)).cached).toBe(true);
}, 3000);
});
describe("DiigoCache", () => {
it("keeps every item of a page, each under its own link_id", async () => {
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
+1
View File
@@ -7,6 +7,7 @@
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true,
"types": []
},