diigo-cache: fill job — a button that grabs every page (goal 1e)

POST /_fill (cache id in the body; target private|public; action
start|stop) starts or stops a background walk of every diigo page into
the cache. DO alarms advance it pagesPerStep pages per step (default
20), a second apart; progress (FillStatus) is stored in the DO after
each page, so it survives eviction. Cached pages are skipped; starting
while running rejoins the job; a stop mid-step wins; a failure is
recorded with its reason and logged once. The created page gets fill
buttons; the public page shows "filling: n of m pages".

Fill state is new, additive DO storage (fill, fillStep): no existing
state changes meaning.

The 13 fill specs are unskipped and green, plus one: a fill makes one
diigo call per page fetched.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 04:25:38 -05:00
1 parent 530942f5d9
commit 31ded9735b
4 files changed
+182 -15

No files matched your search

+84 -8
View File
@@ -53,9 +53,15 @@ export type CachePage = {
const MS_PER_CALL = 500;
/** items per diigo page, for the fill job and the pages it etches */
const PAGE_SIZE = 24;
/** the pause before each fill step's alarm: a step already paces its
* pages at MS_PER_CALL; this keeps steps from running back to back */
const FILL_STEP_DELAY_MS = 1000;
/**
* a fill job's progress (goal 1e, **not yet implemented**: the design is
* pinned by the skipped suite in tests/fill.test.ts). Stored in the DO
* a fill job's progress (goal 1e; tests/fill.test.ts). Stored in the DO
* (new state, additive), so it survives eviction; advanced by alarms.
*/
export type FillStatus = {
@@ -132,21 +138,91 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
/**
* start (or rejoin) the fill job: walk every diigo page into this
* cache, `pagesPerStep` per alarm (default 20, 1–50). Starting while a
* job runs returns that job unchanged. Goal 1e; not yet implemented.
* job runs returns that job unchanged; a finished, stopped, or failed
* job is replaced by a fresh one (cached pages are skipped anyway).
*/
async startFill(opts: { pagesPerStep?: number } = {}): Promise<FillStatus> {
void opts;
throw new Error("startFill: not implemented (goal 1e)");
const current = await this.fillStatus();
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 status: FillStatus = {
state: "running",
nextPage: 1,
pages: this.#knownPages(),
fetched: 0,
startedAt: now,
updatedAt: now,
};
await this.ctx.storage.put({ fill: status, fillStep: step });
await this.ctx.storage.setAlarm(Date.now() + FILL_STEP_DELAY_MS);
console.log({ event: "fill", kind: this.kind, state: status.state, pagesPerStep: step });
return status;
}
/** the fill job's progress, or undefined if none was ever started */
/** the fill job's progress, or undefined if none was ever started;
* the page count comes from any cached page until the job learns it */
async fillStatus(): Promise<FillStatus | undefined> {
throw new Error("fillStatus: not implemented (goal 1e)");
const fill = await this.ctx.storage.get<FillStatus>("fill");
return fill && fill.pages === null ? { ...fill, pages: this.#knownPages() } : fill;
}
/** stop the fill job after its current step; cached pages stay */
async stopFill(): Promise<FillStatus> {
throw new Error("stopFill: not implemented (goal 1e)");
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() };
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 });
return stopped;
}
/** one fill step: up to `fillStep` pages, progress saved after each,
* then the next step is scheduled (or the job ends) */
async alarm(): Promise<void> {
const step = (await this.ctx.storage.get<number>("fillStep")) ?? 20;
let status = await this.fillStatus();
for (let i = 0; i < step && status?.state === "running"; i += 1) {
if (status.pages !== null && status.nextPage > status.pages) {
status = { ...status, state: "done" };
break;
}
try {
const page = await this.page(status.nextPage, PAGE_SIZE);
status = {
...status,
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(),
};
} catch (err) {
status = { ...status, state: "failed", error: String(err), updatedAt: new Date().toISOString() };
console.error({ event: "fill", kind: this.kind, state: "failed", page: status.nextPage, error: status.error });
}
// a stop that landed mid-step wins
if ((await this.fillStatus())?.state === "stopped") return;
await this.ctx.storage.put("fill", status);
}
if (!status) return;
if (status.state === "running" && status.pages !== null && status.nextPage > status.pages) {
status = { ...status, state: "done", updatedAt: new Date().toISOString() };
}
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);
}
/** diigo's page count, if any cached page recorded `total` */
#knownPages(): number | null {
this.ctx.storage.sql.exec(SCHEMA_V1);
const row = this.ctx.storage.sql
.exec<{ total: number | null }>("select total from pages where total is not null order by etched_at desc limit 1")
.toArray();
return row.length > 0 && row[0].total !== null ? Math.max(1, Math.ceil(row[0].total / PAGE_SIZE)) : null;
}
/**