投影注册表与缓存
[会话](/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<TState>(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。
冷读阶梯
冷启动读一个持久会话时,走的是一条有层级的路径,尽量少读日志:
三个入口对应三种需求:
| 方法 | 读日志? | 用途 |
|---|---|---|
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() 维护各类型最近一条事件。这一层解决的是领域投影的通用问题:标题、统计、遥测这类需要自己维护状态、又不希望每次都全量重放的场景。