mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(session-projection-cache): own the cache tree under a config root
Store each session's projection_cache.json under the cache's own root tree
(<root>/<session-id>/projection_cache.json, wired to dshHomePath('projections')
in the base bundle) instead of beside the session log via
sessionPersistence.locate(). The cache owns its directory layout, keys
directories by the code-generated session id, and never consults the
persistence layer; the service now injects only sessionProjections and
sessions.
Drop the coldSnapshot method and its readFrom-tail fold ladder: every cold
consumer refolds from the log itself, so the cache only serves the listing
read (cachedSnapshot, one async file read per session) and the write side.
Fail-soft durability, per-path write serialization, in-flight drain, and
atomic 0600 writes are unchanged; the chain cleanup now observes its own
rejection so a failed write cannot surface as an unhandled error.
dsh-session-persistence leaves peer/dev dependencies and the tsconfig
reference; dsh-atomic-write moves to peerDependencies. Config gains a
required root.
This commit is contained in:
@@ -1085,11 +1085,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'sessionProjectionCache',
|
||||
summary: 'The persisted projection cache service.',
|
||||
description: 'The persisted projection cache service. Checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached file, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op, cold reads fall to the full-log rung.',
|
||||
description: 'The persisted projection cache service. Checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write. The cache owns its directory tree and never consults the persistence layer.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>',
|
||||
description: 'The listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.',
|
||||
description: 'The listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness).',
|
||||
parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }],
|
||||
returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
|
||||
},
|
||||
@@ -1099,12 +1099,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'session', description: 'the live session to checkpoint.' }],
|
||||
returns: 'resolution after durability and event emission.',
|
||||
},
|
||||
{
|
||||
signature: 'async coldSnapshot(meta: SessionHeader, signal?: AbortSignal): Promise<ProjectionSnapshot>',
|
||||
description: 'Cold-read one persisted session\'s projections with zero full-log load: cached rows + a persistence `readFrom` tail from the registry\'s restore floor, refolded by the registry and written back (fail-soft) so the next cold read starts closer. A cache row invalidated by a shrunk log (crash-repair truncation) triggers one full re-read from seq 0 — the ladder\'s slow rung, still no crash. Rejects when the session has no persisted log (`not found` from the persistence seam).',
|
||||
parameters: [{ name: 'meta', description: 'the persisted session whose projections are read (locates the cache file and witnesses the stored log identity).' }, { name: 'signal', description: 'optional cancellation for the persistence reads.' }],
|
||||
returns: 'the snapshot cut at the stored log end.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md
|
||||
README.md: 6d4794fab23dd90c623699a218ec3000b03875bc
|
||||
README.zh.md: 2a61257e360fbc11bab66ffed377ef1bd42fe42c
|
||||
README.md: 08fb59af8e2e07c3ee8864d4a06c6c2c760df104
|
||||
README.zh.md: fed390a2ecea19372cc40a2d72e7652f1c4a9ab1
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one `projection_cache.json` per session inside the session's own persistence directory (resolved through `sessionPersistence.locate(meta)` — the jsonl backend places it beside the session log). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
|
||||
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one `projection_cache.json` per session under the cache's own storage root (`<root>/<session-id>/projection_cache.json`). The cache owns its directory tree and never consults the persistence layer. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
|
||||
|
||||
A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
|
||||
|
||||
- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write self-heals. A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
|
||||
- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the cached view and rejected by restore so the cold-read ladder refolds it from the log.
|
||||
- **Whole-record writes.** Each write atomically replaces the session's cache file (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
|
||||
- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a record, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
|
||||
- **A row must pass the live unit's `stateSchema`.** A malformed file reads as "no cache row", so the cold path refolds from the log.
|
||||
- **Whole-record writes.** Each write atomically replaces the session's cache file (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. Writes to one cache file serialize, so a newer cut never lands before an older one.
|
||||
- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live header is the witness) before accepting a record, so a deleted-then-recreated id cannot let an old record seed state folded from an unrelated log.
|
||||
- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache file lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
|
||||
- **Per-session files, no global medium.** A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op and cold reads fall to the full-log rung. An obsolete cache (any earlier format) is never read — the first cold read refolds from the log and writes the current format.
|
||||
- **The cache owns its tree, private by default.** Session directories and cache files are created owner-only (`0o700`/`0o600`). The cache does not depend on which persistence backend is mounted — no `locate`, no per-session-dir probing.
|
||||
|
||||
## Write policy
|
||||
|
||||
@@ -20,20 +20,16 @@ Two mandatory points, throttled in between:
|
||||
|
||||
| Trigger | Nature |
|
||||
|---|---|
|
||||
| `turn/end` | Mandatory — the turn-final value is what cold reads want. |
|
||||
| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
|
||||
| `turn/end` | Mandatory — the turn-final value is what listing reads want. |
|
||||
| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cache serves this session's final cut. |
|
||||
| `writeEveryEvents` committed events | Config throttle (count). |
|
||||
| `writeIntervalMs` since the first dirty event | Config throttle (interval). |
|
||||
|
||||
Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
|
||||
`root` and both throttle triggers are required `Config` fields (no defaults): the cache root and flush cadence are deployment choices stated in cordis.yml.
|
||||
|
||||
## Listing read (`cachedSnapshot(meta)`)
|
||||
|
||||
One file read per session: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, missing file, or no usable rows); the api-proxy list carrier turns that into an absent column.
|
||||
|
||||
## Cold read (`coldSnapshot(meta, signal?)`)
|
||||
|
||||
The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
|
||||
One file read per session: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, missing or malformed file, or no usable rows); the api-proxy list carrier turns that into an absent column.
|
||||
|
||||
`write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
|
||||
|
||||
@@ -43,15 +39,16 @@ The read ladder, zero full-log load on the happy path: cached rows → `sessionP
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
root: !!js dshHomePath('projections')
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
```
|
||||
|
||||
Injects `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
|
||||
Injects `sessionProjections`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
|
||||
None, as the cache only persists host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -61,5 +58,4 @@ None; the cache never assembles or sends provider requests.
|
||||
|
||||
- **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
|
||||
- **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
|
||||
- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.
|
||||
- **Concurrent checkpoints land in call order per session** — writes to one cache file are serialized (an older cut can never overwrite a newer one), but a crash between a file write and its successor leaves the older cut on disk — stale-but-never-wrong, self-healed by the next write or cold read.
|
||||
- **No cache-side cold refold** — the cache serves and refreshes its files but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,每会话一个 `projection_cache.json`,位于该会话自己的持久化目录内(经 `sessionPersistence.locate(meta)` 解析——jsonl 后端将其放在会话日志旁)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
|
||||
持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,每会话一个 `projection_cache.json`,位于缓存自己的存储根下(`<root>/<session-id>/projection_cache.json`)。缓存拥有自己的目录树,绝不咨询持久化层。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
|
||||
|
||||
一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
|
||||
|
||||
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
|
||||
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
|
||||
- **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
|
||||
- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从缓存视图中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
|
||||
- **整记录写入。** 每次写入原子替换该会话的缓存文件(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
|
||||
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
|
||||
- **每会话文件,无全局介质。** 没有每会话目录的持久化后端(如 sqlite)会禁用持久缓存:写入变为 no-op,冷读落到全量日志那一级。过时的缓存(任何更早格式)从不被读取——首次冷读从日志重折叠并写出当前格式。
|
||||
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
|
||||
- **存储行必须通过当前单元的 `stateSchema`。** 畸形文件读作"无缓存行",冷路径从日志重新折叠。
|
||||
- **整记录写入。** 每次写入原子替换该会话的缓存文件(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。同一缓存文件的写入被串行化,新切面绝不会先于旧切面落盘。
|
||||
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 为证验证它,再接受任何记录——被删后重建的 id 无法让旧记录播种来自无关日志的状态。
|
||||
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存文件才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
|
||||
- **缓存自有目录树,默认私有。** 会话目录与缓存文件以仅属主权限创建(`0o700`/`0o600`)。缓存不依赖挂载的是哪个持久化后端——没有 `locate`、没有每会话目录探测。
|
||||
|
||||
## 写策略
|
||||
|
||||
@@ -20,20 +20,16 @@
|
||||
|
||||
| 触发 | 性质 |
|
||||
|---|---|
|
||||
| `turn/end` | 必写——冷读要的正是轮次终值。 |
|
||||
| 会话释放(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
|
||||
| `turn/end` | 必写——列表读要的正是轮次终值。 |
|
||||
| 会话释放(detach) | 必写——live 转 cold 的时刻;此后缓存服务该会话的最终切面。 |
|
||||
| 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
|
||||
| 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
|
||||
|
||||
两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
|
||||
`root` 与两个节流 `Config` 字段均必填(无默认值):缓存根与写入节奏是部署选择,由 cordis.yml 明示。
|
||||
|
||||
## 列表读(`cachedSnapshot(meta)`)
|
||||
|
||||
每会话一次文件读取:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、缺失文件、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
|
||||
|
||||
## 冷读(`coldSnapshot(meta, signal?)`)
|
||||
|
||||
读取阶梯,正常路径无需加载全量日志:缓存行 → `sessionProjections.restoreFloor`(锚定在最低可用水位之前一个事件的位置)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
|
||||
每会话一次文件读取:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、缺失或畸形文件、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
|
||||
|
||||
`write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
|
||||
|
||||
@@ -43,15 +39,16 @@
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
root: !!js dshHomePath('projections')
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
```
|
||||
|
||||
注入 `sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
|
||||
注入 `sessionProjections`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为缓存只持久化并恢复 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
|
||||
无,因为缓存只持久化 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -61,5 +58,4 @@
|
||||
|
||||
- **不提供淘汰或保留接口**:记录会按会话持续累积;清理已存储的检查点属于带外维护,与会话持久化采用相同策略。
|
||||
- **间隔节流采用按会话的粗粒度控制**:一次无脏数据的写入完成后,计时器会在首个脏事件到达时启动;对于持续但未达到条数阈值的事件流,系统每个间隔写入一次,而不采用滑动窗口。
|
||||
- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。
|
||||
- **并发检查点按调用顺序落盘(每会话)**——同一缓存文件的写入被串行化(旧切面永不覆盖新切面),但文件写入与下一次写入之间的崩溃会留下旧切面——陈旧但绝不错误,由下一次写入或冷读自愈。
|
||||
- **缓存侧不做冷重折叠**——缓存只服务并刷新自己的文件,从不读取会话日志(不依赖持久化层);需要保证冷快照的消费方自行从日志重折叠。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-projection-cache",
|
||||
"description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection_cache.json checkpoints beside the session log, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
|
||||
"description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection_cache.json checkpoints under the cache's own root tree, throttled write-behind, and the cached cold-read rows",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -36,17 +36,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^"
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
|
||||
* checkpoints of every projection unit's state, one `projection_cache.json`
|
||||
* per session inside the session's own persistence directory (resolved via
|
||||
* `sessionPersistence.locate(meta)` — the jsonl backend places it beside
|
||||
* the session log). The cache is a fold shortcut, never an authority: a row
|
||||
* per session under the cache's own storage root (`<root>/<session-id>/
|
||||
* projection_cache.json`). The cache owns its directory tree and never
|
||||
* consults the persistence layer. The cache is a fold shortcut, never an
|
||||
* authority: a row
|
||||
* is possibly stale (its `seq` says how stale) but never wrong, so every
|
||||
* write path is fail-soft (a lost write costs a longer tail replay on the
|
||||
* next cold read) and a `ver` mismatch discards the row instead of migrating
|
||||
@@ -15,13 +16,10 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import: applies the package's cordis Context merge
|
||||
// (`ctx.sessionPersistence`), which this service reads on the cold path.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import { checkpointRecord } from './spec.ts'
|
||||
import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
|
||||
@@ -48,6 +46,8 @@ declare module '@deepseek-ai/cordis' {
|
||||
* disposal) are policy, not tunables, and always fire.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Directory holding one `<session-id>/projection_cache.json` per session. */
|
||||
root: string
|
||||
/** Committed events per session that force a durable checkpoint write between mandatory points. */
|
||||
writeEveryEvents: number
|
||||
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
|
||||
@@ -55,6 +55,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
root: z.string().required(),
|
||||
writeEveryEvents: z.natural().min(1).required(),
|
||||
writeIntervalMs: z.natural().min(1).required(),
|
||||
})
|
||||
@@ -71,15 +72,13 @@ interface DirtyState {
|
||||
* The persisted projection cache service. Checkpoints live sessions on a
|
||||
* throttled write-behind (count/interval triggers from {@link Config}) plus
|
||||
* two mandatory points — `turn/end` and session disposal (the live-to-cold
|
||||
* moment) — and serves the cold-read ladder: cached file, persistence
|
||||
* `readFrom` tail, registry `restore`, durable write-back. Every durable
|
||||
* moment) — and serves the cached rows for a session header. Every durable
|
||||
* write is fail-soft: failures log a warning and the cache self-heals on the
|
||||
* next write or cold read. A persistence backend without a per-session
|
||||
* directory (e.g. sqlite) disables the durable cache: writes no-op, cold
|
||||
* reads fall to the full-log rung.
|
||||
* next write. The cache owns its directory tree and never consults the
|
||||
* persistence layer.
|
||||
*/
|
||||
export class SessionProjectionCache extends Service {
|
||||
static inject = ['sessionProjections', 'sessionPersistence', 'sessions']
|
||||
static inject = ['sessionProjections', 'sessions']
|
||||
|
||||
static Config: z<Config> = Config
|
||||
|
||||
@@ -99,19 +98,15 @@ export class SessionProjectionCache extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one session's cache file path, or `undefined` when the
|
||||
* persistence backend owns no per-session directory. The file sits beside
|
||||
* the backend's session artifact (the jsonl log), derived from
|
||||
* `sessionPersistence.locate(meta)` — the persistence backend is the sole
|
||||
* owner of the session-directory layout.
|
||||
* @param meta - the session header naming the persistence location.
|
||||
* @returns the absolute cache-file path, or `undefined` for backends
|
||||
* without a per-session artifact.
|
||||
* Resolve one session's cache file path. The cache owns the layout: a
|
||||
* per-session directory under the configured root, keyed by the session id
|
||||
* (a code-generated string, safe as a path segment). No persistence
|
||||
* lookup — the path is a pure function of the header.
|
||||
* @param meta - the session header naming the cache entry.
|
||||
* @returns the absolute cache-file path.
|
||||
*/
|
||||
private cachePathFor(meta: SessionHeader): string | undefined {
|
||||
const location = this.ctx.sessionPersistence.locate(meta)
|
||||
if (location === undefined) return undefined
|
||||
return join(dirname(location.path), CACHE_FILE_NAME)
|
||||
private cachePathFor(meta: SessionHeader): string {
|
||||
return join(this.config.root, meta.id, CACHE_FILE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,15 +121,13 @@ export class SessionProjectionCache extends Service {
|
||||
* @returns the identity-matching record, or `undefined`.
|
||||
*/
|
||||
private async recordFor(meta: SessionHeader, expected: CheckpointIdentity): Promise<CheckpointRecord | undefined> {
|
||||
const path = this.cachePathFor(meta)
|
||||
if (path === undefined) return undefined
|
||||
try {
|
||||
const record = checkpointRecord.parse(JSON.parse(await readFile(path, 'utf8')))
|
||||
const record = checkpointRecord.parse(JSON.parse(await readFile(this.cachePathFor(meta), 'utf8')))
|
||||
return identityMatches(record.identity, expected) ? record : undefined
|
||||
} catch {
|
||||
// An absent, unreadable, or malformed file reads as "no cache row";
|
||||
// the cold-read ladder refolds from the log. Identity mismatch is a
|
||||
// normal ternary return above, not an exception.
|
||||
// the caller refolds from the log. Identity mismatch is a normal
|
||||
// ternary return above, not an exception.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -144,9 +137,7 @@ export class SessionProjectionCache extends Service {
|
||||
* (version-matching keys only), each cut carried with its watermark so a
|
||||
* client value store can seed under its higher-seq-wins rule — as stale as
|
||||
* the last durable checkpoint but never wrong, and never from an unrelated
|
||||
* log (the caller's header is the identity witness). Fresher paths (the
|
||||
* history tail baseline, {@link coldSnapshot}) supersede these values
|
||||
* whenever a session is actually opened.
|
||||
* log (the caller's header is the identity witness).
|
||||
* @param meta - the listed session's header (identity witness; no log read).
|
||||
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
|
||||
* `undefined` when no usable row exists for this lifecycle.
|
||||
@@ -174,10 +165,7 @@ export class SessionProjectionCache extends Service {
|
||||
* @returns resolution after durability and event emission.
|
||||
*/
|
||||
async write(session: Session): Promise<void> {
|
||||
// A backend without a per-session directory (sqlite) persists no cache:
|
||||
// skip the checkpoint cut and the durability flush entirely for it.
|
||||
const path = this.cachePathFor(session.header)
|
||||
if (path === undefined) return
|
||||
const rows = this.ctx.sessionProjections.checkpoint(session)
|
||||
this.markClean(session)
|
||||
// Durability barrier: the checkpoint cut was taken above, so flushing
|
||||
@@ -191,57 +179,6 @@ export class SessionProjectionCache extends Service {
|
||||
await this.put(path, identityOf(session.header), rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-read one persisted session's projections with zero full-log load:
|
||||
* cached rows + a persistence `readFrom` tail from the registry's restore
|
||||
* floor, refolded by the registry and written back (fail-soft) so the next
|
||||
* cold read starts closer. A cache row invalidated by a shrunk log
|
||||
* (crash-repair truncation) triggers one full re-read from seq 0 — the
|
||||
* ladder's slow rung, still no crash. Rejects when the session has no
|
||||
* persisted log (`not found` from the persistence seam).
|
||||
* @param meta - the persisted session whose projections are read (locates
|
||||
* the cache file and witnesses the stored log identity).
|
||||
* @param signal - optional cancellation for the persistence reads.
|
||||
* @returns the snapshot cut at the stored log end.
|
||||
*/
|
||||
async coldSnapshot(meta: SessionHeader, signal?: AbortSignal): Promise<ProjectionSnapshot> {
|
||||
const record = await this.recordFor(meta, identityOf(meta))
|
||||
const cached = record?.rows ?? {}
|
||||
const floor = this.ctx.sessionProjections.restoreFloor(cached)
|
||||
const persistence = this.ctx.sessionPersistence
|
||||
if (floor === undefined) {
|
||||
// No unit registered: nothing to fold, but the not-found contract must
|
||||
// hold in this topology too — the probe read rejects for an absent log
|
||||
// and dates the empty cut for a present one.
|
||||
const probe = await persistence.readFrom(meta.id, 0, signal)
|
||||
return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
|
||||
}
|
||||
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
const tail = await persistence.readFrom(meta.id, floor, signal)
|
||||
// The tail's stored header is the identity witness: a record bound to a
|
||||
// different lifecycle (recreated id, swapped store) is discarded whole
|
||||
// before any of its rows can seed a fold.
|
||||
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
|
||||
try {
|
||||
if (!related) throw new Error('unrelated log identity')
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
|
||||
} catch {
|
||||
// Recoverable failures are an unrelated record, a row outside the
|
||||
// supplied suffix or log end, and stateSchema rejection. The full read
|
||||
// removes every checkpoint seed and lets each unit refold from init.
|
||||
const whole = await persistence.readFrom(meta.id, 0, signal)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
|
||||
}
|
||||
// The write-back path and identity come from the STORED header: a caller
|
||||
// header with a wrong cwd must not mint an orphan cache file in a
|
||||
// directory no real read will ever look at.
|
||||
const writebackPath = this.cachePathFor(tail.meta)
|
||||
if (writebackPath !== undefined) {
|
||||
await this.putSoft(writebackPath, meta.id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
|
||||
}
|
||||
return restored.snapshot
|
||||
}
|
||||
|
||||
// --- write-behind (throttle + mandatory points) ---
|
||||
|
||||
private installWritePath(): void {
|
||||
@@ -334,29 +271,13 @@ export class SessionProjectionCache extends Service {
|
||||
))
|
||||
void next.finally(() => {
|
||||
if (this.writeChains.get(path) === next) this.writeChains.delete(path)
|
||||
}).catch(() => {
|
||||
// The chain cleanup must run on failure too; the write rejection
|
||||
// itself is `next`'s, observed by the caller awaiting `put`.
|
||||
})
|
||||
this.writeChains.set(path, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
|
||||
private async putSoft(
|
||||
path: string,
|
||||
id: SessionId,
|
||||
identity: CheckpointIdentity,
|
||||
rows: ProjectionCheckpoint,
|
||||
what: string,
|
||||
): Promise<void> {
|
||||
const run = (async () => {
|
||||
try {
|
||||
await this.put(path, identity, rows)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
|
||||
}
|
||||
})()
|
||||
this.inFlight.add(run)
|
||||
await run.finally(() => this.inFlight.delete(run))
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a header onto the identity fields a record is bound to. */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The projection-cache record schema: one `projection_cache.json` per
|
||||
* session, stored inside the session's own persistence directory (resolved
|
||||
* through `sessionPersistence.locate(meta)`). The file holds the session's
|
||||
* full projection checkpoint (`key → {ver, seq, val}` rows) plus the log
|
||||
* identity it was folded from.
|
||||
* session, stored under the cache's own root tree at
|
||||
* `<root>/<session-id>/projection_cache.json` (independent of session
|
||||
* persistence). The file holds the session's full projection checkpoint
|
||||
* (`key → {ver, seq, val}` rows) plus the log identity it was folded from.
|
||||
* @module @deepseek-ai/dsh-session-projection-cache/src/spec
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
* SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
|
||||
* count/interval throttling between them, fail-soft durability (a failed
|
||||
* write logs and stays stale, never throws into the event path), and the
|
||||
* cold-read ladder (cached file + readFrom tail + registry restore +
|
||||
* write-back; version bump and shrunk-log rows degrade to a full re-read).
|
||||
* The durable medium is one `projection_cache.json` per session inside the
|
||||
* session's persistence directory (resolved via `sessionPersistence.locate`).
|
||||
* cached listing read. The durable medium is one `projection_cache.json`
|
||||
* per session under the cache's own configured root
|
||||
* (`<root>/<session-id>/projection_cache.json`); the cache never consults
|
||||
* the persistence layer.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionCache from '../src/index.ts'
|
||||
@@ -55,27 +55,11 @@ const marksUnit = (stateVersion = 1) => ({
|
||||
stateVersion,
|
||||
}) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
|
||||
|
||||
/** One session's cache file inside its persistence directory. */
|
||||
/** One session's cache file under the cache's own root. */
|
||||
const cachePath = (root: string, id: Session['id']): string =>
|
||||
join(root, String(id), 'projection_cache.json')
|
||||
|
||||
/** A persistence double serving locate + readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
|
||||
function fakePersistence(root: string, logs: Map<string, SessionEvent[]>) {
|
||||
const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
|
||||
const events = logs.get(String(id))
|
||||
if (events === undefined) throw new Error(`session "${id}" not found`)
|
||||
return {
|
||||
meta: { version: 0, id, createdAt: 0 },
|
||||
events: events.filter(event => event.seq >= fromSeq),
|
||||
}
|
||||
})
|
||||
return {
|
||||
readFrom,
|
||||
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: join(root, String(meta.id), 'session.jsonl') }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
|
||||
/** Header shape for cachedSnapshot calls. */
|
||||
const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
|
||||
({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
|
||||
|
||||
@@ -83,7 +67,6 @@ interface HarnessOptions {
|
||||
root?: string
|
||||
config?: { writeEveryEvents: number; writeIntervalMs: number }
|
||||
stateVersion?: number
|
||||
logs?: Map<string, SessionEvent[]>
|
||||
}
|
||||
|
||||
const contexts: Context[] = []
|
||||
@@ -92,16 +75,16 @@ const roots: string[] = []
|
||||
async function harness(options: HarnessOptions = {}) {
|
||||
const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = options.logs ?? new Map<string, SessionEvent[]>()
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit(options.stateVersion))
|
||||
const persistence = fakePersistence(root, logs)
|
||||
ctx.provide('sessionPersistence', persistence as never)
|
||||
const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
return { ctx, root, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
|
||||
const fiber = await ctx.plugin(SessionProjectionCache, {
|
||||
root,
|
||||
...options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 },
|
||||
})
|
||||
return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
|
||||
}
|
||||
|
||||
const mark = (session: Session, marks: string[]): SessionEvent =>
|
||||
@@ -131,7 +114,7 @@ async function seedRecord(
|
||||
rows: CheckpointRecord['rows'],
|
||||
identity: CheckpointRecord['identity'] = { createdAt: 0 },
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(cachePath(root, SessionId(id))), { recursive: true })
|
||||
await mkdir(join(root, id), { recursive: true })
|
||||
await writeFile(cachePath(root, SessionId(id)), JSON.stringify({ identity, rows }))
|
||||
}
|
||||
|
||||
@@ -192,18 +175,6 @@ describe('SessionProjectionCache write policy', () => {
|
||||
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
|
||||
})
|
||||
|
||||
it('serializes concurrent checkpoints so they land in call order', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('chained'))
|
||||
const first = ctx.sessionProjectionCache.write(session)
|
||||
mark(session, ['a'])
|
||||
const second = ctx.sessionProjectionCache.write(session)
|
||||
await Promise.all([first, second])
|
||||
// Both cuts landed; the file holds the second (newer) cut (the mark is
|
||||
// the session's first event, seq 0).
|
||||
expect((await storedRows(root, session.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: 0, val: { marks: ['a'] } })
|
||||
})
|
||||
|
||||
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
// Never dirtied: no events — write() still lands the init-derived cut.
|
||||
@@ -239,36 +210,24 @@ describe('SessionProjectionCache write policy', () => {
|
||||
it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
// A file where a directory is needed makes the first write fail...
|
||||
await writeFile(join(root, 'blocked'), '')
|
||||
const logs = new Map<string, SessionEvent[]>()
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
let block = true
|
||||
ctx.provide('sessionPersistence', {
|
||||
readFrom: async (id: SessionId, fromSeq: number) => {
|
||||
const events = logs.get(String(id))
|
||||
if (events === undefined) throw new Error(`session "${id}" not found`)
|
||||
return { meta: { version: 0, id, createdAt: 0 }, events: events.filter(event => event.seq >= fromSeq) }
|
||||
},
|
||||
// ...and the locate seam can be un-blocked to let the next write succeed.
|
||||
locate: (meta: SessionHeader) => block
|
||||
? { kind: 'jsonl', path: join(root, 'blocked', String(meta.id), 'session.jsonl') }
|
||||
: { kind: 'jsonl', path: join(root, String(meta.id), 'session.jsonl') },
|
||||
} as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
await ctx.plugin(SessionProjectionCache, { root, writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = ctx.sessions.create(SessionId('fail-soft'))
|
||||
// A directory where the cache file must land makes the atomic rename
|
||||
// fail on the first write...
|
||||
await mkdir(cachePath(root, session.id), { recursive: true })
|
||||
mark(session, ['x'])
|
||||
endTurn(session)
|
||||
await settle()
|
||||
expect(await storedRows(root, session.id)).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
|
||||
// Self-heal: the next mandatory point writes the current cut.
|
||||
block = false
|
||||
// Self-heal: once the blocker clears, the next mandatory point writes.
|
||||
await rm(cachePath(root, session.id), { recursive: true })
|
||||
mark(session, ['y'])
|
||||
endTurn(session)
|
||||
await settle()
|
||||
@@ -276,117 +235,22 @@ describe('SessionProjectionCache write policy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionProjectionCache cold read', () => {
|
||||
const storedLog = (marks: string[][]): SessionEvent[] => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
]
|
||||
for (const m of marks) {
|
||||
events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
|
||||
}
|
||||
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return events
|
||||
}
|
||||
|
||||
it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
|
||||
describe('SessionProjectionCache listing read', () => {
|
||||
it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
|
||||
// A warm-era checkpoint at watermark 1 (only ['a'] folded).
|
||||
await seedRecord(root, 'cold', { 'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['a'] } } })
|
||||
const { cache, persistence, root: sameRoot } = await harness({ root, logs })
|
||||
const id = SessionId('cold')
|
||||
const snapshot = await cache.coldSnapshot(headerOf(id))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
|
||||
expect(snapshot.asOfSeq).toBe(3)
|
||||
// The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
|
||||
expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
|
||||
// Write-back: the stored row advanced to the served cut.
|
||||
expect((await storedRows(sameRoot, id))?.['cache-test/marks'])
|
||||
.toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
|
||||
await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
|
||||
const { cache } = await harness({ root })
|
||||
const id = SessionId('listed')
|
||||
// Matching header: values plus the watermark the client seeds under.
|
||||
expect(await cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
|
||||
// A recreated id (different createdAt): the record is unrelated — no block.
|
||||
expect(await cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
|
||||
// Unknown id: no block.
|
||||
expect(await cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a version-mismatched row and refolds the full log', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['bumped', storedLog([['a']])]])
|
||||
await seedRecord(root, 'bumped', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['stale'] } } })
|
||||
const { cache, persistence } = await harness({ root, logs, stateVersion: 2 })
|
||||
const snapshot = await cache.coldSnapshot(headerOf(SessionId('bumped')))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
// Mismatch pulls the floor to 0: one full read, no second pass needed.
|
||||
expect(persistence.readFrom).toHaveBeenCalledTimes(1)
|
||||
expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
|
||||
})
|
||||
|
||||
it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
|
||||
await seedRecord(root, 'shrunk', { 'cache-test/marks': { ver: 1, seq: 9, val: { marks: ['ghost'] } } })
|
||||
const { cache, persistence } = await harness({ root, logs })
|
||||
const snapshot = await cache.coldSnapshot(headerOf(SessionId('shrunk')))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(snapshot.asOfSeq).toBe(2)
|
||||
// Anchored tail read (floor 9) came back empty -> full re-read from 0.
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
|
||||
})
|
||||
|
||||
it('discards malformed persisted state and degrades to one full re-read', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['malformed', storedLog([['real']])]])
|
||||
await seedRecord(root, 'malformed', { 'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'not-an-array' } } })
|
||||
const { cache, persistence } = await harness({ root, logs })
|
||||
|
||||
const snapshot = await cache.coldSnapshot(headerOf(SessionId('malformed')))
|
||||
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
|
||||
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
|
||||
})
|
||||
|
||||
it('write-back failure is contained: the snapshot is still served', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['soft', storedLog([['a']])]])
|
||||
const cacheFile = cachePath(root, SessionId('soft'))
|
||||
await seedRecord(root, 'soft', { 'cache-test/marks': { ver: 1, seq: 0, val: { marks: [] } } })
|
||||
const { ctx, cache } = await harness({ root, logs })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
// A directory where the cache file must land makes the atomic rename fail;
|
||||
// the served snapshot is unaffected.
|
||||
await rm(cacheFile)
|
||||
await mkdir(cacheFile)
|
||||
const snapshot = await cache.coldSnapshot(headerOf(SessionId('soft')))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
|
||||
})
|
||||
|
||||
it('rejects for a session with no persisted log', async () => {
|
||||
const { cache } = await harness()
|
||||
await expect(cache.coldSnapshot(headerOf(SessionId('absent')))).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
|
||||
// A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
|
||||
// its rows pass every watermark check, but the identity does not match.
|
||||
await seedRecord(root, 'reborn', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['phantom'] } } }, { createdAt: 999 })
|
||||
const { cache, root: sameRoot } = await harness({ root, logs })
|
||||
// The caller holds the STALE header (the old lifecycle's createdAt): the
|
||||
// stale record passes the read-side filter, but the stored log's header
|
||||
// is the identity witness and rebinds the write-back to the real lifecycle.
|
||||
const snapshot = await cache.coldSnapshot(headerOf(SessionId('reborn'), 999))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
|
||||
// The write-back rebinds the record to the actual log's identity.
|
||||
expect((await storedRecord(sameRoot, SessionId('reborn')))?.identity).toEqual({ createdAt: 0 })
|
||||
})
|
||||
|
||||
it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
|
||||
it('returns undefined when every stored row is version-mismatched', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
await seedRecord(root, 'all-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
|
||||
@@ -405,99 +269,12 @@ describe('SessionProjectionCache cold read', () => {
|
||||
expect(await cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dates an empty stored log at -1 in the zero-units topology', async () => {
|
||||
it('returns undefined for a malformed cache file (refold from the log on the caller side)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['empty', [] as SessionEvent[]]])
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.provide('sessionPersistence', fakePersistence(root, logs) as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('empty'))))
|
||||
.resolves.toEqual({ asOfSeq: -1, values: {} })
|
||||
})
|
||||
|
||||
it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
|
||||
await mkdir(join(root, 'malformed'), { recursive: true })
|
||||
await writeFile(cachePath(root, SessionId('malformed')), 'not json at all')
|
||||
const { cache } = await harness({ root })
|
||||
const id = SessionId('listed')
|
||||
// Matching header: values plus the watermark the client seeds under.
|
||||
expect(await cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
|
||||
// A recreated id (different createdAt): the record is unrelated — no block.
|
||||
expect(await cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
|
||||
// Unknown id: no block.
|
||||
expect(await cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('SessionProjectionCache without a per-session directory (sqlite-style backend)', () => {
|
||||
it('write() no-ops and cachedSnapshot is undefined — no cache file is ever created', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.provide('sessionPersistence', {
|
||||
readFrom: async () => { throw new Error('no log') },
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
const session = ctx.sessions.create(SessionId('no-path'))
|
||||
mark(session, ['a'])
|
||||
endTurn(session)
|
||||
await settle()
|
||||
expect(await storedRows(root, session.id)).toBeUndefined()
|
||||
expect(await ctx.sessionProjectionCache.cachedSnapshot(headerOf(session.id))).toBeUndefined()
|
||||
// An explicit write is also a no-op: no checkpoint cut, no durability flush.
|
||||
await expect(ctx.sessionProjectionCache.write(session)).resolves.toBeUndefined()
|
||||
expect(await storedRows(root, session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coldSnapshot falls to the full-log rung when no cache path exists', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['nopath', storedLog([['a']])]])
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
|
||||
const events = logs.get(String(id))
|
||||
if (events === undefined) throw new Error(`session "${id}" not found`)
|
||||
return { meta: { version: 0, id, createdAt: 0 }, events: events.filter(event => event.seq >= fromSeq) }
|
||||
})
|
||||
ctx.provide('sessionPersistence', { readFrom, locate: () => undefined } as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
const snapshot = await ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('nopath')))
|
||||
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
|
||||
// No cached rows: the restore floor is undefined, so the ladder is one
|
||||
// full probe read from seq 0 and no write-back happens.
|
||||
expect(readFrom).toHaveBeenCalledWith(SessionId('nopath'), 0, undefined)
|
||||
expect(await storedRows(root, SessionId('nopath'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
|
||||
// Same composition minus any registered unit: restoreFloor is undefined,
|
||||
// yet coldSnapshot must still reject for an absent log (probe read) and
|
||||
// serve an empty cut at the stored end for a present one.
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.provide('sessionPersistence', fakePersistence(root, logs) as never)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('absent')))).rejects.toThrow('not found')
|
||||
await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('bare'))))
|
||||
.resolves.toEqual({ asOfSeq: 2, values: {} })
|
||||
expect(await cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../session-projection"
|
||||
},
|
||||
|
||||
Generated
+3
-9
@@ -1093,6 +1093,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-session-projection-cache':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection-cache
|
||||
'@deepseek-ai/dsh-session-query-sqlite':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query-sqlite
|
||||
@@ -6236,9 +6239,6 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../session-persistence
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../session-projection
|
||||
@@ -7114,12 +7114,6 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection-cache':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection-cache
|
||||
'@deepseek-ai/dsh-storage':
|
||||
specifier: workspace:^
|
||||
version: link:../../storage/storage
|
||||
'@deepseek-ai/dsh-storage-domain':
|
||||
specifier: workspace:^
|
||||
version: link:../../storage/storage-domain
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
Reference in New Issue
Block a user