DSH / Atlas
2026-08-31implementedarchitecture

Released Session formats migrate through stateful streaming stages

已发布 Session 格式通过有状态流式 Stage 迁移

Session format v0 shipped in an alpha release, so a structural writer change can no longer treat existing JSONL as disposable pre-release state. The first whole-artifact migration implementation made those logs convertible, but its data model turned a 116 MB real Session into an operation that exhausted a 16 GB Node process before returning a handle. ### Whole-artifact performance failure - Zstandard input was split

English

Problem

Session format v0 shipped in an alpha release, so a structural writer change can no longer treat existing JSONL as disposable pre-release state. The first whole-artifact migration implementation made those logs convertible, but its data model turned a 116 MB real Session into an operation that exhausted a 16 GB Node process before returning a handle.

Whole-artifact performance failure

  • Zstandard input was split into 317,540 frames and each frame used a separate asynchronous decompression call. The implementation retained every plaintext frame and then concatenated them before JSON parsing, creating the same number of Promise, thread-pool, and native decode transitions.
  • Physical Decode materialized a complete plaintext Buffer, one complete string, every JSONL row, expanded source events, migrated target events, encoded target rows, a joined target string, and target physical Buffers at overlapping points in the same request.
  • Every codec and migration edge called snapshotSessionFormatJson() or snapshotSessionFormatArtifact(). These operations detached, recursively copied, and deeply froze whole headers, rows, payloads, and event arrays before and after adjacent migrations.
  • Released packed Assistant chunks expanded into about 9.14 million logical v0/v1 events before v1-to-v2 folded them into 72,784 current events. The whole-artifact API required both representations and the old-to-new sequence map to coexist.
  • Encoding built the complete JSONL and compressed output in memory. The successful path then decoded the staged target, decoded the committed target, and decoded it again in persistence to construct the business object; it also reread the source for a full fingerprint comparison.
  • Per-frame await calls did not provide useful bounded scheduling. The pre-migration reader instead reused one synchronous decoder and yielded from the outer loop about every 500 ms, avoiding hundreds of thousands of asynchronous transitions.

The interfaces prevented local fixes from composing

SessionFormatCodec decoded and encoded complete arrays, each adjacent SessionFormatMigration accepted and returned a complete SessionFormatArtifact, and the compiled chain could only hand one materialized artifact to the next edge. A faster physical decoder therefore still encountered source-row arrays, expanded-event arrays, per-edge snapshots, and target-row arrays downstream.

The migrations are stateful even though the API presented them as one-shot functions. v0-to-v1 tracks message and retry identity. v1-to-v2 buffers one unsettled Assistant attempt, tracks events blocked behind it, and maintains old-to-new sequence references. Wrapping that state in closures or push/finish helper objects made the runtime structure different from the static declarations and made production, Worker verification, fixtures, and replay use different entry paths.

Decision

The Session format packages use a stateful synchronous Stage API. Static migration declarations describe one adjacent version edge and create a new stage for each restored artifact. A stage owns that artifact's mutable state; no stage instance is shared across Sessions.

Stage and Context protocol

interface SessionFormatMigrationContext {
  emitEvent(event: SessionFormatEvent): void
  emitRun(run: SessionFormatEventRun): void
}

interface SessionFormatMigrationStage {
  readonly headerInheritedEventCount?: number
  transformEvent(
    event: SessionFormatEvent,
    context: SessionFormatMigrationContext,
  ): void
  transformRun(
    run: SessionFormatEventRun,
    context: SessionFormatMigrationContext,
  ): void
  finish(context: SessionFormatMigrationContext): number
}

SessionFormatMigrationContext.emitEvent() and emitRun() are synchronous. The producer declares whether it emits a scalar event or a compact run, so the hot path never infers the category from properties on a parsed file object. The caller owns scheduling and supplies the context to each operation instead of injecting a callback into the stage constructor. One input may emit zero, one, or many outputs without allocating a temporary return array or retaining an internal output queue.

SessionFormatMigration remains an immutable declaration: version numbers, header migration, target-header validation, and createStage(). CompiledSessionFormatChain validates a unique gap-free edge sequence once, creates per-artifact stages in source-to-target order, and connects them with context objects in reverse order. finish() settles stages in source-to-target order so each stage can emit its tail before the downstream stage closes.

JSONL record
  → released physical row decoder
  → v0-to-v1 stage
  → v1-to-v2 stage
  → v2-to-v3 stage
  → v3-to-v4 stage
  → current event collector

The chain contains no flatMap, spread expansion, intermediate event array, or scheduler. The final event collector expands a compact run only after every migration stage has had the opportunity to consume it directly.

Adjacent version ownership

The V3-to-V4 specification specifies parent catalog completion: it advances the header version, preserves admitted events and inherited cuts, and appends missing own catalog entries from child evidence. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.

The V2-to-V3 delivery guards prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source SESSION_FORMAT_VERSION independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.

The V2-to-V3 README is the single specification for that edge's transformations, preservation, and refusal; its separate native admission section prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The system-prompt, PTC, and canonical-envelope notes retain their independent rationale, not duplicate conversion specifications. The format-version cookbook owns package wiring, current consumers, snapshot successors, and validation commands.

Historical content admission belongs to the incoming edge, not native V3 extension validation. Preserving an unknown block without understanding its fields cannot establish that migration preserves its meaning. The source audit therefore uses one historical kind set across its explicitly owned content positions, including partial streams. It inspects admitted content without rewriting it and leaves owner-opaque JSON uninterpreted. Narrowing native acceptance or editing frozen predecessor validators would change independent promises rather than establish safe conversion.

Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released code id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias.

A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The V2-to-V3 inheritance rules support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.

The version and release-status reference owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the versioning rule. Incoming-converter fixes affect later historical conversions, not existing target files; the cookbook owns compatibility review and alpha support policy. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. Integration tests use isolated disposable homes and unchanged historical inputs.

The committed-corpus inventory identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.

Physical codecs and packed runs

Each released codec creates a row decoder with explicit strict or recoverable recovery. The decoder validates and emits one event or one codec-owned SessionFormatEventRun at a time through separate context methods. v0-to-v1 and v1-to-v2 implement both transformEvent() and transformRun(), so packed Assistant chunks can reach the folding edge without first becoming millions of ordinary events.

The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired steering/message and compact/* event names, accepts a released llm/retry after its matching step/end, deterministically supplies a missing llm/retry.retryId per turn/step/provider/policy chain, and supplies one deterministic compactionId across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into goal/change plus the original model-visible message. It also inserts an interrupted turn/end for the bounded released restart in which an open turn with no open step is followed by a non-empty next-turn inbox splice and the next numbered turn/start.

The V3-to-V4 edge applies the same bounded restart repair to logs written directly as V3. Keeping this repair in the incoming edge preserves native V4 lifecycle validation; accepting repeated starts there would admit producer corruption in new logs. Insertions require local-reference and inherited-cut remapping, while captured generations retain their original coordinates. The format specification owns the exact evidence and fields.

The catalog exposes one createRestore() operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains SessionFormatUnsupportedMigrationError; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.

JSONL integration

The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, parses complete JSONL records incrementally, and feeds rows directly into the catalog restore. The outer loop yields at a bounded cadence; there is no per-frame await and no complete plaintext or source-row array.

Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter.

The shipped lib/worker.cjs bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.

The browser preview packer prepares current successors for bundled Session data in Node, using same-root direct-child descriptors as parent catalog evidence. The browser host does not implement node:worker_threads, so this preparation preserves both immutable fixture generations and runtime JSONL’s Worker verification requirement.

Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once publish() starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.

The Stage pipeline ends at one prepared current artifact. Historical Session read preparation defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.

The bulk V4 migration command shares one JSONL persistence Context across a bounded job queue, retaining the backend’s two-Worker verification limit and cache for reusing recently decoded logs. A child’s concurrent publication can change its parent’s selected child generation; the ordinary revision check refuses that attempt. The command defers only this source-change error for one sequential retry after the initial queue drains, collecting fresh evidence without relaxing publication checks or retrying unrelated failures.

Durable format and publication rules

Canonical filenames encode physical format generation: v0 is session.jsonl[.zstd] and positive generations use session.vN.jsonl[.zstd]. Migration never moves, replaces, or deletes a committed generation and writes only the final current target; intermediate versions exist only as stage state.

POSIX publication uses hard-link creation plus directory sync. Windows uses no-overwrite, write-through MoveFileExW. An existing target is accepted only when its verified migration prefix equals the staged bytes; any append tail belongs to current-generation reading rather than migration winner verification.

Existing write handles retain the process-local claim and kernel-backed cross-process SessionWriteLease. Header-only stat and list translate supported historical headers without opening the body or publishing a generation. Projection-cache records bind their fold to the Session header's format version so a cache row cannot bypass a cardinality-changing migration.

Problem-to-solution mapping

Whole-artifact problemImplemented mechanismResult
One asynchronous decode call per Zstandard frameOne reusable decoder; outer 500 ms scheduling cadenceRemoves 317,540 async transitions
Complete plaintext, string, and row arraysIncremental JSONL parser and row decoderRetains only one cross-chunk record fragment
Complete event array between every edgeContext-connected stateful stagesNo intermediate version event arrays
Packed chunks expand before foldingSessionFormatEventRun plus transformRun()9.14 million source events need not materialize
Whole-artifact snapshot and deep freeze at every edgeStage-owned exclusive values and final validationRemoves repeated recursive copy/freeze
One-shot migration functions hide statePer-artifact stage classes from immutable declarationsState ownership and concurrency are explicit
Bulk current encode builds whole strings and BuffersRecord encoder, 1 MiB input slices, 4 MiB write batchesBounds allocation and main-thread slices
Verification repeats on the main threadAt most two complete-generation WorkersKeeps verification CPU off the main thread
Production and fixture migration use different APIsCatalog createRestore() with explicit policiesOne decoder/chain implementation

Parent catalog prerequisites

The current session-local catalog preparation isolates child failures: opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in incomplete child evidence. JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the format specification.

Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.

Historical public revisions combine the parent’s physical revision with a fingerprint of every selected canonical path and its filesystem revision. Parent-only tokens cannot identify catalog changes supplied by children; a root fingerprint preserves metadata-only stat/list and includes unreadable or unsupported members without a new persistent index. Unrelated changes also invalidate historical caches, and obtaining the token costs a root scan. Current tokens stay file-local. The preparation memo keeps physical parent revisions and separately validates child membership and revisions.

The generic format interfaces carry only the artifact being restored. Parent-specific catalog assembly replaces the V3→V4 declaration with a closure over the collected child evidence; it reuses the generated codec, migration, and validation inventory. The assembly compiles one short migration chain per parent preparation, with independent stage state per restore. Binding at assembly keeps child evidence out of generic restore options and stage inputs without moving discovery or source revalidation into the migration. Static header and native current reads need no child evidence; an unbound historical body restore refuses rather than assuming an empty child set.

<a id="catalog-scan-measurements"></a>

Catalog scan measurements

The package-local diagnostic measures one raw V3 parent, four direct children with 1,000 events each, and 0/100/1,000 unrelated header-only V3 Sessions. Each sample creates its own temporary corpus and Cordis context in a fresh process, runs stat → cold read → memo read → write publication → current warm read, and disposes the context and files. It uses built workspace exports under plain Node; fixture creation is outside the measured intervals. These are filesystem-cache-warm observations after fixture creation, not disk-cold latency or a performance gate.

On macOS arm64 with Node v26.0.0, all three samples per size are below in milliseconds. Read-only opens do not publish: membership scanning remains proportional to corpus size even when decoding is memoized. The 1,000-unrelated medians are 84.14 ms for stat, 237.97 ms for cold read, 110.98 ms for memo read, 268.46 ms for publication, and 0.41 ms for the same-process current read. These measurements document the cost; they establish no speedup claim or cross-host threshold.

Unrelated SessionsHistorical statCold readMemo readPublishCurrent warm read
05.1719.001.3642.880.47
03.3014.871.4535.750.41
03.1816.711.5034.510.47
10029.4477.4817.0160.660.43
10012.4242.7113.6960.190.43
10011.1941.0612.6157.180.39
100084.14237.97109.86284.960.48
100099.41259.88123.50268.460.41
100075.40236.64110.98247.690.39

Run from the repository root after building the runtime:

pnpm run build:lib:host
cd packages/session/session-persistence-jsonl
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
node .artifacts/perf/catalog-migration.perf.mjs

Verification

The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.

Content-admission evidence must cover every position named in the specification, nested results, partial starts, and malformed known blocks, with source-coordinate diagnostics. Successful migration must preserve admitted content and opaque values. Refusal through real persistence must leave the source unchanged and publish no successor. Native V3 tests must independently retain extension acceptance under both catalog validation policies; historical refusal is not evidence of native rejection.

Benchmark input and meanings

The benchmark uses Node v24.18.0 and one 116,228,655-byte v0 Zstandard log containing 317,540 frames and 454,151 physical rows. The old reader restores 9,143,111 expanded v0 events. Migration produces 72,784 current v2 events with artifact SHA-256 fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c.

Runs use built artifacts under plain Node, one process per sample, and a 16 GB V8 heap limit. “Retained heap” is measured after forced GC while the restored Session remains live. Values below are three-run medians except the whole-artifact failure, which consistently cannot reach a handle.

Physical Decode

Data pathDecode timePeak RSSScheduling
Pre-migration optimized reader1.553s916MBOne decoder; 2–3 outer yields
Whole-artifact migration7.527s7,219MB317,540 async decoder calls
Streaming Stage path1.467s908MBOne decoder; 2 outer yields

Historical-file cold open

VersionTime to restored SessionCPU timePeak RSSRetained heapRestored eventsOutcome
Pre-migration high-performance v0 reader4.594s6.048s2.720GB2.016GB9,143,111Reads v0; does not migrate
Whole-artifact migration>72.8s—≥7.219GB during Decode——OOM before a handle
Streaming Stage migration with serial publication6.241s8.493s2.107GB477MB72,784Publishes and opens v2

The old reader has lower one-time wall time because it performs no format conversion or durable publication. It also keeps the 9.14-million-event representation live. The Stage path pays encode and verification once, then retains the folded v2 state.

Current-format cold open

Version reading its current formatTime to restored SessionPeak RSSRetained heap
Old reader on v04.594s2.720GB2.016GB
Whole-artifact-era reader on v21.273s1.107GB476MB
Streaming Stage reader on v21.284s1.109GB476MB

The current-v2 fast path remains performance-equivalent. The architectural change does not route current data through historical stages.

Streaming serial migration breakdown

This table records the serial open flow measured for this Stage decision. The current preparation-first scheduling and its measurements are owned by Historical Session read preparation.

PhaseMedian
Source Decode and migration2.784s
Encode, write, and sync0.956s
Full staged-file Worker verification1.415s
Source recheck and no-overwrite publication0.106s
Committed-prefix verification and header reopen0.046s
Generation ensure-current total5.318s
Final current decode observed by persistence0.620s
Session restoration0.594s
End-to-end restored Session6.241s

The generation breakdown and end-to-end table come from separate instrumented runs, so rounded rows are not expected to sum exactly.

Format, catalog, edge, JSONL, fixture, replay, and built-Worker tests cover both encodings, packed runs, header-only classification, torn tails, migration refusal, deterministic legacy normalization, source changes, target collisions, write leases, and Worker failure.

Consequences

At least one final current-event array remains necessary because Session restoration and Agent execution retain complete history. The Stage architecture removes full source and intermediate target arrays; it does not promise memory proportional to a page window.

Decoded scalar assistant/chunk rows receive envelope validation and final target validation, but their complete frozen-v1 source payload-member validation is deferred because that per-event check materially affects Decode and migration time on released logs. Packed Assistant runs remain strictly decoded. The scalar check must be restored only with performance evidence that preserves this migration path's measured behavior.

Read-only access consumes the Stage result before durable publication, while write open reuses the same result and waits for publication before append. The persistence scheduling remains independent from the format pipeline.

Lower generations remain for operator inspection. Retention does not promise downgrade compatibility, automatic fallback, or that an older runtime can safely interpret a newer generation.

Alternatives considered

  • Optimize only Zstandard Decode — restores physical Decode speed but leaves source rows, expanded events, snapshots, intermediate artifacts, and bulk encode in memory.
  • Synchronous Generator stages — retain execution frames and batches at each yield. Real-log measurements increased migration time and migrate-complete RSS from about 1.0 GB to about 1.2 GB.
  • Return arrays from each stage — preserves the old allocation, traversal, and flattening costs under a new name.
  • Give each stage an internal output queue — adds drain, EOF, and error ownership while still retaining intermediate values.
  • Inject an emit callback through constructors — forces reverse construction or a partially connected lifecycle. Passing a context to operations keeps stage construction independent of downstream wiring.
  • Share stateful codec instances globally — would mix pending attempts, mappings, and counters across concurrent Session restores.
  • Persist every intermediate format version — creates durable states with no runtime consumer; only the exact source and final current generation are needed.
  • Let mounted plugins register migrations — makes historical readability deployment dependent. The static catalog must restore released formats before feature plugins mount.

中文

问题

Session 格式 v0 已随 alpha 版本发布,因此结构化 writer 变更不能再把已有 JSONL 当作可丢弃的预发布状态。第一版 whole-artifact migration 让这些日志在语义上可迁移,但它的数据模型会让一份 116 MB 真实 Session 在返回 handle 前耗尽 16 GB Node 进程。

Whole-artifact 性能问题

  • Zstandard 输入包含 317,540 个 frame,每个 frame 都单独执行一次异步解压。实现先保留全部 plaintext frame,再在 JSON 解析前统一拼接,因此创建了同等数量的 Promise、线程池与 native Decode 调度。
  • Physical Decode 会在同一请求的重叠阶段物化完整 plaintext Buffer、完整字符串、全部 JSONL row、展开后的 source events、迁移后的 target events、编码后的 target rows、拼接后的目标字符串与目标 physical Buffer。
  • 每个 codec 与 migration edge 都会调用 snapshotSessionFormatJson() 或 snapshotSessionFormatArtifact(),在相邻迁移前后递归复制并 deep freeze 完整 header、row、payload 与 event array。
  • 已发布的 packed Assistant chunk 会先展开成约 914 万个 v0/v1 逻辑事件,再由 v1-to-v2 折叠成 72,784 个 current events。Whole-artifact API 要求两种表示和 old-to-new seq map 同时存活。
  • Encode 会在内存中构造完整 JSONL 与压缩输出。成功路径随后 Decode staged target、Decode committed target,并由 persistence 再 Decode 一次以创建业务对象;它还会完整重读 source 以比较 fingerprint。
  • 逐 frame await 没有形成有意义的有界调度。迁移前的高性能 reader 会复用一个同步 decoder,只由外层循环约每 500 ms yield 一次,从而避免数十万次异步切换。

既有接口使单点优化无法组合

SessionFormatCodec 以完整数组 Decode 与 Encode;每条相邻 SessionFormatMigration 接收并返回完整 SessionFormatArtifact;compiled chain 只能把已经物化的 artifact 交给下一条 edge。因此即使 physical decoder 单点变快,下游仍会重新创建 source-row array、expanded-event array、逐 edge snapshot 与 target-row array。

Migration 实际有状态,但 API 把它们表现为一次性函数。v0-to-v1 需要跟踪 message 与 retry identity;v1-to-v2 需要暂存一个尚未结算的 Assistant attempt、被它阻塞的后续事件,并维护 old-to-new seq 引用。把这些状态隐藏在 closure 或 push/finish helper object 中,会让运行结构与静态声明分离,也让 production、Worker verify、fixture 与 replay 使用不同入口。

决策

Session format 包采用有状态同步 Stage API。静态 migration declaration 描述一条相邻版本边,并为每次 artifact restore 创建新的 stage。Stage 拥有该 artifact 的可变状态;不同 Session 之间绝不共享 stage instance。

Stage 与 Context 协议

interface SessionFormatMigrationContext {
  emitEvent(event: SessionFormatEvent): void
  emitRun(run: SessionFormatEventRun): void
}

interface SessionFormatMigrationStage {
  readonly headerInheritedEventCount?: number
  transformEvent(
    event: SessionFormatEvent,
    context: SessionFormatMigrationContext,
  ): void
  transformRun(
    run: SessionFormatEventRun,
    context: SessionFormatMigrationContext,
  ): void
  finish(context: SessionFormatMigrationContext): number
}

SessionFormatMigrationContext.emitEvent() 与 emitRun() 都是同步操作。Producer 会声明其发出单个事件还是紧凑 run,因此热路径不会根据已解析文件对象的属性推断类别。调度归 caller 所有,context 在每次调用时传入,而不是把 callback 注入 stage constructor。一个输入可以输出零个、一个或多个值,不需要分配临时返回数组,也不需要 stage 内部保留输出队列。

SessionFormatMigration 继续作为 immutable declaration,声明版本号、header migration、target-header validation 与 createStage()。CompiledSessionFormatChain 只校验一次唯一、无缺口的 edge 序列,按 source-to-target 顺序创建每次 artifact 独占的 stage,再按反方向用 context 连接它们。finish() 按 source-to-target 顺序关闭 stage,使每一级都能在下游关闭前发出尾部数据。

JSONL record
  → released physical row decoder
  → v0-to-v1 stage
  → v1-to-v2 stage
  → v2-to-v3 stage
  → v3-to-v4 stage
  → current event collector

Chain 中不存在 flatMap、spread expansion、中间 event array 或 scheduler。只有在每个 migration stage 都已获得直接消费 compact run 的机会后,最终 event collector 才会展开它。

相邻版本所有权

V3 到 V4 规范规定父目录补全:它推进 header 版本,保留已接纳的事件与继承切点,并根据子 Session 证据追加缺失的自身目录记录。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。

V2 到 V3 投递保护防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 SESSION_FORMAT_VERSION 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。

V2 到 V3 README是该迁移边转换、保留与拒绝规则的单一规范真源;单列的原生准入章节避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。系统提示词、PTC和规范信封记录保留各自独立依据,而非重复转换规范。格式版本实操手册负责包接线、当前消费方、快照后继代际与验证命令。

历史内容准入归入边所有,而非原生 V3 扩展校验。在不了解字段的情况下保留未知块,不能证明迁移保留了其含义。因此,源审计在明确归其所有的内容位置(包括未完成的流)使用同一历史种类集合。它检查已接纳的内容而不改写,并且不解释归其他所有者所有的不透明 JSON。收紧原生准入或修改冻结的前代校验器,会改变独立承诺,而非证明转换安全。

预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 code 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。

源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2 到 V3 继承规则支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。

版本与发布状态参考拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按版本规则添加下一条相邻迁移边。传入转换器修复影响之后的历史转换,不影响已有目标格式文件;cookbook拥有兼容性评审和 alpha 支持政策。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。集成测试使用隔离、可丢弃的 home 和未变更的历史输入。

已提交语料清单 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。

Physical codec 与 packed run

每个 released codec 会用显式 strict 或 recoverable 策略创建 row decoder。Decoder 每次通过不同的 context 方法校验并 emit 一个 event 或 codec-owned SessionFormatEventRun。v0-to-v1 与 v1-to-v2 都实现 transformEvent() 和 transformRun(),因此 packed Assistant chunk 可以直接到达 folding edge,无需先变成数百万个普通事件。

v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 steering/message 与 compact/* 事件名称,接受出现在对应 step/end 之后的已发布 llm/retry,按 turn/step/provider/policy chain 为缺失的 llm/retry.retryId 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 compactionId。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 goal/change 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted turn/end:一个没有 open step 的 open turn 后出现非空 next-turn inbox splice,随后直接开始编号连续的下一轮。

V3-to-V4 边对直接写成 V3 的日志应用同样的有限 restart 修复。将修复保留在迁入边,可以维持原生 V4 生命周期校验;若在那里接受重复 start,新日志也会允许生产者损坏。插入事件要求重映射本地引用与继承截点,而捕获代际保留原坐标。格式规范负责定义具体证据和字段。

Catalog 为 production、Worker、fixture 与 replay 暴露同一个 createRestore()。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 SessionFormatUnsupportedMigrationError;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。

JSONL 串联

JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder,增量解析完整 JSONL record,并把 row 直接送入 catalog restore。外层循环按有界 cadence yield;不存在逐 frame await、完整 plaintext 或 source-row array。

Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。

发布的 lib/worker.cjs 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。

浏览器 preview 打包器在 Node 中为随包 Session 数据准备当前代际后继,并使用同一根目录中直属子 Session 的 descriptor 作为父目录证据。浏览器 host 未实现 node:worker_threads,因此打包阶段的准备同时保留不可变 fixture 代际与运行时 JSONL 的 Worker 校验要求。

Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。publish() 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。

Stage pipeline 终止于一份 prepared current artifact。历史 Session 只读迁移准备定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。

批量 V4 迁移命令在有界任务队列中共享一个 JSONL persistence Context,保留 backend 的双 Worker 校验上限,以及复用近期已解码日志的缓存。子 Session 并发发布可能改变父 Session 选中的子代际;普通修订检查会拒绝这次尝试。命令只把这种源变化错误延后到初始队列清空后串行重试一次,重新收集证据,不放宽发布检查,也不重试无关失败。

Durable format 与 publication 规则

规范文件名编码 physical format generation:v0 使用 session.jsonl[.zstd],正 generation 使用 session.vN.jsonl[.zstd]。Migration 不会移动、覆盖或删除任何 committed generation,并且只写最终 current target;中间版本只存在于 stage state。

POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-overwrite、write-through 的 MoveFileExW。已有 target 只有在其已校验 migration prefix 等于 staged bytes 时才会被接受;任何 append tail 都属于 current-generation reader,而不是 migration winner verification。

既有 write handle 继续使用进程内 claim 与内核支持的跨进程 SessionWriteLease。仅 header 的 stat 与 list 可以转换受支持的历史 header,但不打开 body,也不发布 generation。Projection-cache record 会把 fold 绑定到 Session header 的 format version,使 cache row 不能绕过改变 event 基数的 migration。

问题与方案对照

Whole-artifact 问题实现机制结果
每个 Zstandard frame 单独异步 Decode一个可复用 decoder;外层 500 ms 调度 cadence删除 317,540 次异步切换
完整 plaintext、string 与 row array增量 JSONL parser 与 row decoder只保留一条跨 chunk 残行
每条 edge 之间都形成完整 event arrayContext 直连的有状态 stage不保留中间版本 event array
Packed chunk 在 folding 前完整展开SessionFormatEventRun 与 transformRun()无需物化 914 万 source events
每条 edge 都 whole-artifact snapshot/deep freezeStage-owned 独占值与最终 validation删除重复递归复制与冻结
One-shot migration function 隐藏状态Immutable declaration 创建每次 artifact 独占的 stage class状态 ownership 与并发关系显式化
Bulk current encode 构造完整 string/Buffer单条 record encoder、1 MiB input slice、4 MiB write batch限制分配与主线程 slice
主线程重复执行完整 verification最多两个 complete-generation WorkerVerification CPU 不占用主线程
Production 与 fixture 使用不同 migration APICatalog createRestore() 加显式 policy只保留一套 decoder/chain 实现

父目录前置事实

当前逐会话目录准备隔离子会话失败:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见不完整子目录证据。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见格式规范。

原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。

历史格式的公开修订号组合父日志物理修订号与每个所选规范路径及其文件系统修订号的指纹。仅父日志令牌无法标识子日志提供的目录变化;全库指纹让 stat/list 保持只读元数据,也纳入不可读或不支持的成员,无需新增持久化索引。无关变化也会使历史缓存失效,获取令牌需要扫描根目录。当前格式令牌仍只取决于自身文件。准备缓存保留父日志物理修订号,并独立校验子成员集合与修订。

通用格式接口只传递正在恢复的 artifact。每个父 Session 的 catalog 组装将 V3→V4 声明替换为捕获已收集子 Session 证据的闭包,并复用生成的 codec、迁移与校验清单。每次父 Session 准备只编译一条短迁移链,每次恢复拥有独立的 stage 状态。在组装处绑定证据,使通用恢复选项和 stage 输入不必携带子 Session 证据,同时保留存储对发现与来源重验的所有权。静态 header 和原生当前格式读取不需要子 Session 证据;未绑定的历史正文恢复会拒绝,不会假定子 Session 集合为空。

<a id="catalog-scan-measurements"></a>

目录扫描测量

包内诊断脚本测量一个原始 V3 父日志、四个各有 1,000 条事件的直属子日志,以及 0/100/1,000 个只有 header 的无关 V3 Session。每个样本在新进程中创建独立临时日志库与 Cordis 上下文,按 stat → 冷读 → 缓存读取 → 写入发布 → 当前格式热读顺序执行,最后释放上下文并删除文件。脚本在普通 Node 下使用已构建的工作区导出;创建夹具不计入测量区间。这些结果是夹具创建后的文件系统缓存热态观察,不代表磁盘冷读延迟,也不是性能门禁。

以下为 macOS arm64、Node v26.0.0 下各规模的全部三个样本,单位为毫秒。只读打开不发布后继:即使解码结果已缓存,成员扫描仍与日志库规模成正比。1,000 个无关日志时,中位数分别为 stat 84.14 ms、冷读 237.97 ms、缓存读取 110.98 ms、发布 268.46 ms,以及同进程当前格式读取 0.41 ms。这些测量记录成本,不证明性能提升,也不设跨主机阈值。

无关 Session历史 stat冷读缓存读取发布当前格式热读
05.1719.001.3642.880.47
03.3014.871.4535.750.41
03.1816.711.5034.510.47
10029.4477.4817.0160.660.43
10012.4242.7113.6960.190.43
10011.1941.0612.6157.180.39
100084.14237.97109.86284.960.48
100099.41259.88123.50268.460.41
100075.40236.64110.98247.690.39

在仓库根目录构建运行时后执行:

pnpm run build:lib:host
cd packages/session/session-persistence-jsonl
node --input-type=module -e 'import { build } from "tsdown"; await build({ config: false, entry: ["tests/catalog-migration.perf.ts"], outDir: ".artifacts/perf", tsconfig: false, dts: false, deps: { neverBundle: [/^@deepseek-ai\//], onlyBundle: false } })'
node .artifacts/perf/catalog-migration.perf.mjs

验证

迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。

内容准入证据必须覆盖规范列出的每个位置、嵌套结果、未完成的起始记录和已知种类的畸形块,并验证诊断使用源坐标。成功迁移必须保留已接纳的内容与不透明值。经真实持久化路径拒绝时,必须保持源不变且不发布后继代。原生 V3 测试必须独立证明两种目录校验策略均保留扩展准入;历史拒绝不能证明原生输入也被拒绝。

Benchmark 输入与口径

Benchmark 使用 Node v24.18.0 和一份 116,228,655-byte 的 v0 Zstandard 日志,其中包含 317,540 个 frame 与 454,151 个 physical row。老 reader 会恢复 9,143,111 个展开后的 v0 event;migration 会生成 72,784 个 current v2 event,artifact SHA-256 为 fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c。

所有样本均通过 plain Node 运行 build artifact,每个样本使用独立进程,V8 heap limit 为 16 GB。“Retained heap”表示 restored Session 仍存活时强制 GC 后的 heap。除无法得到 handle 的 whole-artifact 失败外,下表使用三次运行中位数。

Physical Decode

数据路径Decode 耗时峰值 RSS调度
Migration 前的高性能 reader1.553s916MB一个 decoder;外层 yield 2–3 次
Whole-artifact migration7.527s7,219MB317,540 次 async decoder 调用
Streaming Stage 路径1.467s908MB一个 decoder;外层 yield 2 次

历史文件首次冷打开

版本Session restore 完成CPU 时间峰值 RSSRetained heapRestore event 数结果
Migration 前的高性能 v0 reader4.594s6.048s2.720GB2.016GB9,143,111读取 v0,不迁移
Whole-artifact migration>72.8s—Decode 阶段已 ≥7.219GB——返回 handle 前 OOM
Streaming Stage migration + 串行 publication6.241s8.493s2.107GB477MB72,784发布并打开 v2

老 reader 的一次性 wall time 更低,因为它不做格式转换和 durable publication;同时它会常驻 914 万 event 的表示。Stage 路径只多支付一次 encode 与 verification,随后保留折叠后的 v2 state。

Current-format 冷打开

版本读取自己的 current formatSession restore 完成峰值 RSSRetained heap
老 reader 读取 v04.594s2.720GB2.016GB
Whole-artifact 时代 reader 读取 v21.273s1.107GB476MB
Streaming Stage reader 读取 v21.284s1.109GB476MB

Current-v2 快路径保持性能等价。架构改造不会让 current data 进入 historical stage。

Streaming 串行 migration 分段

下表记录该 Stage 决策测量的串行 open 流程。当前 preparation-first 调度及其测量由历史 Session 只读迁移准备记录。

阶段中位耗时
Source Decode + migration2.784s
Encode + write + sync0.956s
staged 文件完整 Worker verify1.415s
Source recheck + no-overwrite publication0.106s
committed-prefix verify + header reopen0.046s
Generation ensure-current 总计5.318s
Persistence 观察到的最终 current Decode0.620s
Session restore0.594s
端到端 Session restore 完成6.241s

Generation 分段与端到端数据来自不同 instrumented run,因此四舍五入后的各行不要求精确相加。

Format、catalog、edge、JSONL、fixture、replay 与 built-Worker 测试覆盖两种编码、packed run、仅 header 分类、torn tail、migration refusal、确定性 legacy normalization、source change、target collision、write lease 与 Worker failure。

后果

最终 current-event array 仍然不可消除,因为 Session restore 与 Agent 执行需要完整历史。Stage 架构删除完整 source 与中间 target array,但不承诺内存与分页窗口大小成正比。

解码后的单条 assistant/chunk 会接受 envelope 校验与最终 target 校验,但其完整冻结 v1 source payload 成员校验仍处于延期状态,因为这项逐事件检查会显著影响已发布日志的 Decode 与 migration 耗时。Packed Assistant run 仍接受严格解码。只有性能证据表明不会破坏该迁移路径的已测表现时,才能恢复单条 chunk 校验。

Read-only access 会在 durable publication 前消费 Stage 结果;write open 则复用同一结果,并在 append 前等待 publication。Persistence 调度仍与 format pipeline 相互独立。

低 generation 为 operator 检查而保留。Retention 不承诺 downgrade compatibility、automatic fallback,也不保证旧 runtime 能安全理解新 generation。

考虑过的替代方案

  • 只优化 Zstandard Decode——可以恢复 physical Decode 速度,但 source rows、expanded events、snapshot、intermediate artifact 与 bulk encode 仍会留在内存中。
  • 同步 Generator stage——每个 yield 都会保留执行帧与 batch。真实日志测量使 migration 更慢,并让 migrate-complete RSS 从约 1.0 GB 增长到约 1.2 GB。
  • 每个 stage 返回数组——只是给旧的 allocation、遍历与 flattening cost 换了名字。
  • Stage 内部输出队列——增加 drain、EOF 与 error ownership,同时仍会保留中间值。
  • 通过 constructor 注入 emit callback——迫使 chain 反向构建或引入 partially connected lifecycle。操作时传 context 可以让 stage construction 不依赖下游 wiring。
  • 全局复用有状态 codec instance——会让不同 Session 的 pending attempt、mapping 与 counter 相互污染。
  • 持久化每个中间格式版本——产生没有 runtime consumer 的 durable state;只需要精确 source 与最终 current generation。
  • 让 mounted plugin 注册 migration——使历史可读性依赖部署。Static catalog 必须在 feature plugin 挂载前恢复已发布格式。