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);
|
if (typeof dispose === "function") dispose.call(stub);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
/** the clock authority a cache uses: reading time, and waiting. Passed
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
* 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
|
/** a cache's wiring: the webapp instance it asks on a miss, plus (private
|
||||||
* cache only) the public companion's id, recorded once published */
|
* cache only) the public companion's id, recorded once published */
|
||||||
@@ -110,6 +120,19 @@ export type CacheEnv = {
|
|||||||
abstract class PagedCache extends DurableObject<CacheEnv> {
|
abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||||
#step = 0;
|
#step = 0;
|
||||||
#pause?: Promise<void>;
|
#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
|
/** one-shot wiring. Idempotent; different values are refused (mint a
|
||||||
* new cache instead). */
|
* new cache instead). */
|
||||||
@@ -146,7 +169,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
if (current?.state === "running") return current;
|
if (current?.state === "running") return current;
|
||||||
const step = Math.trunc(opts.pagesPerStep ?? 20);
|
const step = Math.trunc(opts.pagesPerStep ?? 20);
|
||||||
if (!(step >= 1 && step <= 50)) throw new Error("pagesPerStep: expected 1–50");
|
if (!(step >= 1 && step <= 50)) throw new Error("pagesPerStep: expected 1–50");
|
||||||
const now = new Date().toISOString();
|
const now = this.#iso();
|
||||||
const status: FillStatus = {
|
const status: FillStatus = {
|
||||||
state: "running",
|
state: "running",
|
||||||
nextPage: 1,
|
nextPage: 1,
|
||||||
@@ -156,7 +179,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await this.ctx.storage.put({ fill: status, fillStep: step });
|
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 });
|
console.log({ event: "fill", kind: this.kind, state: status.state, pagesPerStep: step });
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -173,7 +196,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
const current = await this.fillStatus();
|
const current = await this.fillStatus();
|
||||||
if (!current) throw new Error("no fill job to stop");
|
if (!current) throw new Error("no fill job to stop");
|
||||||
if (current.state !== "running") return current;
|
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.put("fill", stopped);
|
||||||
await this.ctx.storage.deleteAlarm();
|
await this.ctx.storage.deleteAlarm();
|
||||||
console.log({ event: "fill", kind: this.kind, state: "stopped", nextPage: stopped.nextPage, pages: stopped.pages });
|
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,
|
nextPage: status.nextPage + 1,
|
||||||
pages: Math.max(1, Math.ceil(page.total / PAGE_SIZE)),
|
pages: Math.max(1, Math.ceil(page.total / PAGE_SIZE)),
|
||||||
fetched: status.fetched + (page.cached ? 0 : 1),
|
fetched: status.fetched + (page.cached ? 0 : 1),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: this.#iso(),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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 });
|
console.error({ event: "fill", kind: this.kind, state: "failed", page: status.nextPage, error: status.error });
|
||||||
}
|
}
|
||||||
// a stop that landed mid-step wins
|
// a stop that landed mid-step wins
|
||||||
@@ -209,11 +232,11 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
}
|
}
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
if (status.state === "running" && status.pages !== null && status.nextPage > status.pages) {
|
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);
|
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 });
|
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` */
|
/** 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;
|
if (this.#pause) await this.#pause;
|
||||||
} while (before !== this.#step);
|
} while (before !== this.#step);
|
||||||
this.#step += 1;
|
this.#step += 1;
|
||||||
this.#pause = sleep(MS_PER_CALL);
|
this.#pause = this.#clock.sleep(MS_PER_CALL);
|
||||||
|
|
||||||
const hit = await this.#pageFromStorage(pageNum, count);
|
const hit = await this.#pageFromStorage(pageNum, count);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
@@ -288,7 +311,7 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
|||||||
}
|
}
|
||||||
this.ctx.storage.sql.exec(
|
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",
|
"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 };
|
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 { newHttpBatchRpcSession } from "capnweb";
|
||||||
import type { CacheApi } from "../src/api.js";
|
import type { CacheApi } from "../src/api.js";
|
||||||
import { toWebkey } from "../src/webkeys.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
|
// goal 1e (capsite checklist): a job that grabs every page. Fake
|
||||||
// accounts: tests/fake-webapp.js ("fake-webapp-id": 30 items = 2 pages
|
// accounts: tests/fake-webapp.js ("fake-webapp-id": 30 items = 2 pages
|
||||||
@@ -12,6 +13,7 @@ import { toWebkey } from "../src/webkeys.js";
|
|||||||
|
|
||||||
type TestEnv = {
|
type TestEnv = {
|
||||||
CACHE_DO: DurableObjectNamespace<import("../src/cache.js").DiigoCache>;
|
CACHE_DO: DurableObjectNamespace<import("../src/cache.js").DiigoCache>;
|
||||||
|
CLOCKED_DO: DurableObjectNamespace<import("./test-worker.js").FixedClockCache>;
|
||||||
PUBLIC_DO: DurableObjectNamespace<import("../src/cache.js").PublicCache>;
|
PUBLIC_DO: DurableObjectNamespace<import("../src/cache.js").PublicCache>;
|
||||||
WEBAPP: { calls(): Promise<number> };
|
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 () => {
|
it("a public cache's fill stores no private rows", async () => {
|
||||||
const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId());
|
const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId());
|
||||||
await stub.setup({ webappId: "fake-webapp-id" });
|
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",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
// the real worker, with WEBAPP bound to tests/fake-webapp.js (an aux
|
// the real worker (via tests/test-worker.ts, which adds a fixed-clock
|
||||||
// worker declared in vitest.config.ts)
|
// cache), with WEBAPP bound to tests/fake-webapp.js (an aux worker
|
||||||
|
// declared in vitest.config.ts)
|
||||||
"name": "diigo-cache-tests",
|
"name": "diigo-cache-tests",
|
||||||
"main": "./src/index.ts",
|
"main": "./tests/test-worker.ts",
|
||||||
"compatibility_date": "2026-09-29",
|
"compatibility_date": "2026-09-29",
|
||||||
"rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }],
|
"rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }],
|
||||||
"services": [
|
"services": [
|
||||||
@@ -12,11 +13,13 @@
|
|||||||
"durable_objects": {
|
"durable_objects": {
|
||||||
"bindings": [
|
"bindings": [
|
||||||
{ "name": "CACHE_DO", "class_name": "DiigoCache" },
|
{ "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": {
|
"exports": {
|
||||||
"DiigoCache": { "type": "durable-object", "storage": "sqlite" },
|
"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