refactor(session-projection): compact checkpoint row fields to ver/seq/val

The persisted row (sessionId, key, stateVersion, observedSeq, state)
becomes (sessionId, key, ver, seq, val) — the cache medium repeats these
three names for every unit of every session, so the long forms dominated
the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec
rename together; the domain spec bumps to v3 (cache semantics: the old
medium is discarded, not migrated). The unit-facing declaration keeps
stateVersion — only the persisted/checkpoint row shape changes.
This commit is contained in:
imccyu
2026-07-28 22:45:35 +08:00
parent 931dd934e3
commit b4bc4f382e
17 changed files with 121 additions and 121 deletions

View File

@@ -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-projection/session-projection-cache/README.md
README.md: a10bb858159e9581c815d2532a893208c45e788a
README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff
README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4

View File

@@ -4,10 +4,10 @@ English | [中文](README.zh.md)
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). 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 → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to:
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.
- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
- **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.
- **Whole-record writes.** Each write replaces the session's full checkpoint (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 row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.

View File

@@ -4,10 +4,10 @@
持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。
- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。

View File

@@ -3,10 +3,10 @@
* checkpoints of every registered projection unit's state, one record per
* session on the domain data form (`session_projcache` domain — the shipped
* json backend lands it beside `workspace.json`). The cache is a fold
* shortcut, never an authority: a row is possibly stale (its `observedSeq`
* 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
* `stateVersion` mismatch discards the row instead of migrating it. Design
* `ver` mismatch discards the row instead of migrating it. Design
* authority: the session-projection RFC
* (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
* @module @deepseek-ai/dsh-session-projection-cache
@@ -125,7 +125,7 @@ export class SessionProjectionCache extends Service {
// The block carries ONE cut: the lowest served watermark is the seq every
// value is at least current as of (under-claiming is safe under
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq))
const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq))
return { asOfSeq, values }
}

View File

@@ -16,7 +16,7 @@ export const inject = ['invariants']
/**
* No runtime invariant: the cache's correctness relation (a stored row equals
* the registry fold at its `observedSeq`) is only checkable by re-running the
* the registry fold at its `seq` watermark) is only checkable by re-running the
* fold over the persisted log — duplicating the implementation rather than
* detecting drift — and its staleness is by design (fail-soft writes). The
* durable boundary is already schema-validated by the storage-domain layer

View File

@@ -1,7 +1,7 @@
/**
* The session-projcache domain declaration: one `sessions` table keyed by
* {@link SessionId}, each record the full projection checkpoint for one
* session (`key → {stateVersion, observedSeq, state}` rows). The spec object
* session (`key → {ver, seq, val}` rows). The spec object
* is the single source of the domain's identity, version, and record schema;
* the storage-domain routing decides the medium (the shipped composition's
* json backend lands it at `<root>/session_projcache.json`, beside
@@ -14,17 +14,17 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
/**
* One persisted checkpoint row (the RFC's `(sessionId, key, stateVersion,
* observedSeq, state)` minus the two record keys). `state` is the unit's
* internal state — plain JSON by the unit contract; `z.json()` enforces that
* at the durable boundary. A row is never wrong, only possibly stale:
* `observedSeq` says exactly how stale, and a `stateVersion` mismatch
* One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
* minus the two record keys). `val` is the unit's internal state — plain
* JSON by the unit contract; `z.json()` enforces that at the durable
* boundary. A row is never wrong, only possibly stale: `seq` says exactly
* how stale, and a `ver` mismatch against the live unit's `stateVersion`
* discards it at read time (never a migration).
*/
export const checkpointRow = z.object({
stateVersion: z.number().int().nonnegative(),
observedSeq: z.number().int().gte(-1),
state: z.json(),
ver: z.number().int().nonnegative(),
seq: z.number().int().gte(-1),
val: z.json(),
})
/**
@@ -61,10 +61,11 @@ export type CheckpointRecord = z.infer<typeof checkpointRecord>
/**
* The session-projcache domain spec. Version bumps discard the whole medium
* (cache semantics: a stale or unreadable cache costs a longer tail replay,
* never a wrong value). v2 added the record's log-identity binding.
* never a wrong value). v2 added the record's log-identity binding; v3
* renamed the row fields to `ver`/`seq`/`val`.
*/
export const projectionCacheDomainSpec = defineDomain({
name: 'session_projcache',
version: 2,
version: 3,
tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
})

View File

@@ -100,7 +100,7 @@ function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
{
identity: { createdAt: number; cwd?: string }
rows: Record<string, { stateVersion: number; observedSeq: number; state: unknown }>
rows: Record<string, { ver: number; seq: number; val: unknown }>
} | undefined
}
@@ -126,7 +126,7 @@ describe('SessionProjectionCache write policy', () => {
const end = endTurn(session)
await settle()
const rows = storedRows(pool, session.id)
expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } })
expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
})
it('writes at session disposal (detach, the live-to-cold moment)', async () => {
@@ -140,7 +140,7 @@ describe('SessionProjectionCache write policy', () => {
mark(session, ['live'])
await owner.dispose()
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] })
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
})
it('flushes when the in-turn event count reaches the configured threshold', async () => {
@@ -152,7 +152,7 @@ describe('SessionProjectionCache write policy', () => {
expect(storedRows(pool, session.id)).toBeUndefined()
mark(session, ['3'])
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] })
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
})
it('flushes on the configured interval when the count threshold is not reached', async () => {
@@ -164,7 +164,7 @@ describe('SessionProjectionCache write policy', () => {
expect(storedRows(pool, session.id)).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(0)
expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] })
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
})
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
@@ -172,7 +172,7 @@ describe('SessionProjectionCache write policy', () => {
// Never dirtied: no events — write() still lands the init-derived cut.
const clean = ctx.sessions.create(SessionId('clean-write'))
await ctx.sessionProjectionCache.write(clean)
expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null })
expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
// A unit whose state violates the plain-JSON contract fails the write loud.
ctx.sessionProjections.register({
key: 'cache-test/marks2' as never,
@@ -214,7 +214,7 @@ describe('SessionProjectionCache write policy', () => {
mark(session, ['y'])
endTurn(session)
await settle()
expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] })
expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
})
})
@@ -234,10 +234,10 @@ describe('SessionProjectionCache cold read', () => {
function seedRow(
pool: MemoryMediaPool,
id: string,
row: { stateVersion: number; observedSeq: number; state: unknown },
row: { ver: number; seq: number; val: unknown },
identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
): void {
pool.versions.set('session_projcache', 2)
pool.versions.set('session_projcache', 3)
pool.media.set('session_projcache', {
tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
global: null,
@@ -248,7 +248,7 @@ describe('SessionProjectionCache cold read', () => {
const pool = new MemoryMediaPool()
const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
// A warm-era checkpoint at watermark 1 (only ['a'] folded).
seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } })
seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
const { cache, persistence, pool: samePool } = await harness({ pool, logs })
const id = SessionId('cold')
const snapshot = await cache.coldSnapshot(id)
@@ -258,13 +258,13 @@ describe('SessionProjectionCache cold read', () => {
expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
// Write-back: the stored row advanced to the served cut.
expect(storedRows(samePool, id)?.['cache-test/marks'])
.toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } })
.toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
})
it('discards a version-mismatched row and refolds the full log', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['bumped', storedLog([['a']])]])
seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } })
seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
const snapshot = await cache.coldSnapshot(SessionId('bumped'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
@@ -276,7 +276,7 @@ describe('SessionProjectionCache cold read', () => {
it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } })
seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
const { cache, persistence } = await harness({ pool, logs })
const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
@@ -307,7 +307,7 @@ describe('SessionProjectionCache cold read', () => {
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.
seedRow(pool, 'reborn', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 })
seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
const { cache, pool: samePool } = await harness({ pool, logs })
const snapshot = await cache.coldSnapshot(SessionId('reborn'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
@@ -317,14 +317,14 @@ describe('SessionProjectionCache cold read', () => {
it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } })
seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
const { cache } = await harness({ pool })
expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
})
it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
const { cache } = await harness({ pool })
const id = SessionId('homed')
expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
@@ -352,7 +352,7 @@ describe('SessionProjectionCache cold read', () => {
it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
const pool = new MemoryMediaPool()
seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } })
seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
const { cache } = await harness({ pool })
const id = SessionId('listed')
// Matching header: values plus the watermark the client seeds under.