diigo-cache: the caches take their clock as a constructor argument
Ocap discipline: no ambient time in logic. PagedCache reads time and
waits only through a Clock { now, sleep }, a default constructor
argument (the live clock; the runtime constructs DOs with ctx and env
only). The fill job's timestamps and alarms, the etch time, and the
diigo-call pacing all go through it.
Tests interpose through the constructor: tests/test-worker.ts (now the
tests' main module) adds FixedClockCache, bound as CLOCKED_DO, and a
test pins startedAt, updatedAt, and the alarm time to the fixed clock
(an ambient new Date() fails it).
Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
7d2100bf77
commit
b6823867ff
4 files changed
+76
-17
No files matched your search
+35
-12
@@ -84,9 +84,19 @@ function disposeStub(stub: unknown): void {
|
||||
if (typeof dispose === "function") dispose.call(stub);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
/** the clock authority a cache uses: reading time, and waiting. Passed
|
||||
* in (ocap discipline), so tests can interpose a fixed clock. */
|
||||
export type Clock = {
|
||||
/** epoch milliseconds */
|
||||
now(): number;
|
||||
sleep(ms: number): Promise<void>;
|
||||
};
|
||||
|
||||
/** the live clock: the one place this module touches time */
|
||||
const liveClock: Clock = {
|
||||
now: () => Date.now(),
|
||||
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
};
|
||||
|
||||
/** a cache's wiring: the webapp instance it asks on a miss, plus (private
|
||||
* cache only) the public companion's id, recorded once published */
|
||||
@@ -110,6 +120,19 @@ export type CacheEnv = {
|
||||
abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
#step = 0;
|
||||
#pause?: Promise<void>;
|
||||
#clock: Clock;
|
||||
|
||||
/** the runtime constructs DOs with (ctx, env); `clock` defaults to the
|
||||
* live one, and a subclass can pass another */
|
||||
constructor(ctx: DurableObjectState, env: CacheEnv, clock: Clock = liveClock) {
|
||||
super(ctx, env);
|
||||
this.#clock = clock;
|
||||
}
|
||||
|
||||
/** now, as an ISO timestamp */
|
||||
#iso(): string {
|
||||
return new Date(this.#clock.now()).toISOString();
|
||||
}
|
||||
|
||||
/** one-shot wiring. Idempotent; different values are refused (mint a
|
||||
* new cache instead). */
|
||||
@@ -146,7 +169,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
if (current?.state === "running") return current;
|
||||
const step = Math.trunc(opts.pagesPerStep ?? 20);
|
||||
if (!(step >= 1 && step <= 50)) throw new Error("pagesPerStep: expected 1–50");
|
||||
const now = new Date().toISOString();
|
||||
const now = this.#iso();
|
||||
const status: FillStatus = {
|
||||
state: "running",
|
||||
nextPage: 1,
|
||||
@@ -156,7 +179,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
updatedAt: now,
|
||||
};
|
||||
await this.ctx.storage.put({ fill: status, fillStep: step });
|
||||
await this.ctx.storage.setAlarm(Date.now() + FILL_STEP_DELAY_MS);
|
||||
await this.ctx.storage.setAlarm(this.#clock.now() + FILL_STEP_DELAY_MS);
|
||||
console.log({ event: "fill", kind: this.kind, state: status.state, pagesPerStep: step });
|
||||
return status;
|
||||
}
|
||||
@@ -173,7 +196,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
const current = await this.fillStatus();
|
||||
if (!current) throw new Error("no fill job to stop");
|
||||
if (current.state !== "running") return current;
|
||||
const stopped: FillStatus = { ...current, state: "stopped", updatedAt: new Date().toISOString() };
|
||||
const stopped: FillStatus = { ...current, state: "stopped", updatedAt: this.#iso() };
|
||||
await this.ctx.storage.put("fill", stopped);
|
||||
await this.ctx.storage.deleteAlarm();
|
||||
console.log({ event: "fill", kind: this.kind, state: "stopped", nextPage: stopped.nextPage, pages: stopped.pages });
|
||||
@@ -197,10 +220,10 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
nextPage: status.nextPage + 1,
|
||||
pages: Math.max(1, Math.ceil(page.total / PAGE_SIZE)),
|
||||
fetched: status.fetched + (page.cached ? 0 : 1),
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: this.#iso(),
|
||||
};
|
||||
} catch (err) {
|
||||
status = { ...status, state: "failed", error: String(err), updatedAt: new Date().toISOString() };
|
||||
status = { ...status, state: "failed", error: String(err), updatedAt: this.#iso() };
|
||||
console.error({ event: "fill", kind: this.kind, state: "failed", page: status.nextPage, error: status.error });
|
||||
}
|
||||
// a stop that landed mid-step wins
|
||||
@@ -209,11 +232,11 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
}
|
||||
if (!status) return;
|
||||
if (status.state === "running" && status.pages !== null && status.nextPage > status.pages) {
|
||||
status = { ...status, state: "done", updatedAt: new Date().toISOString() };
|
||||
status = { ...status, state: "done", updatedAt: this.#iso() };
|
||||
}
|
||||
await this.ctx.storage.put("fill", status);
|
||||
console.log({ event: "fill", kind: this.kind, state: status.state, nextPage: status.nextPage, pages: status.pages, fetched: status.fetched });
|
||||
if (status.state === "running") await this.ctx.storage.setAlarm(Date.now() + FILL_STEP_DELAY_MS);
|
||||
if (status.state === "running") await this.ctx.storage.setAlarm(this.#clock.now() + FILL_STEP_DELAY_MS);
|
||||
}
|
||||
|
||||
/** diigo's page count, if any cached page recorded `total` */
|
||||
@@ -244,7 +267,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
if (this.#pause) await this.#pause;
|
||||
} while (before !== this.#step);
|
||||
this.#step += 1;
|
||||
this.#pause = sleep(MS_PER_CALL);
|
||||
this.#pause = this.#clock.sleep(MS_PER_CALL);
|
||||
|
||||
const hit = await this.#pageFromStorage(pageNum, count);
|
||||
if (hit) {
|
||||
@@ -288,7 +311,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
}
|
||||
this.ctx.storage.sql.exec(
|
||||
"insert into pages (page_num, count, total, etched_at) values (?, ?, ?, ?) on conflict(page_num) do update set count = excluded.count, total = excluded.total, etched_at = excluded.etched_at",
|
||||
pageNum, count, total, new Date().toISOString(),
|
||||
pageNum, count, total, this.#iso(),
|
||||
);
|
||||
});
|
||||
return { total, items: rows.map((r) => r.json), cached: false };
|
||||
|
||||
@@ -5,6 +5,7 @@ import worker from "../src/index.js";
|
||||
import { newHttpBatchRpcSession } from "capnweb";
|
||||
import type { CacheApi } from "../src/api.js";
|
||||
import { toWebkey } from "../src/webkeys.js";
|
||||
import { FIXED_NOW } from "./test-worker.js";
|
||||
|
||||
// goal 1e (capsite checklist): a job that grabs every page. Fake
|
||||
// accounts: tests/fake-webapp.js ("fake-webapp-id": 30 items = 2 pages
|
||||
@@ -12,6 +13,7 @@ import { toWebkey } from "../src/webkeys.js";
|
||||
|
||||
type TestEnv = {
|
||||
CACHE_DO: DurableObjectNamespace<import("../src/cache.js").DiigoCache>;
|
||||
CLOCKED_DO: DurableObjectNamespace<import("./test-worker.js").FixedClockCache>;
|
||||
PUBLIC_DO: DurableObjectNamespace<import("../src/cache.js").PublicCache>;
|
||||
WEBAPP: { calls(): Promise<number> };
|
||||
};
|
||||
@@ -107,6 +109,18 @@ describe("fill job (goal 1e)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads time only through its clock: a fixed clock pins the timestamps and the alarm", async () => {
|
||||
const stub = testEnv.CLOCKED_DO.get(testEnv.CLOCKED_DO.newUniqueId());
|
||||
await stub.setup({ webappId: "fake-webapp-id" });
|
||||
const at = new Date(FIXED_NOW).toISOString();
|
||||
expect(await stub.startFill()).toMatchObject({ startedAt: at, updatedAt: at });
|
||||
await runInDurableObject(stub, async (_obj, state) => {
|
||||
expect(await state.storage.getAlarm()).toBe(FIXED_NOW + 1000);
|
||||
});
|
||||
await drain(stub);
|
||||
expect(await stub.fillStatus()).toMatchObject({ state: "done", updatedAt: at });
|
||||
});
|
||||
|
||||
it("a public cache's fill stores no private rows", async () => {
|
||||
const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId());
|
||||
await stub.setup({ webappId: "fake-webapp-id" });
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @file the tests' main module: the real worker, plus a DiigoCache on a
|
||||
* fixed clock (bound as CLOCKED_DO in wrangler.tests.jsonc), so tests can
|
||||
* interpose time through the constructor.
|
||||
*/
|
||||
import { DiigoCache } from "../src/cache.js";
|
||||
import type { CacheEnv } from "../src/cache.js";
|
||||
|
||||
export * from "../src/index.js";
|
||||
export { default } from "../src/index.js";
|
||||
|
||||
/** 2026-10-07T12:00:00Z */
|
||||
export const FIXED_NOW = Date.UTC(2026, 9, 7, 12, 0, 0);
|
||||
|
||||
export class FixedClockCache extends DiigoCache {
|
||||
constructor(ctx: DurableObjectState, env: CacheEnv) {
|
||||
super(ctx, env, { now: () => FIXED_NOW, sleep: async () => {} });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
// the real worker, with WEBAPP bound to tests/fake-webapp.js (an aux
|
||||
// worker declared in vitest.config.ts)
|
||||
// the real worker (via tests/test-worker.ts, which adds a fixed-clock
|
||||
// cache), with WEBAPP bound to tests/fake-webapp.js (an aux worker
|
||||
// declared in vitest.config.ts)
|
||||
"name": "diigo-cache-tests",
|
||||
"main": "./src/index.ts",
|
||||
"main": "./tests/test-worker.ts",
|
||||
"compatibility_date": "2026-09-29",
|
||||
"rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }],
|
||||
"services": [
|
||||
@@ -12,11 +13,13 @@
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "CACHE_DO", "class_name": "DiigoCache" },
|
||||
{ "name": "PUBLIC_DO", "class_name": "PublicCache" }
|
||||
{ "name": "PUBLIC_DO", "class_name": "PublicCache" },
|
||||
{ "name": "CLOCKED_DO", "class_name": "FixedClockCache" }
|
||||
]
|
||||
},
|
||||
"exports": {
|
||||
"DiigoCache": { "type": "durable-object", "storage": "sqlite" },
|
||||
"PublicCache": { "type": "durable-object", "storage": "sqlite" }
|
||||
"PublicCache": { "type": "durable-object", "storage": "sqlite" },
|
||||
"FixedClockCache": { "type": "durable-object", "storage": "sqlite" }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user