fix(durable): arm before resume in the Durable Object example; timer wording

The README example and the test worker arm the recovery alarm before resume() in the constructor and in
alarm(). Timers should not fire early, and callers recheck; nativeTimer no longer needs a non-null assertion.
This commit is contained in:
Mario Zechner 2026-10-17 23:41:24 +00:00
parent ec1be23eb9
commit 00bf7193d6
6 changed files with 28 additions and 24 deletions

View file

@ -6,7 +6,7 @@
- `TaskRuntime.sleep()` is removed: it waited inside a phase, which holds an invocation and, after a restart, reruns the phase and restarts the wait. A task now ends its phase asleep with `runtime.sleepUntil(until, checkpoint, context)` or by returning `{ status: "sleeping", until, checkpoint }` from `runtime.commit()`; move the work after the wait into the next phase and carry what it needs in the checkpoint. `TaskRuntime.setTimer()` paces work inside a phase without waiting for it.
- Tasks have a `sleeping` status, so code that handles every `TaskState`, `TaskGraphState`, or `TaskInspection` state needs a case for it.
- Opening a SQLite storage migrates it to schema version 2 automatically, rebuilding its task table. A migrated database cannot be opened by 1.3.0 or earlier, so a deployment cannot roll back past this release. JSONL storage needs no migration, but 1.3.0 or earlier never resumes a sleeping task it finds, so its conversation stays busy.
- Opening a SQLite storage migrates it to schema version 2 automatically, rebuilding its task table. A migrated database cannot be opened by 1.3.0 or earlier, so a deployment cannot roll back past this release. JSONL storage needs no migration, but 1.3.0 or earlier never resumes a sleeping task it finds (a run's conversation stays busy).
- `TaskRuntime` has `sleepUntil()` and `setTimer()`, and `ToolExecutionApi` has `now()`: code that builds either object by hand, such as a test fake or a tool wrapper that does not spread the original, must add them.
### Added

View file

@ -165,13 +165,15 @@ export class Session extends DurableObject<Env> {
// A failed alarm write resets the object; it arms again when it next starts.
void this.#arm(wake).catch(() => ctx.abort("Alarm write failed"));
this.#harness = await Harness.open(storage, { models, registry, onWake }, context);
this.#harness.resume();
// Armed before `resume()` runs anything, as for any call that can start work.
await this.#arm(this.#harness.wake());
this.#harness.resume(); // work a lost instance left, and due sleeps
});
}
override async alarm(): Promise<void> {
this.#harness.resume(); // resumes due sleeps and work a lost process left
await this.#arm(this.#harness.wake());
await this.#arm({ ...this.#harness.wake(), active: true });
this.#harness.resume(); // due sleeps and work a lost instance left
}
// Arm before any call that can start work (submit, compact, abortTask, abort, a registry change): `onWake` runs

View file

@ -2363,11 +2363,10 @@ when `nodejs_compat` gives it an `unref()`, and wherever timers cannot be
unreferenced. Foreground timers pace or resume work: throttled
progress commits through `TaskRuntime.setTimer()`, and, without `onWake`, the
timer for the earliest sleep while scheduling runs. That one keeps a Node.js
process alive, since a sleeping task is work left to do. A timer must not fire
before its delay and may fire late; the default splits a delay beyond what
`setTimeout` supports itself. A host that throws on or declines a foreground
process alive, since a sleeping task is work left to do. A timer should not fire
early and may fire late; every caller checks its clock again when it fires. The
default waits out a delay beyond what `setTimeout` supports in steps. A host that throws on or declines a foreground
timer has it armed with `setTimeout` instead, and the first failure is reported.
Callers still check their clock when a timer fires.
**Wakeups.** `Harness.wake()` says, from memory and also while scheduling is
paused, when the Session next needs a running process:

View file

@ -10,26 +10,26 @@ const MAX_TIMER_DELAY = 2_147_483_647;
const inWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";
/**
* The default `HarnessOptions.setTimer`: `setTimeout`, never firing early, also for delays beyond its limit. A
* background timer is unreferenced, so it never keeps the process alive. It is declined in Cloudflare Workers and
* wherever timers cannot be unreferenced.
* The default `HarnessOptions.setTimer`: `setTimeout`, waiting out delays beyond its limit in steps. A background timer
* is unreferenced, so it never keeps the process alive. It is declined in Cloudflare Workers and wherever timers cannot
* be unreferenced.
*/
export const nativeTimer: SetTimer = (callback, delayMs, options) => {
if (options.background && inWorkers) return undefined;
let handle: ReturnType<typeof setTimeout>;
const arm = (remaining: number): boolean => {
handle = setTimeout(
() => (remaining > MAX_TIMER_DELAY ? arm(remaining - MAX_TIMER_DELAY) : callback()),
const arm = (remaining: number): ReturnType<typeof setTimeout> => {
const next = setTimeout(
() => {
if (remaining > MAX_TIMER_DELAY) handle = arm(remaining - MAX_TIMER_DELAY);
else callback();
},
Math.min(remaining, MAX_TIMER_DELAY),
);
if (!options.background) return true;
const unref = (handle as { unref?: unknown }).unref;
if (typeof unref !== "function") return false;
unref.call(handle);
return true;
if (options.background) (next as { unref?: () => void }).unref?.();
return next;
};
if (!arm(Math.max(0, delayMs))) {
clearTimeout(handle!);
let handle = arm(Math.max(0, delayMs));
if (options.background && typeof (handle as { unref?: unknown }).unref !== "function") {
clearTimeout(handle);
return undefined;
}
return () => clearTimeout(handle);

View file

@ -134,10 +134,11 @@ export type TaskDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocF
>;
/**
* A host timer, given as `HarnessOptions.setTimer`: call `callback` once, no sooner than `delayMs` from now, and return
* A host timer, given as `HarnessOptions.setTimer`: call `callback` once, about `delayMs` from now, and return
* a function that disarms it. `background` timers only free memory, and a host may decline one by returning
* `undefined`, for example where a pending timer keeps the process from hibernating; the Harness then frees that memory
* at later commits. Other timers pace or resume work: one the host throws on or declines is armed with `setTimeout`.
* A timer should not fire early; callers check their clock again when it fires.
*/
export type SetTimer = (
callback: () => void,

View file

@ -71,6 +71,8 @@ export class TestSession extends DurableObject {
},
context,
);
// Armed before `resume()` runs anything, as for any call that can start work.
await this.arm(this.#harness.wake());
this.#harness.resume();
});
}
@ -94,8 +96,8 @@ export class TestSession extends DurableObject {
}
async alarm(): Promise<void> {
await this.arm({ ...this.#harness.wake(), active: true });
this.#harness.resume();
await this.arm(this.#harness.wake());
}
/** The alarm a host arms for `wake`: the earliest sleep, or a recovery heartbeat while work can run. */