Browse Source

fix(session-projection-cache): checkpoint at session creation

A session that never talks — a forked child seeded with its ancestor's
title, say — previously got its first cache row only at detach; a crash,
or a fork held live in the store, left the seed-derived values (the
title) unreadable on the cold list. Session creation is now a third
mandatory write point: the creation checkpoint folds the seed and
persists immediately. Write-policy docs (README + catalogs) updated.
_Kerman 1 month ago
parent
commit
b67761a8a5

+ 2 - 2
docs/subsystems/session-projection.i18n.yaml

@@ -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 docs/subsystems/session-projection.md
-session-projection.md: 13c44fe9d52becaa18ab0e164e3dc7e607565bef
-session-projection.zh.md: f727b554c0779296b139b5981c344373a59c16a6
+session-projection.md: 2a6377b1bc4d68e4f9800320800f3c5670ab479c
+session-projection.zh.md: fe0bca725e8b5663dda351b394933d37bed7ea48

+ 17 - 3
docs/subsystems/session-projection.md

@@ -112,7 +112,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, 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 persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `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.
 
 ```ts cordis-catalog
 /**
@@ -129,6 +129,20 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  */
 cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 
+/**
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
+ */
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
+
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
@@ -141,9 +155,9 @@ cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 async write(session: Session): Promise<void>
 ```
 
-Types: [Session](session.md) · [SessionHeader](persistence.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:73`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

+ 17 - 3
docs/subsystems/session-projection.zh.md

@@ -112,7 +112,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, 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 persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `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.
 
 ```ts cordis-catalog
 /**
@@ -129,6 +129,20 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  */
 cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 
+/**
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
+ */
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
+
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
@@ -141,9 +155,9 @@ cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 async write(session: Session): Promise<void>
 ```
 
-Types: [Session](session.md) · [SessionHeader](persistence.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:73`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

+ 7 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1182,7 +1182,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'sessionProjectionCache',
     summary: 'The persisted projection cache service.',
-    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, 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.',
+    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `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.',
     methods: [
       {
         signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined',
@@ -1190,6 +1190,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         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.',
       },
+      {
+        signature: 'coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot',
+        description: 'Cold-read one session\'s projections from its complete log. Each unit is seeded from the identity-checked cached rows — the registry skips `apply` for the already-folded prefix (events at or below the row\'s `seq`) — and the refreshed checkpoint is written back (fail-soft, fire-and-forget), so the first cold read creates the cache row and later ones seed from it. The caller supplies the complete log in seq order: this service never consults the persistence layer.',
+        parameters: [{ name: 'meta', description: 'the stored session header (identity witness).' }, { name: 'events', description: 'the session\'s complete log, in seq order.' }],
+        returns: 'the projection cut at the log end.',
+      },
       {
         signature: 'async write(session: Session): Promise<void>',
         description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the session\'s record is replaced on the domain\'s write chain. NOT fail-soft — callers on the fail-soft paths contain it.',

+ 2 - 2
packages/session/session-projection-cache/README.i18n.yaml

@@ -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: fd7533b23c842fe5da45e31476be0daa49b65edf
-README.zh.md: 13990cd5b65baac9ac3222222dc0ec3de1cb5b15
+README.md: 3985daeca4dd3cc0b3a9b0fcf68070f368b0c31f
+README.zh.md: 5081faeef8d9d549486b6bcec72ce071b41ff651

+ 2 - 1
packages/session/session-projection-cache/README.md

@@ -17,10 +17,11 @@ A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority:
 
 ## Write policy
 
-Two mandatory points, throttled in between:
+Three mandatory points, throttled in between:
 
 | Trigger | Nature |
 |---|---|
+| Session creation | Mandatory — the seed-derived cut (a forked child's inherited title, say) lands immediately, so a crash or a live-held session never loses it from the cold list. |
 | `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). |

+ 2 - 1
packages/session/session-projection-cache/README.zh.md

@@ -17,10 +17,11 @@
 
 ## 写策略
 
-个必写点,其间节流:
+个必写点,其间节流:
 
 | 触发 | 性质 |
 |---|---|
+| 会话创建 | 必写——seed 派生的切面(比如 fork 子会话继承的标题)立即落盘,崩溃或长期存活的会话也不会从冷列表丢失。 |
 | `turn/end` | 必写——列表读要的正是轮次终值。 |
 | 会话释放(detach) | 必写——live 转 cold 的时刻;此后缓存服务该会话的最终切面。 |
 | 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |

+ 16 - 5
packages/session/session-projection-cache/src/index.ts

@@ -37,8 +37,9 @@ declare module '@deepseek-ai/cordis' {
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -63,8 +64,9 @@ interface DirtyState {
 /**
  * The persisted projection cache service. Opens the `session_projcache`
  * domain at init, 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
+ * (count/interval triggers from {@link Config}) plus three mandatory points —
+ * session creation, `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.
  */
@@ -199,6 +201,15 @@ export class SessionProjectionCache extends Service {
       }, this.config.writeIntervalMs)
     })
 
+    // Creation is the FIRST mandatory point: a session that never talks (a
+    // forked child seeded with its ancestor's title, say) would otherwise
+    // get its first row only at detach — so a crash, or a fork held live in
+    // the store, would leave the seed-derived values (the title) unreadable
+    // on the cold list. The creation write captures the seed-derived cut.
+    this.ctx.on('session/created', (session: Session) => {
+      void this.flushSoft(session, 'create')
+    })
+
     // Detach (the live-to-cold moment): the second mandatory point. After
     // this write the cold-read ladder serves the session from the cache.
     // flushSoft's synchronous prefix reads and resets the dirty state, so
@@ -225,7 +236,7 @@ export class SessionProjectionCache extends Service {
   /**
    * One fail-soft durable checkpoint. Every caller has work by construction:
    * the throttle triggers only fire dirty (markClean clears the timer with
-   * the counter) and the two mandatory points write unconditionally.
+   * the counter) and the mandatory points write unconditionally.
    */
   private async flushSoft(session: Session, trigger: string): Promise<void> {
     try {

+ 25 - 7
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -146,13 +146,29 @@ describe('SessionProjectionCache write policy', () => {
     const { ctx, root } = await harness()
     const session = ctx.sessions.create(SessionId('turn-end'))
     mark(session, ['a'])
-    expect(await storedRows(root, session.id)).toBeUndefined() // throttled: no write yet
+    // Creation already wrote the init cut; the mark is throttled, so the
+    // stored row is still the creation-time cut (no marks folded).
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
     const end = endTurn(session)
     await settle()
     const rows = await storedRows(root, session.id)
     expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
   })
 
+  it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
+    const { ctx, root } = await harness()
+    // A forked child seeded with its ancestor's title-like event: no
+    // conversation follows, yet the creation write must capture the fold so
+    // a crash or a live-held fork still lists the derived value.
+    const session = ctx.sessions.create(SessionId('seeded'), {
+      seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
+    })
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
+      .toEqual({ marks: ['seed'] })
+  })
+
   it('writes at session disposal (detach, the live-to-cold moment)', async () => {
     const { ctx, root } = await harness()
     // Sessions dispose with their owning fiber: create in a child plugin.
@@ -173,7 +189,7 @@ describe('SessionProjectionCache write policy', () => {
     mark(session, ['1'])
     mark(session, ['2'])
     await settle()
-    expect(await storedRows(root, session.id)).toBeUndefined()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
     mark(session, ['3'])
     await settle()
     expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
@@ -184,7 +200,7 @@ describe('SessionProjectionCache write policy', () => {
     const session = ctx.sessions.create(SessionId('interval'))
     mark(session, ['slow'])
     await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
-    expect(await storedRows(root, session.id)).toBeUndefined()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
     await settle() // past the interval; the fire-and-forget write lands
     expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
   })
@@ -218,7 +234,8 @@ describe('SessionProjectionCache write policy', () => {
     await fiber.dispose()
     // The armed timer died with the plugin: advancing time writes nothing.
     await vi.advanceTimersByTimeAsync(10_000)
-    expect(await storedRows(root, armed.id)).toBeUndefined()
+    // Only the creation cut exists: the armed mark never wrote.
+    expect((await storedRows(root, armed.id))?.['cache-test/marks']?.seq).toBe(-1)
   })
 
   it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
@@ -234,10 +251,11 @@ describe('SessionProjectionCache write policy', () => {
     ctx.sessionProjections.register(marksUnit())
     await ctx.plugin(SessionProjectionCache, { 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 record document must land makes the atomic
-    // rename fail on the first write...
-    await mkdir(recordPath(root, session.id), { recursive: true })
+    // rename fail — including the creation write, so no row ever lands.
+    const blocker = recordPath(root, SessionId('fail-soft'))
+    await mkdir(blocker, { recursive: true })
+    const session = ctx.sessions.create(SessionId('fail-soft'))
     mark(session, ['x'])
     endTurn(session)
     await settle()