投影注册表与缓存

[会话](/tether/session.html)里的 FoldSurface 与 DeriveMessages 是内置的两个投影。 这一层把"从事件日志折叠出领域状态"变成可注册的能力,并给它加上一层可丢弃的缓存。 Service Definition 在 src/Tether.Session.Projection/;registry、cache、plugin 与本地 checkpoint store 在 src/Tether.Session.Projection.Runtime/。

一个投影单元

每个 unit 声明稳定 key、非负 stateVersion、独立的 state schema,以及同步纯 Init/Apply。client-visible unit 再声明独立的 view schema 与 State→View 映射;host-only unit 没有 wire view,但仍会被 checkpoint,并可通过 StateOf 读取。

new ProjectionDefinition<StatsState, StatsView>(
    "sessionStats",
    stateVersion: 1,
    stateSchema,
    Init,
    Apply,
    viewSchema,
    View);

new ProjectionDefinition<HostState>(
    "host/only",
    stateVersion: 1,
    stateSchema,
    Init,
    Apply);

“能反序列化”不等于 schema validation:seed fold、ViewCheckpoint 与 cold restore 都会先走 state schema;字段形状正确但计数为负、policy 文本不在 canonical 集合内,同样属于畸形 payload,只能丢弃并从日志重放。

Apply 必须用引用相等表达"没变"

契约要求:事件不改变状态时,Apply 必须返回同一个 state 实例。 变更检测靠的就是引用比较。host-only unit 即使变了也不会进入 snapshot 或 change feed。

StateOf&lt;TState&gt;(session, key) 返回同进程 borrowed readonly state:不 clone、不序列化、不进入 Host/CLI/Headless/browser payload。cell 绑定 exact live ISession 实例,same-SessionId 的替换会话不会继承旧 cell。

注册与共享

context.Effect(registry.Register(definition).Dispose);

同一个键被重复注册时,只有兼容定义才共享同一个单元。兼容规则是确定的元组:key、stateVersion、state CLR 类型、view CLR 类型(或双方都是 host-only)、state schema CLR 类型、view schema CLR 类型。仅 stateVersion 相同不足以证明兼容;也不比较偶然的 delegate 名称。不兼容定义 fail loud。

返回的 disposer 必须挂在调用方 exact EffectScope 上;最后一个登记方离开后,definition、cell 与领域 ALC 引用一并消失。整个进程仍然只有一个 registry、一次 session/event 订阅。

一致读与变更馈送

ProjectionSnapshot snap = registry.Snapshot(session);
// snap.AsOfSeq —— 这批 client view 反映到哪个 seq;空日志为 -1
// snap.Values  —— 每个 client-visible 键的完整当前 view;不含 host-only

var host = registry.StateOf<HostState>(session, "host/only");
using var sub = registry.OnChanged((session, key, view, causingSeq) => { /* ... */ });

Snapshot 给出的是跨全部 client-visible 单元的一致切面。它与 StateOf、checkpoint 都先捕获同一份 immutable event-log snapshot,再把已有 cell 补到该日志末尾;即使更早注册的同步 listener 暂时挡住 projection listener,也不会出现“新 AsOfSeq + 旧值”。读路径提前折叠时会保留该 seq 尚未发布的 client change;迟到的 event drive 按 ObservedSeq 避免重复 apply,但仍恰好发布一次 pending change。稳态 drive 只 apply 当前事件,不重新扫描整份日志。OnChanged 的粒度是”每条已提交事件、每个发生变化的 client-visible 单元至多一次通知”。host-only 状态只出现在 StateOf 与 checkpoint 行里。

缓存从不权威

/// <summary>Discardable fold-shortcut store. Never authoritative.</summary>
public interface IProjectionCheckpointStore { … }

契约注释里这句话是整层设计的地基:缓存只是折叠的捷径,正确性永远来自重放日志。任何时候把缓存整个删掉,系统行为不变,只是变慢。

这条性质让它和会话持久化有本质区别:持久化丢了就是数据丢了,投影缓存丢了什么都没丢。

有四道相互独立的失效闸门:

闸门粒度判据
StateVersion + state schema单个单元行的 Ver 必须匹配,且 Val 必须通过该单元的 state schema;畸形/错版本行丢弃并从日志重放
ProjectionCacheIdentity整条记录外层 key 是 SessionId;记录还要匹配 header 的 version、createdAt、cwd、composition、profile
durable watermark整条记录所有行必须处于同一 seq,且不得超过当前 SessionInspection.DurableEndSeq;inspect-only synthetic closers 不算 durable
物理 format整个 cache 文件ProjectionCacheFormat.Version 单调递增;旧 schema 整文件丢弃重建,不做迁移

identity 闸门对齐 #65 的六字段 header authority,防止 id 复用或 composition/profile 切换后把无关状态当成缓存;durable watermark 则在读取时重新对照权威日志,拒绝截断/repair 后仍领先日志的旧行。checkpoint 不得领先 durable SessionEvent log;写失败 fail-soft。

ViewCheckpoint / CachedSnapshotAsync 还要求每个当前注册 unit 的行都存在、通过 state schema,且全部位于同一 seq。任何缺行、坏行或 mixed-seq 记录都会整条 miss;不会用较小的 AsOfSeq 包装较新的 view,也不会把 partial snapshot 暴露给列表消费者。前者只验证传入行;后者会 fail-soft inspect 权威日志的 durable watermark,因此不是零日志 I/O。

冷读阶梯

冷启动读一个持久会话时,走的是一条有层级的路径,尽量少读日志:

读缓存记录 ReadAsync 筛出可用行 版本 + 身份双闸门 算重放下界 RestoreFloor 只读尾部 ReadFrom Restore:把尾部折到可用行上 得到快照 + 刷新后的行 回写缓存 下次冷读的下界更高 全部行都不可用时下界退到日志起点,等价于完整重放——慢,但结果一样正确。 列表展示用 CachedSnapshotAsync:允许落后,但会核对权威 durable watermark,绝不返回超前行。
缓存越新,需要重放的尾部越短。但任何一级失效都只是退化成更长的重放,不会给出错误结果。

三个入口对应三种需求:

方法读日志?用途
ViewCheckpoint(checkpoint)不读只在所有注册 unit 构成同一有效 cut 时生成 client view
CachedSnapshotAsync(header)读 durable watermark会话列表这类允许陈旧、但仍要求内部一致且不领先日志的展示
ColdSnapshotAsync(id)读尾部需要准确当前状态时;inspect-only synthetic closers 不会被写成 durable checkpoint

RestoreFloor 在没有任何单元注册时返回 null——没有投影就没有需要重放的东西。

检查点行的形状

public sealed class ProjectionCheckpointRow
{
    public int Ver { get; }        // 折叠时该单元的 StateVersion
    public long Seq { get; }       // 已折进 Val 的最后一个事件 seq,-1 表示空日志
    public JsonElement Val { get; } // 脱离的状态快照
}

Val 在构造时就 Clone(),因此行一旦建立就不会被外部改动。Restore 返回的 ProjectionRestore 同时带上快照和刷新后的行,可以直接回写,把下次冷读的下界推高。

与内置投影的关系

SessionProjections.FoldSurface 与 DeriveMessages 不走这一层——它们是会话契约里固定的两个投影:静态方法对给定日志做纯函数重放,活的会话则在追加时增量维护同一个 fold(ISession.SnapshotSurface()),并以 ISession.LatestEvent() 维护各类型最近一条事件。这一层解决的是领域投影的通用问题:标题、统计、遥测这类需要自己维护状态、又不希望每次都全量重放的场景。

下一步

在 GitHub 上编辑此页