diigo-cache: invalidate the last N days of cached pages
invalidateRecent(days) drops the cached pages holding anything diigo updated within the last N days (on the cache's injected clock), and every page before them, so the next read or fill refetches them; item rows stay. Exposed on the fill capability (private and public) and on the webkey page as "invalidate the last N days" per cache. A user-requested refetch, not a change in what stored state means. Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
cffb1fa9b2
commit
904f620f06
5 files changed
+91
-1
No files matched your search
@@ -28,9 +28,10 @@ type Fillable = {
|
|||||||
startFill(opts?: { pagesPerStep?: number }): Promise<FillStatus>;
|
startFill(opts?: { pagesPerStep?: number }): Promise<FillStatus>;
|
||||||
fillStatus(): Promise<FillStatus | undefined>;
|
fillStatus(): Promise<FillStatus | undefined>;
|
||||||
stopFill(): Promise<FillStatus>;
|
stopFill(): Promise<FillStatus>;
|
||||||
|
invalidateRecent(days: number): Promise<{ pages: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** a cache's fill job, and nothing else */
|
/** a cache's fill job and page invalidation, and nothing else */
|
||||||
export class FillCap extends RpcTarget {
|
export class FillCap extends RpcTarget {
|
||||||
#cache: Fillable;
|
#cache: Fillable;
|
||||||
|
|
||||||
@@ -55,6 +56,13 @@ export class FillCap extends RpcTarget {
|
|||||||
async stopFill(): Promise<FillStatus> {
|
async stopFill(): Promise<FillStatus> {
|
||||||
return this.#cache.stopFill();
|
return this.#cache.stopFill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** drop the cached pages holding anything updated in the last `days`
|
||||||
|
* days (1–3650), so they refetch */
|
||||||
|
async invalidateRecent(days: unknown): Promise<{ pages: number }> {
|
||||||
|
if (typeof days !== "number") throw new Error("days: expected a number");
|
||||||
|
return this.#cache.invalidateRecent(days);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** what the holder of a private cache's key may do */
|
/** what the holder of a private cache's key may do */
|
||||||
|
|||||||
@@ -28,12 +28,18 @@ export const CACHE_PAGE_HTML = `<!doctype html>
|
|||||||
<p id="private-status">no fill job yet</p>
|
<p id="private-status">no fill job yet</p>
|
||||||
<button type="button" data-target="private" data-action="start">fill</button>
|
<button type="button" data-target="private" data-action="start">fill</button>
|
||||||
<button type="button" data-target="private" data-action="stop">stop</button>
|
<button type="button" data-target="private" data-action="stop">stop</button>
|
||||||
|
<p><label>invalidate the last <input type="number" id="private-days" min="1" max="3650" value="7" size="5"> days</label>
|
||||||
|
<button type="button" data-target="private" data-action="invalidate">invalidate</button>
|
||||||
|
<span id="private-invalidated"></span></p>
|
||||||
</section>
|
</section>
|
||||||
<section id="public" hidden>
|
<section id="public" hidden>
|
||||||
<h2>public cache</h2>
|
<h2>public cache</h2>
|
||||||
<p id="public-status">no fill job yet</p>
|
<p id="public-status">no fill job yet</p>
|
||||||
<button type="button" data-target="public" data-action="start">fill</button>
|
<button type="button" data-target="public" data-action="start">fill</button>
|
||||||
<button type="button" data-target="public" data-action="stop">stop</button>
|
<button type="button" data-target="public" data-action="stop">stop</button>
|
||||||
|
<p><label>invalidate the last <input type="number" id="public-days" min="1" max="3650" value="7" size="5"> days</label>
|
||||||
|
<button type="button" data-target="public" data-action="invalidate">invalidate</button>
|
||||||
|
<span id="public-invalidated"></span></p>
|
||||||
</section>
|
</section>
|
||||||
<p><a href="/">← home</a></p>
|
<p><a href="/">← home</a></p>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
@@ -105,6 +111,13 @@ async function main() {
|
|||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const { target, action } = button.dataset;
|
const { target, action } = button.dataset;
|
||||||
const j = job(target);
|
const j = job(target);
|
||||||
|
if (action === "invalidate") {
|
||||||
|
const days = Number($(target + "-days").value);
|
||||||
|
j.invalidateRecent(days)
|
||||||
|
.then((r) => ($(target + "-invalidated").textContent = "dropped " + r.pages + " cached pages; fill to refetch"))
|
||||||
|
.catch(report);
|
||||||
|
return;
|
||||||
|
}
|
||||||
(action === "start" ? j.startFill() : j.stopFill())
|
(action === "start" ? j.startFill() : j.stopFill())
|
||||||
.then(() => refresh(hasPublic))
|
.then(() => refresh(hasPublic))
|
||||||
.catch(report);
|
.catch(report);
|
||||||
|
|||||||
@@ -245,6 +245,37 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
return stopped;
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drop the cached pages holding anything diigo updated in the last
|
||||||
|
* `days` days, and every page before them (newer still; and new
|
||||||
|
* updates push items back), so the next read or fill refetches them.
|
||||||
|
* Item rows stay. Returns how many pages were dropped.
|
||||||
|
*/
|
||||||
|
async invalidateRecent(days: number): Promise<{ pages: number }> {
|
||||||
|
if (!Number.isInteger(days) || days < 1 || days > 3650) throw new Error("days: expected a whole number from 1 to 3650");
|
||||||
|
this.#schema();
|
||||||
|
const cutoff = Math.floor(this.#clock.now() / 1000) - days * 24 * 60 * 60;
|
||||||
|
const sql = this.ctx.storage.sql;
|
||||||
|
const deepest = sql
|
||||||
|
.exec<{ last: number | null }>(
|
||||||
|
"select max(page_members.page_num) as last from page_members join items on items.item_id = page_members.item_id where cast(items.updated as integer) >= ?",
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
.toArray()[0]?.last;
|
||||||
|
if (!deepest) {
|
||||||
|
console.log({ event: "cache", kind: this.kind, outcome: "invalidated", days, pages: 0 });
|
||||||
|
return { pages: 0 };
|
||||||
|
}
|
||||||
|
let pages = 0;
|
||||||
|
this.ctx.storage.transactionSync(() => {
|
||||||
|
pages = Number(sql.exec<{ n: number }>("select count(*) as n from pages where page_num <= ?", deepest).toArray()[0].n);
|
||||||
|
sql.exec("delete from page_members where page_num <= ?", deepest);
|
||||||
|
sql.exec("delete from pages where page_num <= ?", deepest);
|
||||||
|
});
|
||||||
|
console.log({ event: "cache", kind: this.kind, outcome: "invalidated", days, pages });
|
||||||
|
return { pages };
|
||||||
|
}
|
||||||
|
|
||||||
/** one fill step: up to `fillStep` pages, progress saved after each,
|
/** one fill step: up to `fillStep` pages, progress saved after each,
|
||||||
* then the next step is scheduled (or the job ends) */
|
* then the next step is scheduled (or the job ends) */
|
||||||
async alarm(): Promise<void> {
|
async alarm(): Promise<void> {
|
||||||
|
|||||||
@@ -159,6 +159,30 @@ describe("page numbering (schema v2)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("invalidateRecent", () => {
|
||||||
|
it("drops the pages holding anything updated in the last N days, and only those", async () => {
|
||||||
|
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||||
|
// deep_dee: one bookmark a day, newest first; page 1 = days 0–99,
|
||||||
|
// page 2 = days 100–199, page 3 = days 200–299
|
||||||
|
await stub.setup({ webappId: "fake-deep-webapp" });
|
||||||
|
for (const n of [1, 2, 3]) await stub.page(n, PAGE_SIZE);
|
||||||
|
expect(await stub.invalidateRecent(150)).toEqual({ pages: 2 });
|
||||||
|
expect((await stub.page(3, PAGE_SIZE)).cached).toBe(true);
|
||||||
|
expect((await stub.page(2, PAGE_SIZE)).cached).toBe(false);
|
||||||
|
expect((await stub.page(1, PAGE_SIZE)).cached).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops nothing when nothing cached is that recent, and refuses a bad day count", async () => {
|
||||||
|
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||||
|
await stub.setup({ webappId: WEBAPP_ID }); // the plain account's dates are in 2023
|
||||||
|
await stub.page(1, PAGE_SIZE);
|
||||||
|
expect(await stub.invalidateRecent(7)).toEqual({ pages: 0 });
|
||||||
|
expect((await stub.page(1, PAGE_SIZE)).cached).toBe(true);
|
||||||
|
const refused = await stub.invalidateRecent(0).then(() => "", (e: unknown) => String(e));
|
||||||
|
expect(refused).toMatch(/days/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("eviction", () => {
|
describe("eviction", () => {
|
||||||
it("a cache miss leaves no undisposed RPC stubs to block eviction", async () => {
|
it("a cache miss leaves no undisposed RPC stubs to block eviction", async () => {
|
||||||
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||||
|
|||||||
@@ -300,6 +300,20 @@ describe("fill via webkey and Cap'n Web (goal 1e)", () => {
|
|||||||
expect(html).toContain("event.preventDefault()");
|
expect(html).toContain("event.preventDefault()");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the capability can invalidate recent pages; the page offers it for each cache", async () => {
|
||||||
|
const { cacheId } = await wireJson("private");
|
||||||
|
const { api, done } = session();
|
||||||
|
try {
|
||||||
|
expect(await api.cache(cacheId).invalidateRecent(7)).toEqual({ pages: 0 });
|
||||||
|
} finally {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
const html = await (await call("/cache/")).text();
|
||||||
|
expect(html).toContain('id="private-days"');
|
||||||
|
expect(html).toContain('data-action="invalidate"');
|
||||||
|
expect(html).toContain("invalidateRecent(days)");
|
||||||
|
});
|
||||||
|
|
||||||
it("serves capnweb's browser build itself (no third-party script beside the key)", async () => {
|
it("serves capnweb's browser build itself (no third-party script beside the key)", async () => {
|
||||||
const res = await call("/_capnweb.js");
|
const res = await call("/_capnweb.js");
|
||||||
expect(res.headers.get("content-type")).toMatch(/javascript/);
|
expect(res.headers.get("content-type")).toMatch(/javascript/);
|
||||||
|
|||||||
Reference in new issue
Block a user