From b9f2b3e827daf6677aa835921a7b9c060454889e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 10 Oct 2026 17:36:18 +0000 Subject: [PATCH] docs(durable): read only through tx inside a commit callback Session, Harness, and runtime reads such as snapshot(), documentState(), watchDoc(), and getTask() may queue on the mutation line a commit callback holds; on a cache miss they never resolve. Documented on Session.commit, TaskRuntime.commit, and in the spec's pitfalls. --- packages/durable/docs/spec.md | 5 +++++ packages/durable/src/types.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index 725c81f98..635eddf75 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -5004,6 +5004,11 @@ These are contracts, not invitations to add defensive machinery: - **Long transactions:** an async commit callback holds the Session mutation line. Never await models, tools, processes, network calls, humans, a nested Session commit, or a Session waiter inside it. Use methods on the current `Tx`. +- **Reads inside a commit:** `snapshot()`, `documentState()`, `watchDoc()`, + `getTask()`, and the other Session, Harness, and runtime reads may queue on the + mutation line, for example to load a document not in memory. Inside a commit + callback, which holds that line, such a read never resolves, and the line stays + held. Only a cache hit returns, so the hang is intermittent. Read through `tx`. - **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot, state, and watch lookup return `undefined` instead. - **Family initialization:** the first acquisition of an absent family address diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index c54981ba3..804e49ef1 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -191,7 +191,7 @@ export interface TaskRuntime extends DocumentObserver * Commit on the Session line after rereading the task. Rejects when the task is terminal, the invocation ended, the * Harness is closing, or, in a run invocation, the task carries an abort mark. A returned state replaces the task's * state in the same commit; returning nothing leaves it unchanged. `tx.createTask()` defaults to the task's - * conversation. + * conversation. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. */ commit( change: ( @@ -948,7 +948,9 @@ export type SessionEnd = { readonly reason: "closed" } | { readonly reason: "fai /** Owner of one mutation line, its records, and its tracked documents. */ export interface Session extends DocumentObserver { - /** Run one atomic transaction on the Session mutation line. */ + /** + * Run one atomic transaction on the Session mutation line. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. + */ commit(change: (tx: Tx) => T | Promise, context: Context): Promise; /** Seal admission, settle admitted commits, then close storage. */ close(context: Context): Promise;