diff --git a/diigo-cache/src/cache.ts b/diigo-cache/src/cache.ts index b45ab01..1ccdd2b 100644 --- a/diigo-cache/src/cache.ts +++ b/diigo-cache/src/cache.ts @@ -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 { return new Promise((r) => setTimeout(r, ms)); } @@ -145,8 +151,8 @@ abstract class PagedCache extends DurableObject { /** * 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 { @@ -170,17 +176,17 @@ abstract class PagedCache extends DurableObject { 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 }); diff --git a/diigo-cache/tests/caches.test.ts b/diigo-cache/tests/caches.test.ts index fe01bf7..c26ae79 100644 --- a/diigo-cache/tests/caches.test.ts +++ b/diigo-cache/tests/caches.test.ts @@ -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()); diff --git a/diigo-cache/tsconfig.json b/diigo-cache/tsconfig.json index ca21ad5..cd40bd6 100644 --- a/diigo-cache/tsconfig.json +++ b/diigo-cache/tsconfig.json @@ -7,6 +7,7 @@ "esModuleInterop": true, "verbatimModuleSyntax": true, "skipLibCheck": true, + "lib": ["ES2022", "ESNext.Disposable"], "noEmit": true, "types": [] },