Chương 18Phần 5: Chord và Pi Durable
Durable: session, commit, document và storage
Session với dòng commit nguyên tử, transaction, fork, giao diện Storage và năm backend.
Trong chương này21 mục
- Vai trò: tầng nằm dưới Harness
- Mô hình tư duy: đường đi của một commit
- Session: một dòng commit nguyên tử
- Transaction: một commit chứa gì
- Bên trong document: tracker, base, delta
- Fork: thấy entry của cha mà không sao chép
- Quan sát: CommittedStateSource và CommittedWatch
- Giao diện Storage và scan
- MemoryStorage: ngữ nghĩa tham chiếu
- Lược đồ SQLite
- SQLite: commit, adapter và prefix
- JSONL
- IndexedDB
- Bộ kiểm thử tuân thủ và benchmark
- Lỗi: ba quy tắc và code thực thi chúng
- Cloudflare Durable Object
- Ví dụ: một commit ghi những dòng nào
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- Tóm tắt
Chương trước dựng mô hình khái niệm của Pi Durable từ phía người dùng thư viện: hội thoại, entry, document, submission, agent. Chương này đi xuống một tầng, tới phần bảo đảm lời hứa "lưu trước, hiển thị sau": Session với dòng commit duy nhất, transaction và cách nó gom một commit, document bên trong (tracker, base, delta, lịch sử), fork, quan sát, giao diện Storage và năm backend, các quy tắc lỗi, cách chạy trên Cloudflare Durable Object, và cuối cùng là những dòng cụ thể một commit ghi xuống đĩa. Task và scheduler nằm ở chương task; delta và state nhân bản của Chord nằm ở chương Chord.
Vai trò: tầng nằm dưới Harness#
Harness là một SessionImpl có thêm scheduler, submission và view (harness.ts:207). Session cũng dùng được một mình qua createSession(storage, options), như các ví dụ 00 đến 05: không có task chạy, không có generation, chỉ có bản ghi, document và commit (session.ts:71–87).
/** * Session kernel: one mutation line, the loaded document tracker cache, and committed publication. * * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation * line, so no transaction ever holds a tracker that leaves. * * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and * publication enqueue runs while the line is held; listeners run later. * * Storage failures are final. Every Storage call goes through one guard: the first error a Storage method throws, other * than for a cancelled caller, fails the Session, as does a commit it cannot adopt. That call gets the error; every later * call and every Storage call still underway gets `SessionFailed`; close listeners run at once with the error, and the * Session then closes itself, Storage included, and `closed` settles. Nothing retries: transient errors are the * Storage's to retry. Reopening recovers from what was committed. */export class SessionImpl implements Session {Bên dưới Session là Storage, ranh giới lưu trữ. Hợp đồng chia việc rõ ràng: Session chịu trách nhiệm về ý nghĩa (bản ghi hợp lệ, tổ tiên đúng, chuyển trạng thái hợp lệ) và tuần tự hoá các commit; Storage chịu trách nhiệm về tính nguyên tử, quyền sở hữu ID toàn cục, tính bất biến của hội thoại và entry, sự nhất quán của bản ghi document, và trả về giá trị đã tách (detached) khỏi bộ nhớ của nó (types.ts:1166–1178). Vì Session đã giữ một dòng commit, backend không cần thêm mutex phía người gọi (spec.md:5231–5233).
Mô hình tư duy: đường đi của một commit#
session.commit(callback) hoặc conversation.commit, runtime.commit, api.commit
└─ #enqueue ── dòng commit: một promise chain, mỗi lúc một việc
└─ #runCommit
1 new Transaction(host, context, scope)
2 await callback(tx) tx.appendEntry · tx.doc · tx.createTask · tx.settleSubmission ...
│ đọc bảng trước, ghi bảng sau; draft document là của riêng tx
├─ ném lỗi ──────────────▶ settleFailure(): huỷ mọi draft, không ghi gì
3 tx.settleSuccess() niêm phong; prepare() mọi draft → giá trị mới + Op[]
│ #assemble(): kiểm tra, rồi checkpointWhen → base hay delta
└─ không có gì để ghi ───▶ discard(), xong
4 storage.commit(writes) một lô nguyên tử → Seq
└─ ném lỗi ──────────────▶ Session thất bại (guard), không công bố gì
5 tx.adopt(seq) đổi con trỏ tracker sang giá trị đã chuẩn bị
6 #publish(seq, ...) listener nội bộ (scheduler, submission, view) rồi listener của host,
chạy đồng bộ chỉ để chụp giá trị bất biến
7 thả dòng; callback của watch và document state chạy sau, ngoài dòng
Ba từ vựng của chương: dòng commit (mutation line) là hàng đợi tuần tự mà mọi commit và mọi lần đọc nhiều bước đều đi qua; publication là tập thay đổi bất biến của một commit thành công (CommitPublication, types.ts:900–904); incarnation là một kiếp sống của một document, từ lúc tạo tới lúc nghỉ hưu, có ID riêng không bao giờ dùng lại.
Session: một dòng commit nguyên tử#
"Một dòng commit" nghe như một khái niệm lớn, nhưng hiện thực chỉ là một chuỗi promise:
#enqueue<T>(job: () => Promise<T>): Promise<T> { const run = this.#tail.then(job); this.#tail = run.then( () => undefined, () => undefined, ); return run;}commitWith<T>(change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope): Promise<T> { try { this.assertUsable(); } catch (error) { return Promise.reject(error); } return this.#enqueue(() => this.#runCommit(change, context, scope));}/** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */readOnLine<T>(job: () => Promise<T>): Promise<T> { try { this.assertUsable(); } catch (error) { return Promise.reject(error); } return this.#enqueue(async () => { this.#assertHealthy(); return job(); });}commitWith() là bản nội bộ của commit(), nhận thêm scope (hội thoại mặc định cho createTask() và task được đóng dấu byTaskId). readOnLine() dùng khi một lần đọc gồm nhiều truy vấn, như dựng mount view hay chụp biên ngữ cảnh, để chúng thấy cùng một trạng thái.Toàn bộ một commit nằm trong #runCommit(). Nửa đầu chạy callback và gom lô ghi:
async #runCommit<T>( change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope,): Promise<T> { this.#assertHealthy(); context.abortSignal?.throwIfAborted(); const tx = new Transaction(this.#host, context, scope); let result: T; try { result = await change(tx); // A callback that caught a failed read must not commit, nor succeed as if it had. this.#assertHealthy(); } catch (error) { await tx.settleFailure(); this.#sweepDocuments(); throw error; } let writes: readonly StorageWrite[]; try { writes = await tx.settleSuccess(); } catch (error) { // Settlement aborted every change already. this.#sweepDocuments(); throw error; } if (writes.length === 0) { tx.discard(); this.#sweepDocuments(); return result; }context.abortSignal?.throwIfAborted() chỉ được kiểm tra trước khi bắt đầu; đây là điểm nhận (admission) của commit. Sau đó kiểm tra sức khoẻ lần nữa: một callback đã bắt và nuốt lỗi đọc storage không được commit như thể không có gì. Commit không có gì để ghi thì không tới storage, không tốn số thứ tự Seq.Nửa sau ghi lô, nhận nó vào bộ nhớ, rồi công bố:
let seq: Seq; try { // Once admitted, caller cancellation does not interrupt Storage settlement. seq = await this.#storage.commit(writes, withoutAbortSignal(context)); } catch (error) { // The guard has failed the Session. tx.discard(); throw error; } let documents: DocumentCommitChange[]; try { documents = tx.adopt(seq); } catch (error) { // Storage already committed; a failed adoption leaves memory behind durable state. this.fail(error); throw error; } for (const document of documents) this.#touchDocument(addressId(document.record)); this.#publish(seq, writes, documents, context); this.#sweepDocuments(); return result;}withoutAbortSignal(context): một khi đã nhận, huỷ phía người gọi không cắt ngang việc ghi. Nếu storage ném lỗi, lớp chặn lỗi đã làm Session thất bại và không có gì được công bố. Nếu storage đã ghi mà việc nhận vào bộ nhớ (adopt) thất bại, bộ nhớ đã tụt sau storage, nên Session cũng thất bại. if (changes === undefined) return; for (const document of documents) changes.push(document); const publication: CommitPublication = { seq, changes }; // The commit is durable: a failing listener neither fails the commit nor skips the others. for (const listener of [...this.#internalListeners]) { contained( () => listener(publication, context), (error) => this.fail(error, context), ); } for (const listener of [...this.#commitListeners]) { contained( () => listener(publication, context), (error) => this.report(error, LISTENER, context), ); }}onReport. Commit đã bền thì vẫn đứng (spec.md:5330–5333). Ngay trước đó, publication phát sự kiện telemetry pi.durable.entry và pi.durable.submission (session.ts:776–801).Đây là chỗ hiện thực bất biến "chỉ state đã commit là quan sát được": view, watch, waiter của submission và scheduler đều tiến lên từ publication, và publication chỉ tồn tại sau khi storage.commit() trả về. Listener phải chạy đồng bộ và không được gọi API của Session; callback của watch và document state được lên lịch chạy sau (microtask), ngoài dòng (spec.md:1697–1711).
Đóng Session cũng đi qua dòng (session.ts:495–529): niêm phong việc nhận commit mới, chạy listener đóng, gọi beforeClose() (Harness dùng nó để báo hiệu và chờ mọi invocation của task, harness.ts:396–399), rồi một việc trên dòng xoá cache và đóng storage. Các commit đã nhận trước khi niêm phong vẫn hoàn tất. closed trả về { reason: "closed" } hoặc { reason: "failed", error }.
Transaction: một commit chứa gì#
Thứ một commit gửi xuống storage là một mảng StorageWrite:
/** One record or document mutation in an atomic storage commit. */export type StorageWrite = | { readonly type: "conversation"; readonly value: ConversationRecord } | { readonly type: "entry"; readonly value: EntryRecord } | { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> } | { readonly type: "submission"; readonly value: SubmissionRecord } | { readonly type: "document.create"; readonly record: DocumentCreate; readonly content: Extract<DocumentContent, { readonly kind: "base" }>; } | { readonly type: "document.copy"; readonly record: DocumentCreate; readonly source: DocumentCopySource } | { readonly type: "document.change"; readonly id: DocumentId; readonly content: DocumentContent; } | { readonly type: "document.retire"; readonly id: DocumentId };Transaction gom các lệnh đó trong lúc callback chạy. Trạng thái của nó cho thấy cách gom:
/** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */readonly #writes: StorageWrite[] = [];readonly #createdConversationIds = new Set<ConversationId>();readonly #forkSourceConversationIds = new Set<ConversationId>();readonly #forkSourceDocumentIds = new Set<DocumentId>();/** One entry per task touched by a public read, candidate write, or document-owner lookup. */readonly #tasksById = new Map<TaskId, TransactionTask>();/** Submissions created by this transaction, by ID. */readonly #submissions = new Map<SubmissionId, SubmissionRecord>();/** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */readonly #submissionChanges: { readonly id: SubmissionId; readonly change: SubmissionChange }[] = [];/** Write and publication plans of every staged incarnation, built during assembly. */readonly #plans: DocumentPlan[] = [];/** Every document acquisition or retirement marker in staging order. */readonly #documents: DocumentEntry[] = [];/** Latest transaction-local incarnation or retirement marker at each logical address. */readonly #latestDocumentByAddress = new Map<string, DocumentEntry>();#writes ngay khi gọi, theo đúng thứ tự gọi. Task và submission nằm trong map theo ID: ghi nhiều lần trong một commit thì chỉ bản ghi ứng viên cuối cùng được gửi. Document được lên kế hoạch lúc niêm phong.Đọc bảng trước, ghi bảng sau#
#read<T>(method: string, read: () => Promise<T>): Promise<T> { try { this.#assertOpen(); if (this.#hasTableWrite) throw new ReadAfterWrite(method); return this.#track(read()); } catch (error) { return Promise.reject(error); }}#write<T>(write: () => Promise<T>): Promise<T> { try { this.#assertOpen(); this.#hasTableWrite = true; return this.#track(write()); } catch (error) { return Promise.reject(error); }}Vì sao có quy tắc này? Mọi lần đọc bảng trong transaction đi thẳng tới storage, tức trạng thái đã commit; các lần ghi trong transaction chưa nằm ở đó. Nếu cho đọc sau khi ghi, code sẽ dễ tưởng mình đọc được thứ mình vừa ghi. Thay vào đó, các hàm tạo trả về luôn bản ghi vừa tạo, và lần đọc sau lần ghi đầu tiên ném ReadAfterWrite (spec.md:1728–1734). Document không bị quy tắc này: draft của chính transaction là đọc-thấy-ghi (read-your-writes). Một số tra cứu nội bộ cũng không bị tính, như kiểm tra task chủ dựa trên bản ghi ứng viên mới nhất (transaction.ts:1003–1006), và settleSubmission(), placeSubmission(), vốn chỉ ghi lại một thay đổi để giải quyết lúc gom lô (transaction.ts:466–481). Đó là lý do prepareBoundary() phải được gọi đầu commit: nó đọc head marker, một lần đọc bảng.
Cấp ID và đóng dấu entry#
#appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> { return this.#write(async () => { await this.#requireConversation(conversationId); this.#assertOpen(); const id = await this.#host.storage.mintId<EntryId>(); this.#assertOpen(); const { head, ...rest } = value; // Undefined `head` and `byTaskId` are omitted by the copy. const record = copyJson( { ...rest, id, conversationId, head: head === "self" ? id : head, byTaskId: this.#scope.taskId, }, TABLE_JSON_COPY_OPTIONS, ) as unknown as EntryRecord; this.#writes.push({ type: "entry", value: record }); return record; });}copyJson(..., { omitUndefinedProperties: true }) vừa tách bản ghi khỏi đối tượng của người gọi vừa bỏ trường undefined, để bản ghi là JSON nghiêm ngặt. Bản ghi trả về là giá trị bất biến dùng chung với listener của commit.ID đến từ storage.mintId(), gọi bất đồng bộ vì một storage từ xa có thể cấp ID bền (spec.md:1359–1361). Các backend làm việc này khác nhau. MemoryStorage và SqliteStorage dùng một bộ đếm trong bộ nhớ (memory.ts:427–431, sqlite/storage.ts:212–216); SQLite ghi next_id xuống cùng mỗi commit. IndexedDbStorage ghi mỗi lần cấp trong một transaction riêng (indexeddb.ts:442–452). Hệ quả với SQLite: một ID đã cấp cho một transaction bị huỷ sau commit cuối cùng có thể được cấp lại sau khi mở lại; một ID đã commit thì không bao giờ, đúng bất biến "ID không bao giờ bị dùng lại sau một lần ghi đã commit" (spec.md:69). Hội thoại gốc không xin ID: createRootConversation() dùng ID 1 dành sẵn (transaction.ts:291–294), và idFromNumber() chỉ gắn nhãn kiểu, không làm gì lúc chạy (ids.ts:3–6).
tx.createTask() theo cùng khuôn: kiểm task chủ (tồn tại; không completing, không terminal, không bị đánh dấu huỷ; task con không được là task nền và phải ở cùng hội thoại với chủ), tính checkpoint ban đầu bằng definition.initial(input), cấp ID, và dựng bản ghi trạng thái pending (transaction.ts:388–451). Chủ được kiểm lại trên bản ghi ứng viên cuối cùng lúc gom lô, nên một task không thể tạo việc con trong chính commit kết thúc nó (transaction.ts:880–902).
Niêm phong và gom lô#
/** * Seal after callback success, prepare every open change, and assemble the atomic batch. * Any failure aborts every change before Storage admission. */async settleSuccess(): Promise<readonly StorageWrite[]> { this.#sealed = true; if (this.#pendingOperations.size > 0) { this.#abortChanges(); await Promise.allSettled(this.#pendingOperations); this.#context = BACKGROUND_CONTEXT; throw new Error("Session commit callback settled before its pending Tx operations"); } try { // Synchronously prepare every open change; this revokes every draft. for (const document of this.#documents) { if (document.change !== undefined) document.prepared = document.change.prepare(); } return await this.#assemble(); } catch (error) { this.#abortChanges(); throw error; } finally { this.#context = BACKGROUND_CONTEXT; }}tx còn treo (ví dụ một tx.doc() không được await), commit bị từ chối với "Session commit callback settled before its pending Tx operations". Đây là cách thực thi cạm bẫy "việc bắn rồi quên trên draft" của đặc tả (spec.md:5464–5467).for (const { id, change } of this.#submissionChanges) { const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context)); if (current === undefined) throw new Error(`Submission ${id} does not exist`); const next = applySubmissionChange(current, change); if (next !== current) this.#submissions.set(id, next);}const writes = this.#writes;for (const value of this.#submissions.values()) writes.push({ type: "submission", value });for (const task of this.#tasksById.values()) { if (task.write !== undefined) writes.push({ type: "task", value: task.write.record });}for (const plan of plans) { const change = plan.change; // Checkpoint predicates run last, after every validation. if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) { const { prepared, version } = change; const info = { deltasSinceBase: change.loaded.deltasSinceBase }; if (change.definition!.checkpointWhen(prepared.value, prepared.ops, info)) { plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } }; } } if (plan.content !== undefined) writes.push(plan.content); if (plan.retire) writes.push({ type: "document.retire", id: plan.record.id });}return writes;#assemble() lên kế hoạch document, chặn ghi vào nguồn của fork, kiểm chủ và trạng thái task, và cho mọi document của một task vừa terminal nghỉ hưu cùng nó (transaction.ts:784–848). Predicate checkpointWhen chạy cuối cùng, sau mọi kiểm tra, mỗi thay đổi đúng một lần.Vậy thứ tự trong một lô là: hội thoại và entry theo thứ tự gọi, rồi submission, rồi task, rồi lệnh document (nội dung trước, nghỉ hưu sau). Storage không được dựa vào thứ tự đó cho document: một lô có nhiều nhất một lệnh nội dung cho mỗi incarnation và nội dung luôn được áp trước nghỉ hưu, bất kể vị trí trong mảng (spec.md:5273–5278).
Bên trong document: tracker, base, delta#
Bài toán: pi.live đổi mỗi 100 ms trong lúc mô hình stream. Ghi lại cả giá trị JSON mỗi lần là tốn; chỉ ghi phần khác thì lúc đọc phải phát lại một chuỗi dài. Pi Durable giải bằng hai lớp. Trong bộ nhớ, mỗi document đã nạp có một tracker của Chord giữ revision bất biến hiện tại; một commit mở một change, sửa trên draft, rồi prepare() sinh ra giá trị mới cộng một lô phép toán (spec.md:1428–1466). Trong storage, mỗi thay đổi được lưu hoặc là một base (giá trị đầy đủ) hoặc một delta (lô phép toán), và đọc là lấy base mới nhất rồi áp các delta sau nó.
/** One committed document incarnation owned by the Session tracker cache. */export type LoadedDocument = { readonly addressId: string; readonly record: DocumentRecord; /** Persisted definition version; older while the tracked value is migrated only in memory. */ storedVersion: number; /** Definition version whose shape the tracked value has; access with another version reloads from Storage. */ readonly valueVersion: number; /** Stored deltas after the newest base; advanced by adoption so the next predicate call needs no read. */ deltasSinceBase: number; readonly tracker: Tracker<JsonObject>;};storedVersion có thể cũ hơn valueVersion khi giá trị mới chỉ được migrate trong bộ nhớ. deltasSinceBase được duy trì khi nhận commit, nên lúc đánh giá checkpointWhen không cần đọc storage.Cache này có giới hạn theo mức dùng, không theo lịch sử: một document không có ai quan sát rời bộ nhớ khi không được đọc hay ghi trong documentRetentionMs (mặc định 10 phút); document đang được quan sát thì không bao giờ rời; việc dọn chạy trên dòng commit nên không transaction nào mất tracker nó đang giữ (spec.md:1679–1695; session.ts:626–640).
Lấy draft: nạp hoặc tạo#
async #acquire(entry: DocumentEntry, seed: JsonValue | undefined, skipLoad: boolean): Promise<Draft<JsonObject>> { const definition = entry.definition!; const loaded = skipLoad ? undefined : await this.#host.load(definition, entry.addressId, entry.address, this.#context); this.#assertOpen(); if (loaded !== undefined) { checkRecordScope(definition, loaded.record); checkRecordVersion(definition, loaded.record, loaded.storedVersion); entry.target = { kind: "loaded", document: loaded }; entry.change = loaded.tracker.beginChange(); return entry.change.state; } const scope = entry.address.scope; if (scope.kind === "conversation") await this.#requireConversation(scope.conversationId); if (scope.kind === "task") { const task = await this.#currentTask(scope.taskId); if (task === undefined) throw new Error(`Task ${scope.taskId} does not exist`); if (task.state.status === "terminal") throw new Error(`Task ${scope.taskId} is terminal`); } this.#assertOpen(); const value = copyJson( definition.family === true ? definition.initial(seed) : definition.initial(), ) as JsonObject; const id = await this.#host.storage.mintId<DocumentId>(); this.#assertOpen(); const tracker = track(value); entry.target = { kind: "created", record: documentCreate(definition, entry.address, id), version: definition.version, tracker, }; entry.change = tracker.beginChange(); return entry.change.state;}tx.doc() tạo document; snapshot(), documentState() và watchDoc() trả undefined khi vắng. Lần lấy đầu tiên của một địa chỉ được ghi nhớ trước khi await, nên gọi tx.doc() hai lần trong một commit trả về cùng một draft (transaction.ts:536–565). Giá trị từ initial() được sao chép vào quyền sở hữu của kernel, và bị từ chối nếu không phải JSON nghiêm ngặt.Base hay delta#
case "loaded": { const loaded = target.document; const definition = document.definition!; const prepared = document.prepared!; const version = definition.version; const id = loaded.record.id; // A version change stores a base even without operations; otherwise only a change stores a delta. const content = loaded.storedVersion < version ? ({ type: "document.change", id, content: { version, kind: "base", value: prepared.value } } as const) : prepared.ops.length > 0 ? ({ type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } as const) : undefined; const change = { tracker: loaded.tracker, prepared, version, loaded, definition, }; return { ...plan, record: loaded.record, change, ...(content === undefined ? {} : { content }) };}document.create). Một document đã có, thay đổi rỗng, thì không ghi gì và không công bố gì.Delta chưa phải là quyết định cuối: ở bước cuối của #assemble() (xem excerpt ở mục trước), mỗi delta của document đã nạp được đưa qua checkpointWhen(value, ops, { deltasSinceBase }), và true thì nó thành base mang giá trị đầy đủ. Tạo mới và chuyển version luôn là base và không gọi predicate (spec.md:1542–1562). Đặc tả cho hai mẫu chính sách:
- chặn độ dài phát lại trực tiếp:
(_v, _ops, info) => info.deltasSinceBase >= 31; - checkpoint khi trống, cho document thay đổi dày:
(value) => value.generation === undefined && value.tools === undefined(spec.md:1578–1590), đúng là ý củapi.live.
Sau khi storage ghi xong, adopt() chỉ đổi con trỏ:
const change = plan.change;if (change !== undefined) { const { tracker, prepared, loaded, version } = change; // A new incarnation is adopted unless it retires in the same commit; a loaded one only when it changed. if (loaded === undefined ? !plan.retire : prepared.ops.length > 0) tracker.adopt(prepared); else prepared.abort(); if (loaded !== undefined) { if (loaded.storedVersion < version) loaded.storedVersion = version; if (plan.content?.type === "document.change") { if (plan.content.content.kind === "base") loaded.deltasSinceBase = 0; else loaded.deltasSinceBase++; } } else if (!plan.retire) { this.#host.install({ addressId: plan.addressId, record, storedVersion: version, valueVersion: version, deltasSinceBase: 0, tracker, }); }}tracker.adopt(prepared) không diff, không áp phép toán, không cấp phát: giá trị mới đã được tính lúc prepare(), trước khi gửi xuống storage. Giá trị đó vừa là revision của tracker, vừa là giá trị được công bố (spec.md:1461–1466).latest và rewindable trong storage#
Với document session, task và document hội thoại latest, một base đã commit cho phép storage vứt các bản ghi cũ hơn. Với document rewindable, base chỉ chặn độ dài phát lại, không bao giờ cho phép xoá lịch sử còn được tham chiếu (spec.md:1569–1574). Backend tham chiếu viết đúng hai dòng đó:
} else if (action.content !== undefined) { const revision = { ...action.content, seq } as DocumentRevision; if (revision.kind === "base" && isCurrentOnly(stored!.record)) stored!.revisions = [revision]; else stored!.revisions.push(revision);}if (action.retire && action.create === undefined) stored!.record = { ...stored!.record, retiredAt: seq };if (action.retire && isCurrentOnly(stored!.record)) stored!.revisions = [];DELETE FROM document_revisions (sqlite/storage.ts:922–925); JSONL viết lại file sidecar (mục JSONL).Đọc lịch sử: snapshotAsOf()#
snapshotAsOf(token, conversationId, at) trả giá trị của một document rewindable như nó là tại entry at, cụ thể là giá trị cuối cùng của commit chứa entry đó:
return this.#enqueue(async () => { this.#assertHealthy(); const storedEntry = await this.#storage.entry(conversationId, at, context); if (storedEntry === undefined) { throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`); } const address: DocumentAddress = { ...resolved.address, scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId }, }; const record = await this.#storage.findDocument(address, storedEntry.commitSeq, context); if (record === undefined) return undefined; const stored = await this.#storage.document(record.id, storedEntry.commitSeq, context); if (stored === undefined) { throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`); } return materializeDocument(definition, stored);});private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined { const stored = this.state.documents.get(id); if (stored === undefined) return undefined; if (at !== "current" && isCurrentOnly(stored.record)) { throw new StorageRequestError(`Document ${id} does not retain historical content`); } if (!isAliveAt(stored.record, at)) return undefined; const revisions = at === "current" ? stored.revisions : stored.revisions.filter((revision) => revision.seq <= at); let baseIndex = revisions.length - 1; while (baseIndex >= 0 && revisions[baseIndex]!.kind !== "base") baseIndex--; const base = revisions[baseIndex]; if (base?.kind !== "base") throw new Error(`Document ${id} is missing a required base`); const value = applyImmutableBatches( base.value, documentDeltaBatches(id, base.version, revisions, baseIndex + 1), ) as JsonObject; return { record: stored.record, version: base.version, value, deltasSinceBase: revisions.length - baseIndex - 1 };}createdAt <= at < retiredAt. Hỏi lịch sử của một document chỉ giữ hiện tại ném StorageRequestError, lỗi chỉ làm hỏng lần đọc đó. Thiếu base, hay delta vượt qua ranh giới version, là hỏng dữ liệu (spec.md:5259–5271).Version và migration#
Migration chạy khi truy cập, qua token được truyền vào: version lưu bằng version của token thì dùng luôn; cũ hơn thì gọi migrate(value, storedVersion); mới hơn, hoặc cũ hơn mà không có migrate, thì từ chối (documents.ts:178–208). Đọc chỉ migrate trong bộ nhớ, không bao giờ ghi; lần tx.doc() đầu tiên sau đó ghi một base ở version mới, kể cả khi giá trị không đổi (loaded.storedVersion < version trong planDocument()). Lịch sử rewindable không bao giờ bị viết lại; giá trị lịch sử được migrate sau khi phát lại (spec.md:1603–1625). Cache cũng tôn trọng version: một tracker chỉ phục vụ token cùng version với giá trị của nó; token khác version (ví dụ từ code extension vừa reload) làm cache bị bỏ và nạp lại từ storage (session.ts:865–894).
Fork: thấy entry của cha mà không sao chép#
Fork một hội thoại dài phải rẻ (không chép hàng nghìn entry) và nhất quán (nhánh con thấy đúng tiền tố của cha tại điểm fork, kể cả khi cha chạy tiếp). Pi Durable không chép entry nào. Bản ghi hội thoại con chỉ mang parent: { conversationId, at }, và mọi lần quét entry đi ngược chuỗi tổ tiên với một mức trần giảm dần:
private *visibleEntries( conversationId: ConversationId, minEntryId: number = Number.NEGATIVE_INFINITY, maxEntryId: number = Number.POSITIVE_INFINITY,): Generator<EntryRecord> { if (!this.state.conversations.has(conversationId)) { throw new StorageRequestError(`Unknown conversation: ${conversationId}`); } let currentId = conversationId; let upperEntryId = maxEntryId; while (true) { const ids = this.state.entryIds.get(currentId) ?? []; for (let index = upperBound(ids, upperEntryId) - 1; index >= 0; index--) { const id = ids[index]; if (id < minEntryId) break; yield this.state.entries.get(id)!; } const conversation = this.state.conversations.get(currentId)!; if (conversation.parent === undefined) break; upperEntryId = Math.min(upperEntryId, conversation.parent.at); if (upperEntryId < minEntryId) break; currentId = conversation.parent.conversationId; }}Hai quy tắc làm cho điều này đúng. Entry bất biến, nên tiền tố của cha tới at không bao giờ đổi. Và trong một Session, ID được cấp bên trong callback, tức trên dòng commit, nên chúng tăng theo thứ tự commit: "entry của cha có ID <= at" chính xác là "entry của cha tới lúc fork". (Bản thân storage chấp nhận entry được commit lệch thứ tự ID, và bộ kiểm thử tuân thủ có một ca cho việc đó, storage-conformance.ts:251; chỉ là Session không bao giờ làm vậy.) Nhánh con gồm các entry tới E kể cả khi cùng commit đó còn append entry sau E; state document tại E là state cuối cùng của commit chứa E (spec.md:1629–1633).
Document thì khác: chúng thay đổi, nên nhánh con cần bản riêng. Chính sách fork của từng document quyết định bản đó lấy từ đâu:
fork | Giá trị của nhánh con |
|---|---|
asOf | giá trị của cha tại commit của E (cần rewindable) |
current | giá trị đã commit của cha lúc commit fork chạy |
initial | không chép gì; lần truy cập đầu ở nhánh con gọi initial() |
/** Select every persisted conversation document copied by one fork. */export async function prepareForkDocumentCopies( storage: Storage, parentConversationId: ConversationId, at: EntryId, childConversationId: ConversationId, context: Context,): Promise<readonly ForkDocumentCopy[]> { const entry = await storage.entry(parentConversationId, at, context); if (entry === undefined) throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`); const copies: ForkDocumentCopy[] = []; const copiedAddresses = new Set<string>(); await collectCopies( storage, { kind: "conversation", conversationId: entry.entry.conversationId }, entry.commitSeq, "asOf", childConversationId, copies, copiedAddresses, context, ); await collectCopies( storage, { kind: "conversation", conversationId: parentConversationId }, "current", "current", childConversationId, copies, copiedAddresses, context, ); return copies;}collectCopies() quét các document còn sống trong phạm vi đó, giữ những cái có đúng chính sách, cấp ID mới và dựng bản ghi tạo cho nhánh con (forks.ts:61–97). Không cần định nghĩa document: document của extension không có mặt vẫn được chép nguyên version lưu (spec.md:1644–1649).Mỗi bản chép đi xuống storage dưới dạng lệnh document.copy mang bản ghi của con và một nguồn chính xác { id, at }. Storage tự hiện thực hoá nguồn và lưu thành base ban đầu, độc lập, của con, ở version lưu của nguồn (memory.ts:291–328; sqlite/storage.ts:871–890); một storage từ xa làm việc này phía server. Nếu ngay trong commit tạo fork có code truy cập document được chép, #acquireForkCopy() đọc nguồn và đổi bản chép thành một lệnh tạo bình thường (transaction.ts:649–674).
#rejectForkSourceWrites(plans: readonly DocumentPlan[]): void { for (const plan of plans) { if (plan.content === undefined && !plan.retire) continue; const record = plan.record; if (this.#forkSourceDocumentIds.has(record.id)) { throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`); } const scope = plan.record.scope; if ( scope.kind === "conversation" && this.#forkSourceConversationIds.has(scope.conversationId) && record.scope.kind === "conversation" && record.fork === "current" ) { throw new Error( `Cannot fork conversation ${scope.conversationId} while changing its current-policy documents`, ); } }}current của cha, sẽ có hai nguồn "hiện tại" khác nhau; thay vì chọn ngầm một cái, transaction từ chối. Hãy commit thay đổi của cha trước (spec.md:1651–1654).Với năm document dựng sẵn, kết quả là: pi.agent (asOf) được chép; pi.live, pi.inbox, pi.usage, pi.provider (initial) được hook tạo hội thoại tạo mới; và createAgent() trả về sớm cho fork để không đè bản chép (agent.ts:174–176). Task và document của task không bao giờ được chép. Ví dụ 02-forks.ts fork tại entry đầu tiên và cho thấy nhánh con thấy "hello" mà không thấy "goodbye", với ghi chú asOf đúng như lúc fork.
Quan sát: CommittedStateSource và CommittedWatch#
Chương trước giới thiệu viewState(), watch() và quy tắc 100 frame từ phía người dùng. Bên dưới, cả view lẫn document riêng lẻ đều dùng hai lớp trong observation.ts: CommittedStateSource làm nguồn cho một state nhân bản (replicated state) của Chord, và CommittedWatch làm watch tuần tự. Cả hai được cấp revision bất biến và lô phép toán chính xác của từng commit; không ai diff lại hay sao chép.
documentState() và watchDoc() nạp document trên dòng commit, rồi gắn observer trong cùng việc đó, nên ảnh chụp ban đầu và đăng ký nhận commit sau là nguyên tử: commit nào trước lúc gắn đã nằm trong ảnh chụp, commit nào sau thì được giao (spec.md:4704–4707). Phần gắn:
const observed = { version: loaded.valueVersion };unsubscribeCommit = this.observeCommits((publication, context) => { for (const change of publication.changes) { if (change.type !== "document" || change.record.id !== loaded.record.id) continue; // A document state's frames carry no caller cancellation; a watch observes its own cancellation. const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context; const ops = observedOperations(observed, change); // A migration-only base changes nothing for an observer of the new version. if (ops.length === 0) continue; // A retired incarnation never changes again: its observers keep their last value but pin nothing. if (change.value === null) unpin(); observer.advance(change.value, ops, frameContext); }});unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure));return { observer, detach };[["r", null]] rồi bỏ ghim; observer không bao giờ đi theo incarnation thay thế. Một observer dựng ở version khác nhận giá trị mới dưới dạng phép thay gốc thay vì phép toán cho hình dạng cũ (session.ts:934–942).CommittedStateSource hiện thực giao kèo nguồn của Chord: attach() trả một ảnh chụp cố định cộng con trỏ (cursor), activate(listener) cài listener duy nhất và xả mọi frame đã đệm (spec.md:4615–4650; observation.ts:25–78). Frame được giao qua microtask, không bao giờ ngay trong commit (observation.ts:103–119). Phần còn lại, gồm cursor liên tục và việc áp phép toán lên state phía client, là việc của Chord (chương Chord).
CommittedWatch thì tự giao, từng frame một, chờ listener xong mới giao frame sau:
async #drain(): Promise<void> { if (this.#running || this.#end !== undefined || !this.#started) return; this.#running = true; try { while (this.#end === undefined) { const frame = this.#pending.shift(); if (frame === undefined) break; this.#value = frame.value; const deliveryContext = withoutAbortSignal(frame.context); try { await this.#listener!(frame.value, frame.ops, deliveryContext); } catch (error) { if (this.#end === undefined) { this.#report(error); this.#terminate({ reason: "listener_error", error: toError(error) }); } break; } if (frame.value === null) { this.#terminate({ reason: "retired" }); break; } } } finally { this.#running = false; if (this.#end === undefined && this.#pending.length > 0) this.#schedule(); }}watch.value tiến tới revision của frame ngay trước khi callback được gọi. Listener ném lỗi kết thúc watch với listener_error và bỏ các frame đang chờ; frame null kết thúc nó với retired. Các lý do kết thúc khác (stopped, cancelled, session_closed, session_failed) nằm ở types.ts:1010–1015.Mount view của hội thoại dùng đúng hai lớp này (view.ts:110–140), chỉ khác là revision được dẫn xuất từ publication thay vì lấy thẳng từ một tracker. Khi Session đóng, mọi state và watch kết thúc tại lúc niêm phong: state giữ giá trị cuối, commit hoàn tất trong lúc đóng không được giao nữa (spec.md:4697–4698).
Giao diện Storage và scan#
Giao diện mà mọi backend phải hiện thực nhỏ một cách có chủ đích: một phương thức ghi, một phương thức cấp ID, các lần tra theo khoá, các lần quét theo cursor, ba phương thức cho document, và close(). "Ordered scans are cursor-based. Exact identity lookups are keyed" (spec.md:5085).
export interface Storage { /** * Atomically persist one batch and return its sequence. Once resolved, later reads through this storage observe it. */ commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>; /** * Return a fresh branded candidate from the Session-global numeric ID namespace, greater than every ID minted or stored * before, also across reopen: scans order by ID as creation order, and the Harness relies on it. */ mintId<I extends Id<string>>(): Promise<I>; /** Look up one conversation by exact ID. */ conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>; /** Scan conversations in `query.order` (default ascending) by ID. */ scanConversations( query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context, ): Promise<Page<ConversationRecord, Cursor>>; /** Resolve the incarnation occupying one exact logical address at the selected point. */ findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>; /** Materialize one specific incarnation by ID at the selected point without following a replacement at its address. */ document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>; /** Scan incarnations alive in one exact scope at the selected point. */ scanDocuments( query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context, ): Promise<Page<DocumentRecord, Cursor>>;submissionByRequest() phục vụ requestId (types.ts:1201–1256). entry(conversationId, id) chỉ trả về khi entry nhìn thấy được qua tổ tiên của hội thoại, kèm commitSeq mà việc đọc lịch sử document cần./** Complete checkpoint or Chord operation batch selected by the owning Session. */export type DocumentContent = | { readonly version: number; readonly kind: "base"; readonly value: JsonObject; } | { readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[]; };/** Exact persisted source selected for a definition-free document copy. */export type DocumentCopySource = { readonly id: DocumentId; readonly at: DocumentPoint;};/** Detached materialized value and stored definition version at a selected point. */export type StoredDocument = { readonly record: DocumentRecord; readonly version: number; readonly value: JsonObject; /** Deltas replayed after the selected base to materialize `value`. */ readonly deltasSinceBase: number;};DocumentContent đi vào và StoredDocument đi ra, cùng DocumentPoint là Seq | "current" (types.ts:805–821).Các truy vấn quét có bộ lọc vừa đủ cho Harness: hội thoại theo task chủ hoặc hội thoại chủ, entry theo khoảng ID, task theo hội thoại, kind, trạng thái, cờ huỷ, cờ nền, submission theo hội thoại và trạng thái (types.ts:764–803). Bộ lọc chủ phải có chỉ mục, vì việc duyệt cây sở hữu (huỷ cả cây, chờ cả cây rảnh) dựa vào chúng thay vì quét mọi hội thoại (spec.md:5243–5245). Cursor là JSON của backend, người gọi chỉ đưa lại cho đúng lần quét đó trên đúng storage đó; nó mang theo chiều quét:
export function scanStart( requested: ScanOrder | undefined, cursor: Readonly<Record<string, unknown>> | undefined, fallback: ScanOrder,): ScanStart { if (requested !== undefined && requested !== "ascending" && requested !== "descending") { throw new StorageRequestError(`Invalid scan order: ${String(requested)}`); } if (cursor === undefined) return { order: requested ?? fallback, after: undefined }; const { after, order: stored } = cursor; if (typeof after !== "number" || !Number.isSafeInteger(after)) throw new StorageRequestError("Invalid storage cursor"); if (stored !== undefined && stored !== "ascending" && stored !== "descending") { throw new StorageRequestError("Invalid storage cursor"); } const order = stored ?? fallback; if (requested !== undefined && requested !== order) { throw new StorageRequestError(`The cursor continues a ${order} scan; the query asks for ${requested}`); } return { order, after };}/** The continuation of a scan whose last returned item has `id`. */export function nextCursor(id: Id<string>, order: ScanOrder): { readonly after: number; readonly order: ScanOrder } { return { after: id, order };}StorageRequestError: lỗi của một lần đọc không hợp lệ, không có hậu quả bền, chỉ làm hỏng lần đọc đó (mục Lỗi). Quy tắc chiều quét được thêm ở 1.1.0 như một thay đổi phá vỡ cho backend tự viết (CHANGELOG.md:103).Ngoài các chữ ký, hợp đồng còn đòi vài điều backend phải tự kiểm khi commit: một ID chỉ thuộc một bảng; hội thoại, entry và document chỉ được tạo một lần; mỗi địa chỉ logic có nhiều nhất một incarnation hiện tại; delta không được vượt ranh giới version (memory.ts:733–815). Và mọi lần đọc trả về giá trị đã tách: chuỗi bất biến được dùng chung, mảng và object thì không (spec.md:1736–1743).
MemoryStorage: ngữ nghĩa tham chiếu#
Năm backend đi kèm (README.md:761–767):
| Backend | Mở bằng | Ghi chú |
|---|---|---|
| Memory | new MemoryStorage() (gốc package) | Không lưu gì; ngữ nghĩa tham chiếu |
| SQLite | openNodeSqliteStorage(file) | Một file; WAL với synchronous = NORMAL |
| JSONL | openNodeJsonlStorage(dir, context) | File chỉ-append trong một thư mục; giữ toàn bộ lịch sử trong bộ nhớ |
| IndexedDB | await IndexedDbStorage.open(name) | Chỉ trình duyệt |
| Durable Object | openDurableObjectSqliteStorage(ctx.storage) | SQLite của một Durable Object; commit là transaction của nó |
MemoryStorage là ngữ nghĩa tham chiếu: nó sao chép giá trị ghi được giữ lại và mọi kết quả đọc, cố ý mô phỏng ranh giới sở hữu mà mã hoá/giải mã của SQLite và JSONL tự nhiên tạo ra; đây là để tuân thủ, không phải để phòng thủ (spec.md:5383–5387). Cách nó đạt tính nguyên tử là mẫu đáng học:
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { return this.prepareCommit(writes).apply();}/** Validate and detach one commit without changing observable state. */prepareCommit(writes: readonly StorageWrite[], seq: Seq = seqFromNumber(this.nextSeq)): PreparedMemoryCommit { this.assertOpen(); if (!Number.isSafeInteger(seq) || seq < this.nextSeq) { throw new Error(`Commit sequence ${seq} does not strictly increase`); } const detachedWrites = freeze(this.resolveDocumentCopies(writes.map((write) => clone(write)))); this.checkGlobalIds(detachedWrites); const documentActions = this.prepareDocumentActions(detachedWrites); this.checkDocumentActions(documentActions); let applied = false; return { seq, writes: detachedWrites, apply: () => { if (!applied) { applied = true; this.applyPreparedCommit(detachedWrites, documentActions, seq); } return seq; }, };}prepareCommit() sao chép và đóng băng lô, giải các lệnh document.copy, kiểm quyền sở hữu ID và lệnh document, mà không chạm state. apply() chỉ còn việc cập nhật map và chỉ mục, không có bước nào có thể thất bại giữa chừng. Cách chia hai bước này còn được JSONL dùng lại: nó chuẩn bị bằng MemoryStorage, ghi file, rồi mới apply().State bên trong là các map theo ID cộng các mảng ID đã sắp xếp làm chỉ mục: theo hội thoại, theo chủ, theo trạng thái, theo request ID, theo địa chỉ và phạm vi document (memory.ts:72–92). ID bắt đầu từ 2, Seq từ 1 (memory.ts:259–260).
Lược đồ SQLite#
Tên bảng và chỉ mục là một lịch sử bất biến: không bao giờ đổi hay xoá giá trị đã có, đổi tên thì thêm mục mới, để migration cũ vẫn render đúng tên gốc (schema.ts:5–38). DDL của schema version 1 nằm trong createSqliteMigrations():
const initialSchema: readonly string[] = [ `CREATE TABLE ${tables.durableMetadata} ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), next_id TEXT NOT NULL, next_seq INTEGER NOT NULL ) STRICT`, `INSERT INTO ${tables.durableMetadata} (singleton, next_id, next_seq) VALUES (1, '2', 1)`, `CREATE TABLE ${tables.recordIds} ( id INTEGER PRIMARY KEY, record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document')) ) STRICT`, `CREATE TABLE ${tables.conversations} ( id INTEGER PRIMARY KEY, owner_conversation_id INTEGER, owner_task_id INTEGER, record TEXT NOT NULL CHECK (json_valid(record)) ) STRICT`, `CREATE INDEX ${indexes.conversationsByOwnerConversation} ON ${tables.conversations} (owner_conversation_id, id)`, `CREATE INDEX ${indexes.conversationsByOwnerTask} ON ${tables.conversations} (owner_task_id, id)`, `CREATE TABLE ${tables.entries} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, head INTEGER, commit_seq INTEGER NOT NULL, record TEXT NOT NULL CHECK (json_valid(record)) ) STRICT`, `CREATE INDEX ${indexes.entriesByConversation} ON ${tables.entries} (conversation_id, id DESC)`, `CREATE INDEX ${indexes.entryHeadsByConversation} ON ${tables.entries} (conversation_id, id DESC) WHERE head IS NOT NULL`,next_id là TEXT vì node:sqlite từ chối số nguyên vượt khoảng an toàn của JavaScript (chú thích ở dòng 13). record_ids hiện thực "một không gian ID toàn cục": mỗi ID ghi nhận bảng sở hữu nó. Chỉ mục một phần trên head IS NOT NULL giúp tìm head marker mới nhất bằng một lần tra.`CREATE TABLE ${tables.tasks} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')), abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)), background INTEGER NOT NULL CHECK (background IN (0, 1)), record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`,`CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`,`CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`,`CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`,`CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)`,`CREATE TABLE ${tables.submissions} ( id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, request_id TEXT, status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')), record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.submissionsByRequest} ON ${tables.submissions} (conversation_id, request_id)`,`CREATE INDEX ${indexes.submissionsByConversation} ON ${tables.submissions} (conversation_id, id)`,`CREATE INDEX ${indexes.submissionsByStatus} ON ${tables.submissions} (status, id)`,record dạng JSON, có CHECK (json_valid(record)). Ràng buộc trạng thái của version 1 chưa có sleeping.`CREATE TABLE ${tables.documents} ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, family INTEGER NOT NULL CHECK (family IN (0, 1)), key_value TEXT NOT NULL, scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')), owner_id INTEGER NOT NULL, created_at INTEGER NOT NULL, retired_at INTEGER, record TEXT NOT NULL CHECK (json_valid(record))) STRICT`,`CREATE INDEX ${indexes.documentsByAddress} ON ${tables.documents} (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`,`CREATE INDEX ${indexes.documentsByScope} ON ${tables.documents} (scope_kind, owner_id, id)`,`CREATE INDEX ${indexes.documentsByScopeKind} ON ${tables.documents} (scope_kind, owner_id, kind, id)`,`CREATE TABLE ${tables.documentRevisions} ( document_id INTEGER NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')), version INTEGER NOT NULL, content TEXT NOT NULL CHECK (json_valid(content)), PRIMARY KEY (document_id, seq)) STRICT`,`CREATE INDEX ${indexes.documentRevisionsByKind} ON ${tables.documentRevisions} (document_id, kind, seq DESC)`,documents giữ một hàng cho mỗi incarnation với các cột địa chỉ (kind, family, key, phạm vi, chủ) và vòng đời (created_at, retired_at là số Seq). document_revisions giữ base và delta theo (document_id, seq); chỉ mục (document_id, kind, seq DESC) tìm base mới nhất trước một seq.Schema version 2 dựng lại bảng tasks để thêm trạng thái sleeping, vì SQLite không đổi được ràng buộc CHECK tại chỗ (migrations.ts:91–116). Bộ chạy migration chung nằm trong Chord và chạy mọi migration còn thiếu trong một transaction, ghi version vào bảng durable_schema, và từ chối một database đã được migrate bởi bản mới hơn:
await database.transaction(async (transaction) => { await transaction.exec(`CREATE TABLE IF NOT EXISTS ${table} ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), version INTEGER NOT NULL CHECK (version >= 0) ) STRICT`); await transaction.run(`INSERT OR IGNORE INTO ${table} (singleton, version) VALUES (1, 0)`); const row = await transaction.get<SchemaRow>(`SELECT version FROM ${table} WHERE singleton = 1`); if (row === undefined) throw new Error("SQLite schema metadata is missing"); const currentVersion = migrations.at(-1)?.version ?? 0; if (row.version > currentVersion) { throw new Error(`SQLite schema version ${row.version} is newer than supported version ${currentVersion}`); } for (const migration of migrations) { if (migration.version <= row.version) continue; for (const statement of migration.statements) await transaction.exec(statement); await transaction.run(`UPDATE ${table} SET version = ? WHERE singleton = 1`, migration.version); }});SQLite: commit, adapter và prefix#
Một transaction SQL là một commit của Session (spec.md:5391):
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); const candidateNextId = this.candidateNextId(writes); const seq = await this.db.transaction(async (transaction) => { const metadata = await transaction.get<MetadataRow>( `SELECT next_id, next_seq FROM ${this.schema.tables.durableMetadata} WHERE singleton = 1`, ); if (metadata === undefined) throw new Error("Durable SQLite metadata is missing"); const committedSeq = seqFromNumber(metadata.next_seq); await this.checkGlobalIds(transaction, writes); await this.checkDocumentActions(transaction, documentActions); for (const write of writes) await this.applyTableWrite(transaction, write, committedSeq); await this.applyDocumentActions(transaction, documentActions, committedSeq); await transaction.run( `UPDATE ${this.schema.tables.durableMetadata} SET next_id = ?, next_seq = ? WHERE singleton = 1`, String(Math.max(Number(metadata.next_id), candidateNextId)), committedSeq + 1, ); return committedSeq; }); this.nextId = Math.max(this.nextId, candidateNextId); return seq;}Seq là next_seq đọc trong chính transaction, rồi tăng một. Kiểm quyền sở hữu ID và lệnh document chạy trước mọi lần ghi; một lỗi bất kỳ làm cả transaction rollback, nên chỉ mục và bảng không bao giờ lệch nhau (ca tuân thủ "rolls back record tables and secondary indexes when a document command fails", storage-conformance.ts:1478).Từng loại ghi đi vào bảng của nó (sqlite/storage.ts:795–854): hội thoại và entry là INSERT thuần (ghi trùng thì lỗi khoá chính, đúng tính bất biến), task và submission là INSERT ... ON CONFLICT(id) DO UPDATE vì chúng là bản ghi thay thế trọn. Mọi ID đi qua claimId() vào record_ids. Các chuỗi dùng làm khoá chỉ mục (kind, request_id, key_value) được lưu dưới dạng JSON string, vì một số binding SQLite thay thế ký tự surrogate lẻ, và mã hoá JSON giữ danh tính không mất mát (sqlite/storage.ts:74–75). Đọc một document gói hai truy vấn (bản ghi, rồi base và đuôi delta) trong một transaction để chúng thấy cùng một trạng thái (sqlite/storage.ts:549–553), và việc ghi revision cho document current-only xoá các revision cũ khi gặp base (sqlite/storage.ts:922–950).
Lõi khả chuyển và adapter#
SqliteStorage không gọi API của Node. Nó nói chuyện với một facade bất đồng bộ SqliteDatabase của Chord gồm exec, run, get, all, transaction, close; callback của transaction nhận một handle riêng, mọi việc trong transaction phải dùng handle đó, và adapter phải xếp hàng mọi thao tác khác cho tới khi transaction xong (chord/src/sqlite/database.ts:18–38; README.md:792–799). Hai adapter đi kèm:
- Node:
openNodeSqliteStorage(path, options)mở file bằngnode:sqlitequaopenNodeSqliteDatabase()của Chord (sqlite/node.ts:14–22), vốn bậtjournal_mode = WAL,synchronous = NORMALvà auto-checkpoint WAL mỗi 1.000 trang (chord/src/sqlite/node.ts:179–192). README nói rõ hệ quả: commit sống qua crash của tiến trình, nhưng commit mới nhất có thể mất khi mất điện hay hỏng máy chủ (README.md:765). - Cloudflare Durable Object:
openDurableObjectSqliteStorage(ctx.storage, options), xem mục Cloudflare bên dưới.
Prefix: chung database với bảng của host#
Từ 1.3.0, { prefix: "pi_" } đặt tiền tố cho mọi bảng và chỉ mục, để Pi chung một database với bảng của ứng dụng (CHANGELOG.md:48). Storage tự render tên đã có tiền tố vào SQL của nó, nên adapter không cần hỗ trợ gì (schema.ts:53–61). Tiền tố phải là một định danh SQLite, không được bắt đầu bằng sqlite_ (chord/src/sqlite/schema.ts:1–9); host còn có thể giữ thêm tiền tố riêng, như _cf_ trong Durable Object; đổi tiền tố không migrate dữ liệu cũ (README.md:773–777).
JSONL#
Backend JSONL giữ dữ liệu trong các file chỉ-append trong một thư mục, dùng khả năng FileSystem khả chuyển từ /env chứ không phải toàn bộ ExecutionEnv (spec.md:5410–5421):
main.jsonl: ghi bảng, bản ghi document, và đúng một marker cho mỗi commit;doc-<id>.jsonl: nội dung (base, delta) của một incarnation document;task-<id>.jsonl: các bản thay thế của một task còn sống.
type MainOperation = | Extract<StorageWrite, { readonly type: "conversation" | "entry" | "submission" | "document.retire" }> | { readonly type: "task"; readonly value: StoredTask } | { readonly type: "task.sidecar"; readonly id: TaskId; readonly ordinal: number } | { readonly type: "document.create"; readonly record: DocumentCreate; readonly ordinal: number } | { readonly type: "document.change"; readonly id: DocumentId; readonly ordinal: number };type MainMarker = { readonly format: typeof FORMAT_VERSION; readonly type: "commit"; readonly seq: Seq; readonly writes: readonly MainOperation[];};type SidecarPayload = | { readonly type: "task"; readonly value: StoredTask } | { readonly type: "document"; readonly id: DocumentId; readonly content: DocumentContent };type SidecarRecord = { readonly format: typeof FORMAT_VERSION; readonly type: "record"; readonly seq: Seq; readonly ordinal: number; readonly payload: SidecarPayload;};ordinal của bản ghi sidecar trong commit đó (jsonl/storage.ts:384–449).Giao thức công bố có ba bước: append mọi bản ghi sidecar, append một marker hoàn chỉnh liệt kê chúng, và chỉ công bố trong bộ nhớ sau khi marker được ghi thành công (spec.md:5423–5429). Code theo đúng thứ tự đó:
async commit(writes: readonly StorageWrite[], context: Context): Promise<Seq> { this.assertUsable(); const prepared = this.memory.prepareCommit(writes); const encoded = this.encodeCommit(prepared.seq, prepared.writes); const reclamations = this.planReclamations(prepared.writes, encoded); const sidecars = await Promise.all( [...encoded.sidecars].map(async ([file, content]) => ({ file, content, path: await this.resolveFile(file, context), })), ); for (const sidecar of sidecars) { const result = await this.fs.appendFile(sidecar.path, sidecar.content, context); if (!result.ok) throw this.poison(errorFromFile(`append to ${sidecar.file}`, result.error)); } if (this.fsync) { for (const sidecar of sidecars) { const result = await this.fs.flushFile(sidecar.path, context); if (!result.ok) throw this.poison(errorFromFile(`flush of ${sidecar.file}`, result.error)); } } const marker = await this.fs.appendFile(this.mainPath, encoded.marker, context); if (!marker.ok) throw this.poison(errorFromFile(`append to ${MAIN_FILE}`, marker.error)); const seq = prepared.apply(); this.adoptSidecarState(prepared.writes); await this.reclaimSidecars(reclamations, context); return seq;}MemoryStorage nghĩa là mọi kiểm tra ngữ nghĩa xảy ra trước khi chạm đĩa. Một lần append thất bại thì không biết đã ghi được bao nhiêu, nên storage bị đầu độc (poisoned): mọi lời gọi sau ném JsonlStoragePoisonedError cho tới khi mở lại (jsonl/storage.ts:837–845). Trên Session, lỗi đó lại làm Session thất bại như mọi lỗi storage khác.fsync và độ bền#
fsync mặc định là false (jsonl/storage.ts:77–80). Không có nó, JSONL bảo đảm nhất quán khi tiến trình crash, không bảo đảm sống qua mất điện hay lỗi kernel. Với { fsync: true }, mọi sidecar bị ảnh hưởng được flush trước khi append marker, để một marker sống sót không bao giờ đi trước dữ liệu sidecar của nó; main.jsonl không bị flush ở mỗi commit, nên commit cuối đã được xác nhận vẫn có thể mất (spec.md:5430–5441).
Thu hồi và khôi phục#
Sau khi marker đã công bố state, JSONL dọn dẹp như một việc bảo trì có thể làm lại: một base của document current-only viết lại sidecar của nó chỉ còn base đó; nghỉ hưu một document current-only hay kết thúc một task thì xoá sidecar. Việc viết lại đi qua một file tạm .reclaim rồi đổi tên (jsonl/storage.ts:451–533). Khi mở, recover() đọc lại mọi thứ (jsonl/storage.ts:535–765):
- Cắt dòng ráchByte cuối file không phải xuống dòng nghĩa là lần ghi cuối bị cắt giữa chừng; file bị cắt về dòng hoàn chỉnh cuối cùng (jsonl/storage.ts:793–804).
- Kiểm marker
seqtrongmain.jsonlphải tăng nghiêm ngặt; dòng hoàn chỉnh mà hỏng là lỗiJsonlCorruptionError, không phải dữ liệu bị bỏ qua. - Xác nhận sidecarMỗi bản ghi sidecar chỉ có hiệu lực khi một marker tham chiếu nó; đuôi chưa được xác nhận bị cắt bỏ; thiếu dữ liệu đã được xác nhận là hỏng dữ liệu và việc mở thất bại (jsonl/storage.ts:767–784).
- Phát lại vào bộ nhớMỗi marker được áp vào
MemoryStoragenội bộ với đúngseqcủa nó, rồi thu hồi những gì còn dở.
Adapter Node truyền một NodeExecutionEnv làm FileSystem (jsonl/node.ts:5–12); lõi /storage/jsonl chạy được ở nơi khác (Bun, Durable Object) với một FileSystem khác (README.md:790).
IndexedDB#
Backend mới nhất (thêm ở 1.4.0, CHANGELOG.md:16) chạy trong trình duyệt. Nó dựng các object store tương ứng một-một với bảng SQLite, kể cả record_ids và meta giữ nextId, nextSeq, với chỉ mục ghép cùng hình dạng (indexeddb.ts:335–375).
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); return this.transaction(ALL_STORES, "readwrite", async (transaction) => { const metaStore = transaction.objectStore(STORES.meta); const metadata = await requestResult(metaStore.get(META_KEY) as IDBRequest<MetaRow>); if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing"); const seq = seqFromNumber(metadata.nextSeq); await this.checkGlobalIds(transaction, writes); await this.checkDocumentActions(transaction, documentActions); for (const write of writes) await this.applyTableWrite(transaction, write, seq); await this.applyDocumentActions(transaction, documentActions, seq); let nextId = metadata.nextId; for (const write of writes) { const id = writeId(write); if (id !== undefined) nextId = Math.max(nextId, id + 1); } await requestResult(metaStore.put({ key: META_KEY, nextId, nextSeq: seq + 1 } satisfies MetaRow)); return seq; });}async mintId<I extends Id<string>>(): Promise<I> { this.assertOpen(); return this.transaction(STORES.meta, "readwrite", async (transaction) => { const store = transaction.objectStore(STORES.meta); const metadata = await requestResult(store.get(META_KEY) as IDBRequest<MetaRow>); if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing"); if (!Number.isSafeInteger(metadata.nextId)) throw new Error("ID space is exhausted"); await requestResult(store.put({ ...metadata, nextId: metadata.nextId + 1 })); return idFromNumber<I>(metadata.nextId); });}mintId() ở đây tự commit bộ đếm trong một transaction riêng, nên ID đã cấp không bao giờ được cấp lại kể cả khi transaction của Session bị huỷ.Một chủ duy nhất phải giữ database tại mỗi thời điểm; README khuyên bọc vòng đời của nó trong một Web Lock thay vì mở ở nhiều tab hay worker cạnh tranh, và gọi navigator.storage.persist() khi cần giữ dữ liệu lâu (README.md:769–771). Ghi chú của lớp nói đúng điều đó: "One owner must serialize access to each database" (indexeddb.ts:377).
Bộ kiểm thử tuân thủ và benchmark#
Một backend tự viết không cần đọc hết đặc tả để biết mình đúng: /testing xuất một bộ kiểm thử tuân thủ (conformance suite) độc lập với test runner.
/** Registers the runner-independent cases with a Vitest/Jest-compatible test runner. */export function registerStorageConformance( runner: StorageConformanceRunner, name: string, withStorage: StorageConformanceProvider,): void { const cases = createStorageConformance({ assertions: createExpectAssertions(runner.expect), withStorage, }); runner.describe(name, () => { for (const testCase of cases) runner.it(testCase.name, testCase.run); });}withStorage phải gọi và chờ callback đúng một lần cho mỗi ca, ví dụ mở storage, chạy, rồi đóng trong finally (README.md:801–815).Bộ kiểm thử có 25 ca, mỗi ca là một hợp đồng cụ thể, ví dụ ca đầu tiên kiểm ID 1 dành cho hội thoại gốc và việc tạo lại nó bị từ chối (storage-conformance.ts:98–105). Các ca khác phủ commit nguyên tử và rollback, giá trị đã tách, quét fork sâu qua mọi mức trần, quét hai chiều với cursor, document rewindable và incarnation nửa mở, bản chép document độc lập, base khi chuyển version, vòng đời create-plus-retire, chuỗi đặc biệt trong khoá chỉ mục, và từ chối mọi thao tác sau close(). Trong repo, bộ này chạy trên Memory, SQLite, JSONL, IndexedDB (qua fake-indexeddb), các biến thể "across reopen" của ba backend lưu bền, adapter Durable Object có prefix trên một storage giả lập bằng node:sqlite, và adapter đó trên Durable Object thật trong workerd (memory-storage.test.ts:8, sqlite-storage.test.ts:113–117, jsonl-storage.test.ts:123–125, indexeddb-storage.test.ts:106–108, sqlite-cloudflare.test.ts:10, test-workers/storage.test.ts:13; cấu hình workerd ở test-workers/vitest.config.ts:7). Câu "The semantic conformance suite covers memory, SQLite, and JSONL" của đặc tả (spec.md:5280) đã cũ so với code: IndexedDB và Durable Object cũng được phủ.
Cùng chỗ đó có bộ dữ liệu và kịch bản benchmark dùng lại được (storage-benchmark.ts:24–34): đọc (tra entry chính xác, quét trang 100 entry, quét task có lọc, tra địa chỉ document giữa nhiều document, đọc lịch sử trước và sau một base mới hơn, quét và tra head qua 8 tầng fork; storage-benchmark.ts:284) và ghi (commit một entry, 100 entry, một lô trộn entry/task/submission/document; :409). npm run bench:storage chạy chúng trên memory, SQLite và JSONL bằng vitest bench, kèm đo thời gian mở lại (storage.bench.ts:18; package.json:124); bench:storage:memory đo bộ nhớ ở quy mô 1k và 10k bản ghi.
Lỗi: ba quy tắc và code thực thi chúng#
README tóm cách xử lý lỗi bằng ba quy tắc, để host biết chuyện gì xảy ra mà không phải đọc code (README.md:821–825; bản đầy đủ ở spec.md:5282–5377). Phần này nối từng quy tắc với code thực thi nó.
/** A transaction read a table after its first table write. Read every required row before writing. */export class ReadAfterWrite extends Error { constructor(method: string) { super(`Tx.${method}() cannot read tables after the first table write`); this.name = "ReadAfterWrite"; }}/** * A Storage read rejected an invalid request, such as an unknown conversation or a cursor from another scan, with no * durable effect. Unlike any other error a Storage throws, it fails only that read, not the Session; from `commit()` it is * fatal like any other. */export class StorageRequestError extends Error { constructor(message: string) { super(message); this.name = "StorageRequestError"; }}/** * The Session failed, so nothing runs or commits any more. It fails when a Storage method throws, a commit cannot be * adopted, or the Harness's own bookkeeping throws (a scheduler commit, an internal commit listener, a registry whose * `snapshot()` throws). `cause` is that first error. Reopen the Session; reopening recovers from what was committed. */export class SessionFailed extends Error { constructor(cause: unknown) { super("Session failed after a storage error; close and reopen it", { cause }); this.name = "SessionFailed"; }}Quy tắc 1: lỗi storage kết thúc Harness#
Mọi thành phần của Session và Harness đọc ghi qua GuardedStorage, một lớp bọc quanh storage thật. Mọi lời gọi đi qua một hàm duy nhất:
/** * Run one backend call under the guard. `read` is a read's context; `commit()` and `mintId()` pass none, so nothing * exempts their errors: once a batch is admitted, whether it committed is unknown. A call still underway when another * fails the Session ends with `SessionFailed` too, whatever it returns: nothing it read or wrote is used. */async #call<T>(read: Context | undefined, run: () => Promise<T>): Promise<T> { this.#assertHealthy(); if (this.#closing) throw new Error("Session is closed"); let result: T; // An async wrapper, so a backend that throws synchronously is guarded too. const call = (async () => run())(); this.#underway.add(call); try { result = await call; } catch (error) { this.#assertHealthy(); // An invalid read, or one its caller cancelled, says nothing about the Storage; it fails that call only. const exempt = read !== undefined && (error instanceof StorageRequestError || read.abortSignal?.aborted === true); if (!exempt) this.#latch.fail(error); throw error; } finally { this.#underway.delete(call); } this.#assertHealthy(); return result;}SessionFailed, bất kể nó trả về gì, vì không thứ gì nó đọc hay ghi được dùng nữa./** * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its * scheduler's own commits. */fail(error: unknown, context?: Context): void { if (this.#failure !== undefined) return; this.#failure = { error }; this.#failed.reject(new SessionFailed(error)); // Seal first, so a report handler that calls back finds the Session failed. Closing runs the close listeners // synchronously; a failing backend close is the caller's to see, and here nobody waits. this.close(BACKGROUND_CONTEXT).catch(() => {}); this.report(error, { type: "session_failed" }, context);}onReport gọi ngược lại vào Harness thấy nó đã thất bại.Từ lúc đó: lời gọi gặp lỗi nhận chính lỗi đó; mọi lời gọi sau và mọi lần chờ đang treo nhận SessionFailed với cause là lỗi đầu (util.ts:57–61); watch và luồng sự kiện kết thúc với session_failed (observation.ts:219–224); task đang chạy nhận tín hiệu huỷ; onReport nhận lỗi một lần với type: "session_failed"; Session tự đóng và closed trả { reason: "failed", error }. Không có gì được thử lại; storage tự thử lại lỗi tạm thời bên trong phương thức của nó. Phục hồi là mở lại: commit nguyên tử nên storage nhất quán, và gửi lại với cùng requestId tìm thấy submission đã được nhận nếu commit kịp thành công. Host khởi động lại theo mẫu:
`harness.closed` settles once the Harness has closed, by `close()` or after a storage error, so a host restarts on failure like this:```typescriptharness.closed.then((end) => { if (end.reason === "failed") reopen(end.error);});```Quy tắc này có từ 1.2.0, khi lớp lỗi StorageRejected cũ bị bỏ và mọi lỗi storage, kể cả lỗi đọc, trở thành lỗi chết (CHANGELOG.md:66). Lý do: một lỗi đọc giữa chừng một quyết định của scheduler để bộ nhớ ở trạng thái không biết đúng hay sai; dừng hẳn và dựng lại từ storage đơn giản và an toàn hơn đoán.
Quy tắc 2: lần đọc không hợp lệ chỉ hỏng lần đọc đó#
Biến exempt trong #call() là toàn bộ quy tắc: chỉ lần đọc (có read context) được miễn, và chỉ khi lỗi là StorageRequestError hoặc người gọi đã tự huỷ. commit() và mintId() không truyền context đọc nên không bao giờ được miễn: một khi lô đã được nhận, không biết nó đã commit hay chưa (session.ts:973–980). Bản 1.4.0 sửa chính README cho khớp điểm này (CHANGELOG.md:41). Backend ném StorageRequestError cho cursor hỏng (scan.ts:25–26), hội thoại không tồn tại, hay lịch sử mà document không giữ (memory.ts:662–664). Một callback commit đã bắt một lần đọc thất bại thì không commit được nữa, vì #runCommit() kiểm sức khoẻ Session sau callback.
Quy tắc 3: code của bạn chỉ hỏng đơn vị của nó#
| Code ném lỗi | Hậu quả | Ở đâu |
|---|---|---|
Callback commit, conversationCreated, init, initial, migrate, checkpointWhen của document | Commit đó rollback và reject | session.ts:729–733 |
| Phase của task | Task kết thúc faulted, báo task_faulted | chương task |
execute() của tool, hoặc dựng môi trường thất bại | Mô hình nhận kết quả lỗi | chương task |
| Hook | Được báo và bỏ qua; riêng beforeTool ném lỗi thì chặn lời gọi | tool.ts:154–157 |
| Section của prompt | Giữ chữ đang hiển thị, báo, vẫn gửi request | prompt.ts:37–43 |
| Listener của host (commit, close, watch, subscriber) | Được báo, các listener khác vẫn chạy; watch kết thúc listener_error | session.ts:916–928 |
Listener nội bộ của Session, hoặc snapshot() của RegistryReader | Làm Session thất bại như lỗi storage, vì bộ nhớ hoặc khả năng lập lịch đã mất | spec.md:5325–5333 |
onReport | Bị bỏ, vì không còn chỗ nào để báo | harness.ts:443–447 |
Mọi báo cáo tới onReport(error, info) kèm info.type nói chuyện gì đã xảy ra (session_failed, task_faulted, task_parked, migration_failed, task_outdated, task_error, extension_error, host_error, listener_error), để host phân biệt một Session hỏng (thường do nền tảng reset) với một task lỗi (README.md:218).
Cloudflare Durable Object#
Agent phần lớn thời gian ngồi chờ giữa các request, và "Keeping a machine running around the clock wastes compute and quickly gets expensive", như bài "Pi Durable on Cloudflare" (2026-10-16) mở đầu. Durable Object của Cloudflare ngủ đông (hibernate) khi rảnh và thức dậy khi có request, tin nhắn WebSocket hay alarm. Bài viết dựng một agent chat nhiều người dùng: mỗi session là một Durable Object có SQLite, định địa chỉ bằng session ID, vì một Harness gắn với đúng một storage. Phần này đi qua phía storage và mẫu host; cách task ngủ và lập lịch nằm ở chương task.
Adapter: commit là transaction của Durable Object#
/** * Open durable storage on a SQLite-backed Durable Object, optionally under a table prefix. Calls given the same * storage object share an operation queue. */export function openDurableObjectSqliteStorage( storage: DurableObjectSqliteStorage, options: SqliteStorageOptions = {},): Promise<SqliteStorage> { return SqliteStorage.open(new DurableObjectSqliteDatabase(storage), options);}const storageQueues = new WeakMap<DurableObjectSqliteStorage, SerialQueue>();function queueFor(storage: DurableObjectSqliteStorage): SerialQueue { let queue = storageQueues.get(storage); if (queue === undefined) { queue = new SerialQueue(); storageQueues.set(storage, queue); } return queue;} /** `ctx.storage.transaction()` commits when the callback resolves and rolls back when it rejects. */ transaction<T>(callback: (transaction: SqliteExecutor) => Promise<T>): Promise<T> { return this.queue.hold(() => this.storage.transaction(async () => { const transaction = new DurableObjectSqliteTransaction(this.storage); try { return await callback(transaction); } finally { transaction.active = false; } }), ); }ctx.storage.sql của Cloudflare là đồng bộ; adapter bọc nó thành facade bất đồng bộ của Chord và xếp hàng các lời gọi phía sau một transaction đang chạy. Hàng đợi được dùng chung qua một WeakMap theo storage object, nên nhiều storage Pi (với prefix khác nhau) trên cùng Durable Object tự phối hợp với nhau (CHANGELOG.md:52). Số nguyên được bind như double, nên một bigint ngoài khoảng an toàn bị từ chối thay vì mất chính xác (chord/src/sqlite/cloudflare.ts:67–75).Muốn dùng chung database với bảng của host, hãy tạo một adapter và dùng nó cho cả Pi lẫn mọi truy vấn của host; gọi thẳng ctx.storage sẽ đi vòng qua hàng đợi và có thể bị rollback cùng một transaction của Pi (README.md:779–789).
Mẫu host: blockConcurrencyWhile, onWake và alarm#
Một Durable Object có thể bị trục xuất (evict) bất cứ lúc nào, và trong lúc ngủ không có timer nào chạy. Vì vậy Harness không tự giữ timer cho task đang ngủ khi host truyền onWake; nó báo cho host biết khi nào Session cần một tiến trình:
/** * When a Session next needs a running process (`Harness.wake()`). `active`: some task can run now or is running, or a * reconcile pass such as open's recovery has not landed yet, so a process that dies must be woken again soon, for * example by a recovery alarm. `at`: the earliest sleep not yet due, * when there is one. Neither: nothing needs a process until new work is submitted. Tasks blocked on a missing or * unmigratable definition, `completing` tasks, and waits on live tasks do not count; the work they wait for does. */export type Wake = { readonly active: boolean; readonly at?: number };completing, và việc chờ trên task còn sống không được tính; công việc mà chúng chờ thì được tính.Worker dùng cho test của package hiện thực đúng mẫu mà README mô tả (README.md:166–210):
constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env); this.#state = ctx; void ctx.blockConcurrencyWhile(async () => { // A fallback before anything can throw: an object whose open or recovery fails is started again. const fallback = Date.now() + HEARTBEAT_MS; const current = await ctx.storage.getAlarm(); if (current === null || current > fallback) await ctx.storage.setAlarm(fallback); this.#offset = ctx.storage.kv.get<number>("offset") ?? 0; const faux = fauxProvider(); faux.setResponses(Array.from({ length: 8 }, () => this.#step())); const models = createModels(); models.setProvider(faux.provider); const registry = createRegistry(); registry.install(defineExtension({ name: "test", tasks: [Reminder] })); this.#harness = await Harness.open( await openDurableObjectSqliteStorage(ctx.storage), { models, registry, now: () => this.now(), onWake: (wake) => { this.wakes.push(wake); // A failed alarm write resets the object; it arms again when it next starts. void this.arm(wake).catch(() => ctx.abort("Alarm write failed")); }, }, context, ); // Armed before `resume()` runs anything, as for any call that can start work. await this.arm(this.#harness.wake()); this.#harness.resume(); });}blockConcurrencyWhile bắt mọi sự kiện chờ tới khi khởi tạo xong; Cloudflare chạy constructor khi tạo lần đầu và lại chạy sau mỗi lần ngủ đông hay bị trục xuất. Alarm dự phòng được đặt trước mọi thứ có thể ném lỗi, để một object mở hỏng vẫn được khởi động lại. Ghi alarm thất bại thì ctx.abort() reset object; lần khởi động sau sẽ arm lại.async alarm(): Promise<void> { await this.arm({ ...this.#harness.wake(), active: true }); this.#harness.resume();}/** The alarm a host arms for `wake`: the earliest sleep, or a recovery heartbeat while work can run. */async arm(wake: Wake): Promise<void> { const heartbeat = wake.active ? Date.now() + HEARTBEAT_MS : undefined; const sleep = wake.at === undefined ? undefined : wake.at - this.#offset; const at = heartbeat === undefined || sleep === undefined ? (heartbeat ?? sleep) : Math.min(heartbeat, sleep); if (at === undefined) await this.#state.storage.deleteAlarm(); else await this.#state.storage.setAlarm(at);}async root(): Promise<Conversation> { return this.#harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });}async submit(text: string, requestId: string): Promise<void> { // Arm before admitting work: a process that dies after the commit is still woken. await this.arm({ ...this.#harness.wake(), active: true }); await (await this.root()).submit({ type: "input", content: text, requestId }, context);}onWake chạy sau commit; một alarm sớm là vô hại, một alarm thiếu thì bỏ rơi công việc (README.md:195–196). Alarm của Durable Object chạy ít nhất một lần, nên handler phải idempotent; bài blog nhấn mạnh đúng điều này.Trong lúc có việc, arm() đặt một nhịp tim (heartbeat) 60 giây, không bao giờ muộn hơn giấc ngủ sớm nhất: nếu object bị trục xuất giữa chừng, alarm đánh thức nó, Harness.open() đưa task đang running về pending, và resume() chạy tiếp. Ở đây requestId cũng là thứ cho exactly-once: bài blog lưu một biên nhận cùng commit tạo hội thoại, và client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.
Timer và bộ nhớ trong Workers#
/** * Cloudflare Workers: a pending timer keeps a Durable Object from hibernating or being evicted, even an unreferenced * one (`nodejs_compat` gives timers an `unref()` that changes nothing). */const inWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";/** * 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;unref(). Timer nền chỉ để giải phóng bộ nhớ, nên chúng bị từ chối; Session khi đó dọn document rảnh ở cuối mỗi commit thay vì theo timer (session.ts:674–684). Đây là một bản sửa của 1.4.0 (CHANGELOG.md:40).Giới hạn bộ nhớ 128 MB của một Durable Object cũng để lại dấu vết trong CHANGELOG: bộ mã hoá base64 dùng cho ảnh từng tốn khoảng 140 MB heap cho một ảnh 3 MiB và được viết lại ở 1.3.0 (CHANGELOG.md:57). Các test của test-workers/ chạy trong tích hợp Vitest của Cloudflare Workers, gồm cả bộ kiểm thử tuân thủ trên một Durable Object có prefix. Bài blog dẫn tới repo mẫu earendil-works/pi-durable-cloudflare, với một subagent tìm kiếm là một hội thoại do task tool sở hữu, khai báo replay-safe để khi object bị trục xuất giữa lời gọi thì chạy lại và dùng lại đúng hội thoại con cũ.
Ví dụ: một commit ghi những dòng nào#
Ghép mọi thứ lại với commit C1 của trace ở chương trước: người dùng gửi một input tới hội thoại gốc đang rảnh, inbox rỗng, không có requestId. Lần theo code:
- Callback
admitSubmission()lấy draftpi.live(document 2) và, quaprepareBoundary(), đọc head marker (bảng) rồi lấy draftpi.inbox(document 3) mà không sửa. RồiappendInput()cấp entry 7,createSubmission()cấp submission 8,startRun()cấp task 9 và gánlive.run. - Niêm phongDraft của document 2 sinh một phép set lên
["run"]; draft của document 3 không có phép nào nên không ghi gì.checkpointWhencủapi.livetrảtrue(không có generation, không tool nào chạy), nên thay đổi được ghi thành base. - Lô
[entry 7, submission 8, task 9, document.change 2 (base)], đúng thứ tự entry, submission, task, document.
Trên SQLite, SqliteStorage.commit() chạy các câu lệnh sau trong một transaction:
Ví dụ minh hoạ: storage mới; commit 1 là root() (hội thoại 1, document 2–6), đây là commit 2.
Câu lệnh theo đúng thứ tự trong storage.ts; JSON rút gọn bằng "...".
SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1 → ('7', 2)
SELECT record_type FROM record_ids WHERE id = ? (7, 8, 9: chưa thuộc bảng nào)
SELECT record FROM documents WHERE id = ? (2: có, chưa nghỉ hưu)
SELECT id FROM documents WHERE kind = ? AND scope_kind = ? ... AND retired_at IS NULL
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (7, 'entry')
INSERT INTO entries (id, conversation_id, head, commit_seq, record)
VALUES (7, 1, NULL, 2,
'{"model":[{"role":"user","content":"Đọc notes.txt rồi tóm tắt","timestamp":1761104000000}],
"kind":"pi.user","id":7,"conversationId":1}')
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (8, 'submission')
INSERT INTO submissions (id, conversation_id, request_id, status, record)
VALUES (8, 1, NULL, 'placed',
'{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}') ON CONFLICT(id) DO UPDATE ...
INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (9, 'task')
INSERT INTO tasks (id, conversation_id, kind, status, abort_requested, background, record)
VALUES (9, 1, '"pi.generation"', 'pending', 0, 0,
'{"id":9,"conversationId":1,"kind":"pi.generation","version":1,"input":{},
"background":false,"abortRequested":false,
"state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}') ON CONFLICT(id) DO UPDATE ...
SELECT record FROM documents WHERE id = ? (2)
DELETE FROM document_revisions WHERE document_id = ? (2: pi.live là latest, base cũ bị bỏ)
INSERT INTO document_revisions (document_id, seq, kind, version, content)
VALUES (2, 2, 'base', 1, '{"run":{"taskId":9,"inputs":[8]}}')
UPDATE durable_metadata SET next_id = '10', next_seq = 3 WHERE singleton = 1
documents không đổi: hàng của document 2 vẫn là (2, '"pi.live"', 0, '""', 'conversation', 1, 1, NULL, ...). Cột kind của task và key_value của document là chuỗi JSON (có ngoặc kép), vì encodeIndexedString(). Trường requestId, trace, head, byTaskId vắng mặt vì copyJson bỏ giá trị undefined.Trên JSONL, cùng lô đó thành ba dòng mới trong ba file, theo đúng giao thức sidecar trước, marker sau:
Ví dụ minh hoạ: cùng commit 2 trên JSONL (mỗi bản ghi là một dòng; ở đây xuống dòng cho dễ đọc).
1. append task-9.jsonl (task còn sống → sidecar, ordinal 0)
{"format":1,"type":"record","seq":2,"ordinal":0,"payload":{"type":"task","value":{"id":9,
"conversationId":1,"kind":"pi.generation","version":1,"input":{},"background":false,
"abortRequested":false,"state":{"status":"pending","checkpoint":{"phase":"prepare","attempt":1}}}}}
2. append doc-2.jsonl (nội dung document → sidecar, ordinal 1)
{"format":1,"type":"record","seq":2,"ordinal":1,"payload":{"type":"document","id":2,
"content":{"version":1,"kind":"base","value":{"run":{"taskId":9,"inputs":[8]}}}}}
với { fsync: true }: flush task-9.jsonl và doc-2.jsonl ở đây
3. append main.jsonl (marker: entry và submission nằm thẳng trong đó)
{"format":1,"type":"commit","seq":2,"writes":[
{"type":"entry","value":{"model":[...],"kind":"pi.user","id":7,"conversationId":1}},
{"type":"submission","value":{"conversationId":1,"type":"input","status":"placed","entry":7,"id":8}},
{"type":"task.sidecar","id":9,"ordinal":0},
{"type":"document.change","id":2,"ordinal":1}]}
4. áp vào MemoryStorage nội bộ (lúc này commit mới được công bố)
5. thu hồi: doc-2.jsonl được viết lại chỉ còn dòng của bước 2
(ghi doc-2.jsonl.reclaim rồi đổi tên), vì pi.live là latest và vừa có base mới
requestId. Nếu chết sau bước 3, commit đã bền; bước 5 chỉ là dọn dẹp và sẽ được làm lại lúc khôi phục.Sau khi storage trả về Seq 2, adopt() đổi con trỏ tracker của document 2, và publication { seq: 2, changes: [entry 7, submission 8, task 9, document 2] } đến các listener: scheduler thấy task 9 pending và xếp lịch; mount view của hội thoại 1 (nếu có người xem) thêm một phép splice vào entries và một phép set dưới ["docs", "pi.live", "run"]; watchEvents() phát message_start, message_end, submission, run_start, turn_start. Không ai trong số đó thấy gì trước khi transaction SQL ở trên commit, hay trước khi marker nằm trong main.jsonl.
Quyết định thiết kế#
Một dòng commit cho mỗi Session, mở rộng bằng nhiều Session#
Không có đồng thời bên trong một Session: commit, đọc nhiều bước và dọn cache xếp hàng trên một chuỗi promise. Nhờ đó các bất biến dễ giữ và dễ kiểm: ID tăng theo thứ tự commit, đọc-trước-ghi-sau có nghĩa, publication không bao giờ trộn hai commit, và backend không cần mutex riêng. Cái giá là một callback chậm chặn cả Session, nên đặc tả cấm mọi hiệu ứng ngoài bên trong transaction (spec.md:1697–1701). Khi cần mở rộng, mô hình là thêm Session, như một Durable Object cho mỗi session trong bài blog Cloudflare, không phải thêm luồng vào một Session.
Storage ngây thơ, Session thông minh#
Storage tin Session về ngữ nghĩa và chỉ thực thi những gì cấu trúc dữ liệu đòi hỏi: nguyên tử, sở hữu ID, tạo-một-lần, một incarnation hiện tại cho mỗi địa chỉ, không có delta vượt version. Nó không chạy code định nghĩa nào: "Storage executes no definition code and never receives an unused complete candidate with a selected delta" (spec.md:1564–1567). Hệ quả là mỗi backend nhỏ (memory.ts 864 dòng, SQLite 957, JSONL 846, IndexedDB 1.208) và kiểm được bằng cùng một bộ tuân thủ; một backend mới là hiện thực mười sáu phương thức.
Bảng là bản ghi, document là cây Chord#
Hội thoại, entry, task và submission là bản ghi phẳng: tạo một lần hoặc thay trọn, với vài cột được tách ra để làm chỉ mục và phần còn lại là JSON. Document là cây JSON thay đổi từng chút, nên được lưu bằng phép toán Chord, có lịch sử khi cần. Pi Durable không dịch phép toán Chord sang hàm JSON của SQL: SQLite lưu thẳng các bản ghi Chord (spec.md:5400–5402), và đó là một mục trong danh sách không làm (spec.md:5615). Đánh đổi: không truy vấn được nội dung document bằng SQL, đổi lại một đường ghi duy nhất cho mọi backend.
Lỗi storage là lỗi chết#
Trước 1.2.0, một số lỗi storage có thể được bỏ qua. Từ 1.2.0, mọi lỗi (trừ hai ngoại lệ của lần đọc) làm Session thất bại và tự đóng; không có gì được thử lại. Lựa chọn này đổi một chút sẵn sàng lấy sự đơn giản của tính đúng: bộ nhớ không bao giờ phải đoán storage đang ở trạng thái nào, vì trạng thái đúng duy nhất là thứ đọc lại được sau khi mở lại, và commit nguyên tử bảo đảm nó nhất quán.
Entry chia sẻ, document sao chép#
Fork không chép entry vì entry bất biến: một mức trần ID trên chuỗi tổ tiên là đủ. Document thì thay đổi, nên được chép (phía server, bằng lệnh document.copy) theo chính sách mà định nghĩa của nó khai báo. Đặc tả nhấn mạnh current, initial và asOf là ngữ nghĩa sản phẩm chứ không phải tối ưu hoá (spec.md:5537–5538).
Độ bền được nói rõ cho từng backend#
Không backend nào giả vờ bền hơn thực tế: SQLite chạy WAL với synchronous = NORMAL (sống qua crash tiến trình, có thể mất commit cuối khi mất điện); JSONL mặc định chỉ nhất quán qua crash tiến trình, fsync là tuỳ chọn; Durable Object dùng transaction của chính nền tảng. Ứng dụng chọn mức bền bằng cách chọn backend và tuỳ chọn, không phải bằng một cờ chung.
Cạm bẫy và trường hợp biên#
- Transaction dài và đọc trong commit. Await mô hình, mạng hay một commit lồng bên trong callback chặn cả Session; gọi
snapshot()haygetTask()bên trong có thể treo vĩnh viễn khi trượt cache (spec.md:5521–5528). - Việc bắn rồi quên trên draft. Một
tx.doc()không được await làm commit bị từ chối lúc niêm phong; sửa draft sau khi callback xong thì ném lỗi. - Đọc bảng sau khi ghi. Đọc mọi hàng cần thiết trước lần ghi bảng đầu tiên; hàm cần đọc bảng (như
prepareBoundary()) phải chạy đầu commit. - Fork trong lúc sửa cha. Một commit vừa fork vừa sửa document
currentcủa cha bị từ chối; commit thay đổi của cha trước. - Không bao giờ checkpoint.
checkpointWhen: () => falsehợp với document ít đổi hay sống ngắn (như document của task); với document đổi dày nó làm chuỗi phát lại và dung lượng lưu của document current-only tăng không giới hạn khi document còn sống (spec.md:5534–5536). - Đổi tên kind. Kind và đường mount là giao thức đã lưu; migration không đổi được kind, phải chép tường minh rồi cho nghỉ hưu (spec.md:5539–5543).
- Sửa revision bất biến. Giá trị từ
snapshot(), state và watch có thể dùng chung object với tracker; không có đóng băng lúc chạy, nên sửa chúng làm hỏng cache một cách lặng lẽ. Cần sửa thì sao chép trước (spec.md:5544–5546). - Hai tiến trình, một storage. Không có khoá liên tiến trình; với IndexedDB, hai tab cùng mở một database là sai. Một Harness mới chỉ được mở sau khi
close()của Harness cũ đã xong (spec.md:3820–3823). - Không quay lui được qua schema SQLite version 2. 1.4.0 tự migrate; 1.3.0 trở về trước từ chối database đó. Đổi prefix cũng không migrate dữ liệu cũ.
- JSONL cho agent chạy lâu. Bộ nhớ lớn theo lịch sử,
main.jsonlkhông được nén, và không cófsyncthì commit đã xác nhận có thể mất khi mất điện (spec.md:5600–5602). - Kết quả lớn trong bản ghi task. Bản ghi task terminal còn được giữ để tra; kết quả lớn nên nằm trong entry hay document sống lâu, outcome chỉ giữ ID (spec.md:5560–5562).
- Gọi thẳng
ctx.storagetrên Durable Object. Bỏ qua hàng đợi của adapter và có thể bị rollback cùng một transaction của Pi; dùng chung mộtDurableObjectSqliteDatabase.
Đọc code ở đâu#
Mọi đường dẫn tính từ packages/durable/, trừ khi ghi khác.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | docs/spec.md §3–4, §10–11 | Document, transaction, hợp đồng storage, lỗi, backend |
| 2 | src/types.ts | Storage, StorageWrite, DocumentRecord, DocumentContent, Tx, Session |
| 3 | src/session/session.ts | #enqueue, #runCommit, #publish, cache document, snapshotAsOf, GuardedStorage |
| 4 | src/session/transaction.ts | Quy tắc bảng, appendEntry, createTask, doc/#acquire, settleSuccess, #assemble, adopt, planDocument |
| 5 | src/documents.ts, src/ids.ts | Địa chỉ document, kiểm version, migration; nhãn ID |
| 6 | src/session/forks.ts, observation.ts | Chép document khi fork; nguồn state và watch |
| 7 | src/storage/scan.ts, memory.ts | Cursor; backend tham chiếu (đọc prepareCommit, visibleEntries, materializeDocument) |
| 8 | sqlite/migrations.ts, sqlite/storage.ts, chord/src/sqlite/ | Lược đồ, commit, đọc lịch sử; facade và adapter |
| 9 | jsonl/storage.ts | Sidecar, marker, fsync, thu hồi, khôi phục |
| 10 | indexeddb.ts | Cùng hợp đồng trên object store |
| 11 | src/errors.ts | Ba lớp lỗi; đọc cùng GuardedStorage |
| 12 | testing/storage-conformance.ts, storage-benchmark.ts | Hợp đồng dưới dạng 25 ca; kịch bản benchmark |
| 13 | test-workers/worker.ts, src/timers.ts | Mẫu host Durable Object, timer trong Workers |
| 14 | test/examples/00 tới 05, 13, 17 | Session trần, document, fork, state, watch; khôi phục trên SQLite; JSONL để mở ra xem |
Tóm tắt#
- Session là một dòng commit (một chuỗi promise) cộng một cache tracker document; Harness kế thừa nó.
- Một commit đi qua bảy bước: callback, niêm phong và chuẩn bị, gom lô,
storage.commit(), đổi con trỏ tracker, publication, thả dòng; không gì được công bố trước khi storage trả về. - Transaction gom entry và hội thoại theo thứ tự gọi, task và submission theo bản ứng viên cuối, document theo kế hoạch; đọc bảng phải trước lần ghi bảng đầu tiên.
- Document lưu base hoặc delta theo
checkpointWhencủa định nghĩa; latest được thu hồi, rewindable giữ lịch sử chosnapshotAsOf(); migration chạy lúc truy cập. - Fork chia sẻ entry qua mức trần trên chuỗi tổ tiên và chép document theo chính sách
asOf,current,initial. - Năm backend (Memory, SQLite, JSONL, IndexedDB, Durable Object) hiện thực cùng giao diện nhỏ và cùng bộ kiểm thử tuân thủ 25 ca.
- Lỗi storage làm Session thất bại và tự đóng; lần đọc không hợp lệ chỉ hỏng chính nó; code của người dùng chỉ hỏng đơn vị của nó. Phục hồi là mở lại.