DSH / Atlas
2026-08-21implementedarchitecturearchived 2026-09-04

Session log reads state their materialization cost

会话日志读取显式表达物化成本

An all-purpose `Session.events` accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separ

English

Problem

An all-purpose Session.events accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separately by the source-owned session immutability decision.

Decision

Session exposes three cost-specific read operations. seq reads the current length in constant time, eventAt(seq) reads one event in constant time, and snapshotEvents(fromSeq?, toSeqExclusive?) explicitly materializes a frozen array for consumers that need array operations or a stable materialized range. Sequence parameters are non-negative log positions, not Array.prototype.slice offsets from the end. Recurring domain state such as the selected agent preset is read from a Session projection instead of rescanning live history.

The complete current snapshot is cached until append because repeated whole-log consumers can share the same immutable array. A range snapshot copies only the selected references and is not cached: arbitrary range caching would retain unbounded arrays and require an eviction policy. Previously returned snapshots remain stable because accepted events are immutable and a snapshot array never grows after append.

Recurring domain-state reads use session projections when the required value can be maintained incrementally. Raw-log snapshots remain appropriate for persistence, export, replay, and consumers whose output is the event sequence itself. This API makes materialization visible but does not attempt to eliminate every full-log fold in the same change.

Alternatives considered

Keep a cached events array accessor. This preserves ordinary array syntax but makes scalar and indexed reads appear cheap while an append can turn either into a whole-log copy.

Return a custom immutable cut with array-like traversal operations. A captured length could provide a stable constant-time cut over the growing log, but the abstraction would reimplement selected array semantics and keep expanding as callers request more operations. Explicit indexed reads, explicit materialization, and projections cover the shipped intents with a smaller public API.

Cache every materialized range or maintain an incremental chunked snapshot. Range caching needs retention and eviction rules, while a chunked public representation changes consumers and serialization for a cost that many callers avoid through indexed reads or projections. These representations remain options if measured full-snapshot consumers justify them.

Consequences

  • Length and single-event reads do not copy the log.
  • Full and ranged snapshots retain an explicit linear cost proportional to the selected event count.
  • Consumers choose between raw history and incrementally maintained state at the call site.
  • The public API does not emulate an array; callers materialize only when they need array operations.

中文

问题

通用的 Session.events 访问器会在每次 append 后的首次读取中隐藏一次与数组大小成正比的复制。完整冻结快照可以缓存,但流式输出的每个新事件都会使缓存失效,因此只需要日志长度或单个事件的调用方也可能反复复制数百万个引用。返回值不可变并不要求每种读取意图都承担该成本;事件不可变性由源端拥有的会话不可变性决策另行负责。

决策

Session 提供三种成本不同的读取操作。seq 以常数时间读取当前长度,eventAt(seq) 以常数时间读取一个事件,snapshotEvents(fromSeq?, toSeqExclusive?) 则为需要数组操作或稳定物化区间的消费方显式物化冻结数组。序列参数是非负日志位置,不是从末尾计算的 Array.prototype.slice 偏移量。所选 agent preset 这类重复读取的领域状态来自会话投影,而不是反复扫描活跃日志。

当前完整快照会缓存到下一次 append,使重复读取整个日志的消费方可以共享同一个不可变数组。区间快照只复制所选引用且不缓存:缓存任意区间会保留数量无界的数组,并要求额外的淘汰策略。先前返回的快照保持稳定,因为已接受的事件不可变,且快照数组不会在 append 后增长。

当所需值可以增量维护时,重复的领域状态读取使用会话投影。持久化、导出、回放,以及输出本身就是事件序列的消费方仍适合使用原始日志快照。此 API 使物化成本显式可见,但不会在同一项改动中消除所有完整日志折叠。

曾考虑的替代方案

保留缓存的 events 数组访问器。 这保留了普通数组语法,但会让标量读取和索引读取看似廉价,而一次 append 就可能使其中任何一次读取变成完整日志复制。

返回提供类数组遍历操作的自定义不可变 cut。 捕获长度可以在持续增长的日志上提供稳定且常数时间的 cut,但随着调用方要求更多操作,该抽象会不断重新实现所选数组语义。显式索引读取、显式物化和投影以更小的公开 API 覆盖了已经交付的读取意图。

缓存每个物化区间或维护增量分片快照。 区间缓存需要保留与淘汰规则,而公开的分片表示会改变消费方和序列化方式;许多调用方已通过索引读取或投影避开这项成本。若实测的完整快照消费方证明有必要,仍可考虑这些表示。

后果

  • 长度读取和单事件读取不会复制日志。
  • 完整快照和区间快照仍有与所选事件数量成正比的显式线性成本。
  • 消费方在调用点选择原始历史或增量维护的状态。
  • 公开 API 不模拟数组;调用方只在需要数组操作时物化。