From 00bf7193d6ba0f13c01bbb5666a6e72505683f87 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 17 Oct 2026 23:41:24 +0000 Subject: [PATCH] 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. --- packages/durable/CHANGELOG.md | 2 +- packages/durable/README.md | 8 ++++--- packages/durable/docs/spec.md | 7 +++---- packages/durable/src/timers.ts | 28 ++++++++++++------------- packages/durable/src/types.ts | 3 ++- packages/durable/test-workers/worker.ts | 4 +++- 6 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 816836c7a..d10e824cc 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -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 diff --git a/packages/durable/README.md b/packages/durable/README.md index 4c68184ed..a47744b35 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -165,13 +165,15 @@ export class Session extends DurableObject { // 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 { - 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 diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index e8ebd993f..204d429df 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -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: diff --git a/packages/durable/src/timers.ts b/packages/durable/src/timers.ts index 0e270c9b4..f7c9b6fd9 100644 --- a/packages/durable/src/timers.ts +++ b/packages/durable/src/timers.ts @@ -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; - const arm = (remaining: number): boolean => { - handle = setTimeout( - () => (remaining > MAX_TIMER_DELAY ? arm(remaining - MAX_TIMER_DELAY) : callback()), + const arm = (remaining: number): ReturnType => { + 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); diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index a07657a2e..c639eee2d 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -134,10 +134,11 @@ export type TaskDocFamilyToken = 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, diff --git a/packages/durable/test-workers/worker.ts b/packages/durable/test-workers/worker.ts index 888f298af..4dbcb78a1 100644 --- a/packages/durable/test-workers/worker.ts +++ b/packages/durable/test-workers/worker.ts @@ -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 { + 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. */