mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(session-projection): keep released units in checkpoints (#7754)
* fix(session-projection): keep released units in checkpoints Since the Cordis rc.11 teardown order unloads the Web layer before the base layer, Session Controller and harness-dev-context release their projection units before their live Sessions detach. The projection cache is still alive at that point and wrote a checkpoint holding only the units that were still registered, replacing the complete record. A blank Session then lost sessionListMetadata and listed as an untitled conversation after every graceful restart. A released unit now stays in the registry as a predecessor: it leaves event drives, snapshots, restores, and change notifications, while checkpoint() keeps each Session's last cell of it without advancing it. Registering the same key again replaces the predecessor and refolds the log. The projection cache keeps replacing whole records. * test(session-projection): assert frozen released units across unload tests Update the remaining unload tests that still expected a released unit to vanish from checkpoint(): user-approval, agent-loop inbox and scope lifecycle, time-context, and the agent registry now assert that the same Session keeps its pre-unload row, that a later event does not advance it, and that a Session created after the unload has no row. The token-meter and goal unload tests also append an event after the unload. Document that a retained handle read still advances a released cell that checkpoint() serves, and that restore() folds only live units, so its refreshed rows omit released units. * docs(session-projection): sync the ProjectionHandle type-equiv block * docs(session-projection): record frozen released units and their upgrade Add the upgrade guide for checkpoint() keeping a released unit's last row, update the registration-handles Agent Note with the frozen-cell decision and the retained predecessor, list both consequences under the registry README's known limitations, note in the projection cache README that a stored row can be frozen at its unit's release, and say that restoreFloor() ignores released predecessors. Update the agent-loop resource-lifecycle unload test, which read checkpoints through an alias, to the frozen-row semantics. * docs(upgrade-guide): mark the released-checkpoint snippet ignore-check * docs(session-projection): state the retained-handle path and narrow a rejected alternative * docs(upgrade-guide): re-record the released-checkpoint guide pairing
This commit is contained in:
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: 47ba548b869be190
|
||||
zh: c2e5038a56fe741c
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
|
||||
en: be0d17bfbd1b59d4
|
||||
zh: 7a23bccdf95c21ff
|
||||
en: a6f9ae8a53af5e4b
|
||||
zh: 0aece791574ef060
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
|
||||
en: e7c66403fab991d2
|
||||
zh: 1580ec29116c7d42
|
||||
en: 12e0f5fc9638acc6
|
||||
zh: 24213550da27b70b
|
||||
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
|
||||
en: 31ce6f02dd3bf39d
|
||||
zh: bcd29b2146f689b5
|
||||
en: f5c294608d87844f
|
||||
zh: 24488e439a04d81a
|
||||
|
||||
+6
-2
@@ -12,7 +12,7 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a
|
||||
|
||||
`SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
|
||||
|
||||
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
|
||||
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives and snapshots. Checkpoints keep each Session's last cell of the released unit until its key registers again: the registry no longer advances that cell, while a retained handle read still advances it forward, so a writer that runs after the owner unloads still stores the state already folded. Under the Cordis rc.11 teardown order, Web-layer plugins release their units before the Sessions they describe detach, and the final projection-cache write would otherwise drop those rows. Stored rows can therefore lag the log; the cache promises only possibly stale rows. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
|
||||
|
||||
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
|
||||
|
||||
@@ -20,6 +20,10 @@ Plugins compiled against the synchronous API keep running during migration. The
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Detach owned Sessions before unregistering.** Other Web-layer plugins unload in earlier fibers under the rc.11 teardown order, and plugin reload or disable reaches the same timing, so ordering one owner cannot keep every row.
|
||||
|
||||
**Checkpoint a released unit only while its cell is at the Session cursor.** A Session with a running turn at shutdown appends teardown events after its units are released, so its rows would be dropped again.
|
||||
|
||||
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
|
||||
|
||||
**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness.
|
||||
@@ -36,4 +40,4 @@ Plugins compiled against the synchronous API keep running during migration. The
|
||||
|
||||
## Consequences
|
||||
|
||||
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
|
||||
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. The registry also retains a released predecessor, with its definition closures and weakly keyed cells, until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old definition referenced for the process lifetime. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
|
||||
|
||||
+6
-2
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
`SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
|
||||
|
||||
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
|
||||
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进和快照中移除单元。检查点仍保留每个 Session 中已释放单元的最后一个 cell,直到它的 key 再次注册:注册表不再推进这个 cell,但已保留的 handle 读取时仍会将其向前推进,因此 owner 卸载后才运行的写入方仍会存下已经折叠出的状态。按 Cordis rc.11 的卸载顺序,Web 层插件会在它们所描述的 Session detach 之前释放单元,否则 projection cache 的最终写入会丢掉这些行。因此存储的行可能落后于日志;cache 只承诺行可能过时。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
|
||||
|
||||
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
|
||||
|
||||
@@ -20,6 +20,10 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**卸载前先 detach 自己拥有的 Session。** 按 rc.11 的卸载顺序,其他 Web 层插件在更早卸载的 fiber 里,插件重载或禁用也会碰上同样的时机,调整单个 owner 的顺序保不住所有行。
|
||||
|
||||
**只在已释放单元的 cell 位于 Session 游标时才写入检查点。** 关停时正在运行一轮的会话会在单元释放后追加 teardown 事件,因此它的这些行会再次被丢掉。
|
||||
|
||||
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
|
||||
|
||||
**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。
|
||||
@@ -36,4 +40,4 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`
|
||||
|
||||
## Consequences
|
||||
|
||||
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
|
||||
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册表自身也会保留已释放的前任及其定义闭包和弱键单元,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧定义。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
|
||||
|
||||
@@ -84,6 +84,24 @@ class RestartableServer {
|
||||
logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1<redacted>') }
|
||||
}
|
||||
|
||||
type HostRpc = <T>(endpoint: string, args: object) => Promise<T>
|
||||
|
||||
/** Authenticate against one server process and return its Host RPC caller. */
|
||||
async function hostRpc(url: string): Promise<HostRpc> {
|
||||
const authenticated = await fetch(url, { redirect: 'manual' })
|
||||
const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0]
|
||||
if (cookie === undefined) throw new Error('Server did not issue an authentication cookie')
|
||||
return async <T>(endpoint: string, args: object): Promise<T> => {
|
||||
const response = await fetch(new URL('/api/' + endpoint, url), {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', cookie },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }),
|
||||
})
|
||||
const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } }
|
||||
if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`)
|
||||
return result.result.value
|
||||
}
|
||||
}
|
||||
|
||||
it.each([false, true])('keeps the same revision, Session and page across a server restart (delayed startup: %s)', async (holdStartup) => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-'))
|
||||
onTestFinished(() => rm(world, { recursive: true, force: true }))
|
||||
@@ -118,18 +136,7 @@ it.each([false, true])('keeps the same revision, Session and page across a serve
|
||||
|
||||
const url = await server.start(0)
|
||||
const port = Number(new URL(url).port)
|
||||
const authenticated = await fetch(url, { redirect: 'manual' })
|
||||
const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0]
|
||||
if (cookie === undefined) throw new Error('Server did not issue an authentication cookie')
|
||||
const rpc = async <T>(endpoint: string, args: object): Promise<T> => {
|
||||
const response = await fetch(new URL('/api/' + endpoint, url), {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', cookie },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } }),
|
||||
})
|
||||
const result = await response.json() as { result: { ok: boolean; value: T; error?: { message: string } } }
|
||||
if (!result.result.ok) throw new Error(`${endpoint}: ${String(result.result.error?.message)}`)
|
||||
return result.result.value
|
||||
}
|
||||
const rpc = await hostRpc(url)
|
||||
await rpc('workspace/create', { request: { path: workspacePath } })
|
||||
const scenarioId = randomUUID()
|
||||
|
||||
@@ -234,3 +241,30 @@ it.each([false, true])('keeps the same revision, Session and page across a serve
|
||||
expect(streamErrors).toEqual([])
|
||||
expect(navigations).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a live blank Session blank across a graceful server restart', async () => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-'))
|
||||
onTestFinished(() => rm(world, { recursive: true, force: true }))
|
||||
// The scenario runs no model turn, so the model endpoint is never contacted.
|
||||
const server = new RestartableServer(world, 'http://127.0.0.1:9')
|
||||
onTestFinished(() => server.stop())
|
||||
const workspacePath = join(world, 'workspace')
|
||||
await mkdir(workspacePath)
|
||||
onTestFailed(async () => {
|
||||
await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-blank-' + randomUUID() + '.json'), JSON.stringify({ server: server.logs() }, null, 2))
|
||||
})
|
||||
|
||||
const before = await hostRpc(await server.start(0))
|
||||
const { workspace } = await before<{ workspace: { id: string } }>('workspace/create', { request: { path: workspacePath } })
|
||||
const { sessionId } = await before<{ sessionId: string }>('session/create', { request: { workspaceId: workspace.id } })
|
||||
const listed = async (rpc: HostRpc) => (await rpc<{
|
||||
items: { sessionId: string; blank: boolean; agentAvailable: boolean }[]
|
||||
}>('session/list', { _request: {} })).items.find(item => item.sessionId === sessionId)
|
||||
expect(await listed(before)).toMatchObject({ blank: true, agentAvailable: true })
|
||||
|
||||
// SIGTERM unloads the Web layer, including the list projection, before its live Session detaches;
|
||||
// stopping right after creation also precedes the throttled checkpoint of the Session's setup events.
|
||||
await server.stop()
|
||||
const after = await hostRpc(await server.start(0))
|
||||
expect(await listed(after)).toMatchObject({ blank: true, agentAvailable: false })
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
|
||||
/session-projections:
|
||||
en: b2789f5257a935c5
|
||||
zh: 12d430a9cd454592
|
||||
en: 1804bc8fc5f226e4
|
||||
zh: 2f31d0f014508d29
|
||||
/session-projections/the-unit:
|
||||
en: d48814d3ab0e997f
|
||||
zh: d0337d8eba097a63
|
||||
@@ -18,5 +18,5 @@
|
||||
en: 6a93a4e1682fd6e6
|
||||
zh: 28729129c3d8a3b1
|
||||
/session-projections/the-registry-ctx-sessionprojections:
|
||||
en: 67fe0f49dee90e25
|
||||
zh: c23de3a2803040f0
|
||||
en: 33e4aa5276d4c0fa
|
||||
zh: 18019b4990734b82
|
||||
|
||||
@@ -6,7 +6,7 @@ The session-projection seam — a [capability seam](../capability-seams.md) thro
|
||||
|
||||
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
|
||||
A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md).
|
||||
A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md).
|
||||
|
||||
```ts type-equiv
|
||||
/** An effect-owned registration with a reader retained independently of registration. */
|
||||
@@ -14,6 +14,8 @@ interface ProjectionHandle<S> {
|
||||
/**
|
||||
* Read the current state, including after unregistering, by folding new
|
||||
* Session events with this handle's original unit. Do not mutate the live value.
|
||||
* After the final release, a read also advances the released cell that
|
||||
* `checkpoint(session)` serves, until the key registers again.
|
||||
* @param session - Session whose committed events are folded.
|
||||
* @returns the projection state at the Session's current cursor.
|
||||
*/
|
||||
@@ -230,7 +232,7 @@ type ProjectionKeySelection =
|
||||
|
||||
## The registry: `ctx.sessionProjections`
|
||||
|
||||
`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration.
|
||||
`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, checkpoints keep each Session's last state of it until the key registers again, and retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
|
||||
### `ctx.sessionProjections` — `SessionProjectionRegistry`
|
||||
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -408,8 +410,13 @@ summaryKeys(): ReadonlySet<Extract<keyof SessionProjectionMap, string>>
|
||||
* mutable state, and a caller reaching the live reference could corrupt
|
||||
* every subsequent snapshot and frame through it (plain JSON by the unit
|
||||
* contract, so the clone is total).
|
||||
* A released unit contributes the Session's last cell. The registry no
|
||||
* longer advances that cell, while a retained handle read still advances it
|
||||
* forward, so a writer running after a plugin unloads keeps that unit's
|
||||
* latest state; a Session that has no cell of it gets no row. {@link restore}
|
||||
* folds only live units, so its refreshed rows omit released units.
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key.
|
||||
* @returns one row per registered key, plus one per released key with a cell for this Session.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint
|
||||
|
||||
@@ -426,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint
|
||||
* re-read.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns the offset for the stored-log suffix read (`SessionHandle.read`),
|
||||
* or `undefined` when no unit is registered (no read needed —
|
||||
* or `undefined` when no live unit is registered (released predecessors do not count; no read needed —
|
||||
* {@link restore} would serve empty values regardless).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined
|
||||
@@ -467,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection,
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
* The rows cover live units only: a released unit has no cell for a log
|
||||
* folded without a live `Session`, so a write-back of them omits it.
|
||||
*/
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
|
||||
|
||||
`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。
|
||||
`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。
|
||||
|
||||
```ts type-equiv
|
||||
/** An effect-owned registration with a reader retained independently of registration. */
|
||||
@@ -14,6 +14,8 @@ interface ProjectionHandle<S> {
|
||||
/**
|
||||
* Read the current state, including after unregistering, by folding new
|
||||
* Session events with this handle's original unit. Do not mutate the live value.
|
||||
* After the final release, a read also advances the released cell that
|
||||
* `checkpoint(session)` serves, until the key registers again.
|
||||
* @param session - Session whose committed events are folded.
|
||||
* @returns the projection state at the Session's current cursor.
|
||||
*/
|
||||
@@ -230,7 +232,7 @@ type ProjectionKeySelection =
|
||||
|
||||
## 注册表:`ctx.sessionProjections`
|
||||
|
||||
`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。
|
||||
`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,检查点仍保留每个会话中它的最后状态,直到该 key 再次注册,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -331,7 +333,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
|
||||
### `ctx.sessionProjections` — `SessionProjectionRegistry`
|
||||
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session's last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -408,8 +410,13 @@ summaryKeys(): ReadonlySet<Extract<keyof SessionProjectionMap, string>>
|
||||
* mutable state, and a caller reaching the live reference could corrupt
|
||||
* every subsequent snapshot and frame through it (plain JSON by the unit
|
||||
* contract, so the clone is total).
|
||||
* A released unit contributes the Session's last cell. The registry no
|
||||
* longer advances that cell, while a retained handle read still advances it
|
||||
* forward, so a writer running after a plugin unloads keeps that unit's
|
||||
* latest state; a Session that has no cell of it gets no row. {@link restore}
|
||||
* folds only live units, so its refreshed rows omit released units.
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key.
|
||||
* @returns one row per registered key, plus one per released key with a cell for this Session.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint
|
||||
|
||||
@@ -426,7 +433,7 @@ checkpoint(session: Session): ProjectionCheckpoint
|
||||
* re-read.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns the offset for the stored-log suffix read (`SessionHandle.read`),
|
||||
* or `undefined` when no unit is registered (no read needed —
|
||||
* or `undefined` when no live unit is registered (released predecessors do not count; no read needed —
|
||||
* {@link restore} would serve empty values regardless).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined
|
||||
@@ -467,6 +474,8 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection,
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
* The rows cover live units only: a released unit has no cell for a log
|
||||
* folded without a live `Session`, so a write-back of them omits it.
|
||||
*/
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/projection-released-checkpoint/guide.md
|
||||
/:
|
||||
en: 738f2376c5a8ebe5
|
||||
zh: 078f2a08d8b6ad4b
|
||||
/released-projection-units-stay-in-checkpoints:
|
||||
en: 9355d9de711acc7d
|
||||
zh: eb89da6b886a7556
|
||||
/released-projection-units-stay-in-checkpoints/change:
|
||||
en: 5944b6c7b72e15e4
|
||||
zh: 051d68e2eb5151d7
|
||||
/released-projection-units-stay-in-checkpoints/migration:
|
||||
en: 9f24ecf12748eb41
|
||||
zh: c07649e79a0174f9
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "`ctx.sessionProjections.checkpoint(session)` keeps a released unit's last row instead of omitting it."
|
||||
---
|
||||
|
||||
# Released projection units stay in checkpoints
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Previously, when the last registrant of a projection key released it, through `handle.dispose()` or by unloading the owning fiber, the registry deleted the unit and `ctx.sessionProjections.checkpoint(session)` stopped returning its row.
|
||||
|
||||
Now the released unit stays in the registry as a predecessor. It leaves event drives, `snapshot()`, `summaryKeys()`, `restoreFloor()`, `restore()`, `viewCheckpoint()`, and `hydrate()`, and Sessions created afterward get no cell for it. `checkpoint(session)` still returns the last row the unit folded for each existing Session, and the registry no longer advances that row. Registering the same key again replaces the predecessor and refolds the log; see the [registration lifecycle](../../../../packages/session/session-projection/README.md).
|
||||
|
||||
This affects plugin code and tests that read `checkpoint()` after a projection's owner unloads, such as HMR tests that assert a released key is absent. `@deepseek-ai/dsh-session-projection-cache` stores these rows, so a graceful shutdown no longer drops the values that Web-layer plugins folded before their Sessions detach.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Find reads of `sessionProjections.checkpoint(...)` that run after a projection's owner unloads, including calls through an alias of `ctx.sessionProjections`.
|
||||
2. Replace an assertion that the released key is absent from an existing Session with one that its row keeps the pre-unload value:
|
||||
|
||||
```ts ignore-check
|
||||
const row = ctx.sessionProjections.checkpoint(session).myKey
|
||||
await owner.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row)
|
||||
```
|
||||
|
||||
3. To show that the unit left live participation, assert that a Session created after the unload has no row, `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`, or that an event appended after the unload leaves the row unchanged. A client-visible key is also absent from `snapshot(session).values`.
|
||||
4. Run the affected tests; they pass when no assertion expects a released key to disappear from an existing Session's checkpoint.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "`ctx.sessionProjections.checkpoint(session)` 保留已释放单元的最后一行,而不再省略它。"
|
||||
---
|
||||
|
||||
# 已释放的投影单元保留在检查点中
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
此前,投影 key 的最后一个注册方通过 `handle.dispose()` 或卸载所属 fiber 释放它时,注册表会删除该单元,`ctx.sessionProjections.checkpoint(session)` 也不再返回它的行。
|
||||
|
||||
现在,已释放的单元作为前任留在注册表中。它退出事件推进、`snapshot()`、`summaryKeys()`、`restoreFloor()`、`restore()`、`viewCheckpoint()` 和 `hydrate()`,此后新建的 Session 也不会为它建立 cell。`checkpoint(session)` 仍为每个已有 Session 返回该单元最后折叠出的行,注册表不再推进这一行。同一 key 再次注册时会替换前任并重新折叠日志;参见[注册生命周期](../../../../packages/session/session-projection/README.zh.md)。
|
||||
|
||||
这会影响在投影所属方卸载后读取 `checkpoint()` 的插件代码和测试,例如断言已释放 key 不存在的 HMR 测试。`@deepseek-ai/dsh-session-projection-cache` 会存储这些行,因此正常关停不再丢掉 Web 层插件在其 Session detach 之前已折叠出的值。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 找出在投影所属方卸载后执行的 `sessionProjections.checkpoint(...)` 读取,包括通过 `ctx.sessionProjections` 别名的调用。
|
||||
2. 把「已有 Session 中不存在已释放 key」的断言,改为断言它的行保持卸载前的值:
|
||||
|
||||
```ts ignore-check
|
||||
const row = ctx.sessionProjections.checkpoint(session).myKey
|
||||
await owner.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('myKey', row)
|
||||
```
|
||||
|
||||
3. 如需证明该单元已退出实时参与,可断言卸载后新建的 Session 没有这一行,即 `expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('myKey')`,或断言卸载后追加的事件不会改变这一行。客户端可见的 key 也不会出现在 `snapshot(session).values` 中。
|
||||
4. 运行受影响的测试;当没有断言要求已释放 key 从已有 Session 的检查点中消失时,测试即通过。
|
||||
@@ -455,7 +455,7 @@ describe('time-context projection fold edges', () => {
|
||||
expect(contextTexts(cold).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
||||
})
|
||||
|
||||
it('rebuilds an old checkpoint from the full journal and removes the unit on unload', async () => {
|
||||
it('rebuilds an old checkpoint from the full journal and freezes the unit on unload', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const session = Session.create(SessionId('browser-old-row'))
|
||||
@@ -474,8 +474,12 @@ describe('time-context projection fold edges', () => {
|
||||
expect(ctx.sessionProjections.checkpoint(cold).timeContext).toEqual({
|
||||
ver: 3, seq: cold.seq - 1, val: rows.timeContext!.val,
|
||||
})
|
||||
const row = ctx.sessionProjections.checkpoint(cold).timeContext
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(cold).timeContext).toBeUndefined()
|
||||
openMessageTurn(cold, 2, 'UTC')
|
||||
// The released unit keeps the hydrated row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(cold)).toHaveProperty('timeContext', row)
|
||||
expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('browser-after-unload')))).not.toHaveProperty('timeContext')
|
||||
})
|
||||
|
||||
it('retains reported invalid zones without validating rejected or throttled requests', async () => {
|
||||
|
||||
@@ -107,9 +107,11 @@ describe('ReactLoopInbox', () => {
|
||||
'next-turn': [pending],
|
||||
'next-step': [],
|
||||
})
|
||||
const row = ctx.sessionProjections.checkpoint(session).inbox
|
||||
|
||||
await projection.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(session).inbox?.val).toBeUndefined()
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('inbox', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('inbox')
|
||||
expect(first.nextTurn).toEqual([pending])
|
||||
first.clear()
|
||||
expect(second.nextTurn).toEqual([])
|
||||
|
||||
@@ -71,7 +71,9 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed
|
||||
expect(scopeDisposedBeforeWorkExit).toBe(false)
|
||||
expect(scopeDisposed).toBe(true)
|
||||
expect(abortHandled).toBe(1)
|
||||
expect(projections.checkpoint(agent.session).inbox).toBeUndefined()
|
||||
// The released inbox unit keeps this Session's last row; a Session created after the unload gets none.
|
||||
expect(projections.checkpoint(agent.session)).toHaveProperty('inbox')
|
||||
expect(projections.checkpoint(ctx.sessions.create())).not.toHaveProperty('inbox')
|
||||
expect(projections.checkpoint(agent.session).turnBoundary).toBeDefined()
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(ctx.sessions.get(agent.session.id)).toBeUndefined()
|
||||
|
||||
@@ -325,8 +325,14 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined()
|
||||
|
||||
await Promise.all([first.dispose(), second.dispose()])
|
||||
const row = ctx.sessionProjections.checkpoint(second.agent.session).inbox
|
||||
const coldRow = ctx.sessionProjections.checkpoint(cold).inbox
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeUndefined()
|
||||
cold.append('turn/start', { turn: 1 })
|
||||
// The released unit keeps each Session's last row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(second.agent.session)).toHaveProperty('inbox', row)
|
||||
expect(ctx.sessionProjections.checkpoint(cold)).toHaveProperty('inbox', coldRow)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create(SessionId('projection-after-unload')))).not.toHaveProperty('inbox')
|
||||
})
|
||||
|
||||
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
|
||||
|
||||
@@ -61,9 +61,13 @@ describe('AgentRegistry', () => {
|
||||
})
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
expect(ctx.agents.turnBoundaryOf(session)).toMatchObject({ openTurnStartSeq: null, lastTurn: 3 })
|
||||
expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined()
|
||||
const row = ctx.sessionProjections.checkpoint(session).turnBoundary
|
||||
expect(row).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeUndefined()
|
||||
session.append('turn/start', { turn: 4 })
|
||||
// The released unit keeps its last row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('turnBoundary', row)
|
||||
expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('turn-positions-after-unload')))).not.toHaveProperty('turnBoundary')
|
||||
})
|
||||
|
||||
it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
|
||||
|
||||
@@ -1164,11 +1164,13 @@ describe('Team direct messages and waiting', () => {
|
||||
readDuringShutdown = true
|
||||
if (fails) throw new Error('admitted operation failed during shutdown')
|
||||
}))
|
||||
const row = ctx.sessionProjections.checkpoint(lead.session).agentTeam
|
||||
const disposal = teamFiber.dispose()
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(internal.lifecycle.disposed).toBe(true)
|
||||
expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam')
|
||||
expect(ctx.sessionProjections.checkpoint(lead.session)).toHaveProperty('agentTeam', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('agentTeam')
|
||||
})
|
||||
expect(readDuringShutdown).toBe(false)
|
||||
} finally {
|
||||
|
||||
@@ -1671,11 +1671,13 @@ describe('forgePanel lifecycle', () => {
|
||||
const opened = await open(repo, {}, { worktrees: true })
|
||||
await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal)
|
||||
const proposed = prompt('go')
|
||||
const row = opened.ctx.sessionProjections.checkpoint(opened.session).forgeWorktree
|
||||
|
||||
await opened.panel.dispose()
|
||||
|
||||
expect(opened.ctx.get('forgePanel')).toBeUndefined()
|
||||
expect(opened.ctx.sessionProjections.checkpoint(opened.session)).not.toHaveProperty('forgeWorktree')
|
||||
expect(opened.ctx.sessionProjections.checkpoint(opened.session)).toHaveProperty('forgeWorktree', row)
|
||||
expect(opened.ctx.sessionProjections.checkpoint(opened.ctx.sessions.create())).not.toHaveProperty('forgeWorktree')
|
||||
sendPrompt(opened.agent, 'go')
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })).resolves.toEqual({ kind: 'enter', messages: [proposed] })
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
|
||||
@@ -370,7 +370,9 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
: null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success'], ['Change 9', 'success']]) })
|
||||
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session')
|
||||
const row = ctx.sessionProjections.checkpoint(agent.session).forgeChangeRequests
|
||||
await entry?.fiber?.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(agent.session)).not.toHaveProperty('forgeChangeRequests')
|
||||
expect(ctx.sessionProjections.checkpoint(agent.session)).toHaveProperty('forgeChangeRequests', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('forgeChangeRequests')
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -394,13 +394,16 @@ describe('forgeSession lifecycle', () => {
|
||||
it('unregisters its projection and stops discovery when the service fiber is disposed', async () => {
|
||||
const { ctx, provider, session, service } = await open()
|
||||
create(session, 'create', pullUrl(1))
|
||||
expect(ctx.sessionProjections.checkpoint(session).forgeChangeRequests?.val).toMatchObject({ bindings: [{ url: pullUrl(1) }] })
|
||||
const row = ctx.sessionProjections.checkpoint(session).forgeChangeRequests
|
||||
expect(row?.val).toMatchObject({ bindings: [{ url: pullUrl(1) }] })
|
||||
|
||||
await service.dispose()
|
||||
push(session, 'push', 'feat')
|
||||
await settle()
|
||||
|
||||
expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('forgeChangeRequests')
|
||||
// The released unit keeps its last row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('forgeChangeRequests', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('forgeChangeRequests')
|
||||
expect(provider.lookups).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -2952,7 +2952,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'sessionProjections',
|
||||
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
|
||||
description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.',
|
||||
description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, restores, and client snapshots, while checkpoints keep each Session\'s last cell of it until the key registers again and the new unit refolds the log; retained handles still fold new events on demand using the original unit.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): Promise<ProjectionHandle<S>>',
|
||||
@@ -2992,15 +2992,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'checkpoint(session: Session): ProjectionCheckpoint',
|
||||
description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total).',
|
||||
description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total). A released unit contributes the Session\'s last cell. The registry no longer advances that cell, while a retained handle read still advances it forward, so a writer running after a plugin unloads keeps that unit\'s latest state; a Session that has no cell of it gets no row. restore folds only live units, so its refreshed rows omit released units.',
|
||||
parameters: [{ name: 'session', description: 'the session whose unit states are checkpointed.' }],
|
||||
returns: 'one row per registered key.',
|
||||
returns: 'one row per registered key, plus one per released key with a cell for this Session.',
|
||||
},
|
||||
{
|
||||
signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined',
|
||||
description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row pulls the floor to `0` — that key must refold the full log). The one-below anchor is load-bearing: the tail then proves how far the stored log still extends, so restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.',
|
||||
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }],
|
||||
returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).',
|
||||
returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no live unit is registered (released predecessors do not count; no read needed — {@link restore} would serve empty values regardless).',
|
||||
},
|
||||
{
|
||||
signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial<SessionProjectionMap>',
|
||||
@@ -3012,7 +3012,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
|
||||
description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the stored events at or past `restoreFloor(checkpoint)` (a `SessionHandle.read` slice) and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).',
|
||||
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }],
|
||||
returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.',
|
||||
returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back. The rows cover live units only: a released unit has no cell for a log folded without a live `Session`, so a write-back of them omits it.',
|
||||
},
|
||||
{
|
||||
signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot',
|
||||
|
||||
@@ -287,10 +287,14 @@ describe('GoalService creation and replay', () => {
|
||||
const stub = stubAgent('goal-hmr')
|
||||
await ctx.agents.register(stub.agent)
|
||||
const goal = ctx.goals.create(stub.agent, { objective: 'survive service reload' })
|
||||
const row = ctx.sessionProjections.checkpoint(stub.session).goal
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined()
|
||||
stub.session.append('turn/start', { turn: 1 })
|
||||
// The released unit keeps its last row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(stub.session)).toHaveProperty('goal', row)
|
||||
expect(ctx.sessionProjections.checkpoint(Session.create(SessionId('goal-after-unload')))).not.toHaveProperty('goal')
|
||||
expect(() => first.get(stub.agent)).toThrow('cannot get required service "agents" in inactive context')
|
||||
|
||||
await ctx.plugin(GoalService)
|
||||
|
||||
@@ -123,9 +123,14 @@ describe('approval state', () => {
|
||||
expect(ctx.approval.overrideOf(session)).toBe('never')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
const row = ctx.sessionProjections.checkpoint(session)['approvalState']
|
||||
|
||||
await service.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('approvalState')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The released unit keeps its last row and folds no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('approvalState', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('approvalState')
|
||||
await ctx.plugin(ApprovalService)
|
||||
expect(ctx.approval.overrideOf(session)).toBe('never')
|
||||
expect(ctx.sessionProjections.checkpoint(session)['approvalState']?.val).toEqual({ policy: 'never', turnOpen: false })
|
||||
@@ -214,10 +219,13 @@ describe('approval state', () => {
|
||||
await registration.entered
|
||||
const session = ctx.sessions.create()
|
||||
setApprovalPolicy(session, 'never')
|
||||
const row = ctx.sessionProjections.checkpoint(session)['approvalState']
|
||||
const unloading = fiber.dispose()
|
||||
registration.release()
|
||||
await unloading
|
||||
expect(ctx.sessionProjections.checkpoint(session)).not.toHaveProperty('approvalState')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toHaveProperty('approvalState', row)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).not.toHaveProperty('approvalState')
|
||||
expect(ctx.get('approval')).toBeUndefined()
|
||||
const prompt = await ctx.systemPrompt.assemble({ agent: agentFor(session) })
|
||||
expect(prompt.contexts).not.toContainEqual(expect.objectContaining({ name: 'approval:policy' }))
|
||||
|
||||
@@ -137,11 +137,15 @@ describe('TokenMeter configuration and registration', () => {
|
||||
const fiber = await ctx.plugin(TokenMeter)
|
||||
expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter)
|
||||
const session = ctx.sessions.create()
|
||||
expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort())
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
expect(Object.keys(rows).sort())
|
||||
.toEqual(['contextBreakdown', 'contextPressure', 'tokenMeasurement', 'tokenUsage'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('tokenMeter')).toBeUndefined()
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toEqual({})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
// The released units keep their last rows and fold no event after the unload.
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toEqual(rows)
|
||||
expect(ctx.sessionProjections.checkpoint(ctx.sessions.create())).toEqual({})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: bae62224d0008bc0
|
||||
zh: 0daf92dab3314913
|
||||
/deepseek-ai-dsh-session-projection-cache/use-this-package/reading-cached-values:
|
||||
en: e8dc676fbfe97c5b
|
||||
zh: 686837459289fd41
|
||||
en: 342e5c8be6ab3a37
|
||||
zh: ff09481eebf0b4cc
|
||||
/deepseek-ai-dsh-session-projection-cache/use-this-package/what-the-cache-guarantees:
|
||||
en: 6e4878de28ea113e
|
||||
zh: 6f940a2220585b5e
|
||||
|
||||
@@ -58,7 +58,7 @@ Three mandatory points always write: session creation persists the seed-derived
|
||||
|
||||
### Reading cached values
|
||||
|
||||
`cachedSnapshot(meta, keys?)` is the read-only face: it synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts a record whose lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) matches the header and serves its version- and schema-matching keys as one block whose `asOfSeq` is the lowest watermark among the served rows. That watermark is the stored record's own: a header witnesses no inherited cut and cannot vouch that the row sequence is comparable with the log a consumer later opens, so the Session list labels the block `cached` and the client lets every value the connected Session later produces supersede it. Within one format generation the cut is fixed at fork time and distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold, so seeded (forked) sessions are served exactly like unseeded ones. `cachedPredecessorTitle(meta)` is the narrower listing-only exception across a Session-format edge: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row, because title text is invariant across the adjacent edges. All other predecessor rows remain unavailable. `coldSnapshot(meta, inheritedEventCount, events)` is a fold face: it accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself.
|
||||
`cachedSnapshot(meta, keys?)` is the read-only face: it synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts a record whose lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) matches the header and serves its version- and schema-matching keys as one block whose `asOfSeq` is the lowest watermark among the served rows. That watermark is the stored record's own: a header witnesses no inherited cut and cannot vouch that the row sequence is comparable with the log a consumer later opens, so the Session list labels the block `cached` and the client lets every value the connected Session later produces supersede it. A row can also be frozen where its projection unit was released before the write, so its watermark may trail the log by more than the last checkpoint interval. Within one format generation the cut is fixed at fork time and distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold, so seeded (forked) sessions are served exactly like unseeded ones. `cachedPredecessorTitle(meta)` is the narrower listing-only exception across a Session-format edge: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row, because title text is invariant across the adjacent edges. All other predecessor rows remain unavailable. `coldSnapshot(meta, inheritedEventCount, events)` is a fold face: it accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself.
|
||||
|
||||
### What the cache guarantees
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ kind: "package-reference"
|
||||
|
||||
### 读取缓存值
|
||||
|
||||
`cachedSnapshot(meta, keys?)` 是只读面:以零 I/O 从存储域的内存表同步提供客户端值。它接受生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`)与 header 匹配的记录,把其中版本和 schema 均匹配的 key 作为一个 block 提供,其 `asOfSeq` 是所服务各行中最低的水位。这个水位是存储记录自己的:header 作证不了 inherited cut,也作证不了行序号与消费者稍后打开的日志可比,因此 Session list 把该 block 标为 `cached`,客户端让建连后的 Session 产出的任何值覆盖它。在同一格式代内,cut 在 fork 时写死,不能区分其他字段区分不了的生命周期,而只读视图也从不播种 fold,所以 seeded(fork 出来的)会话与 unseeded 会话被同样地提供。`cachedPredecessorTitle(meta)` 是跨 Session 格式 edge 的更窄列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row,因为 title 文本在相邻 edge 之间保持不变。其他 predecessor row 仍不可用。`coldSnapshot(meta, inheritedEventCount, events)` 是 fold 面:接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。
|
||||
`cachedSnapshot(meta, keys?)` 是只读面:以零 I/O 从存储域的内存表同步提供客户端值。它接受生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`)与 header 匹配的记录,把其中版本和 schema 均匹配的 key 作为一个 block 提供,其 `asOfSeq` 是所服务各行中最低的水位。这个水位是存储记录自己的:header 作证不了 inherited cut,也作证不了行序号与消费者稍后打开的日志可比,因此 Session list 把该 block 标为 `cached`,客户端让建连后的 Session 产出的任何值覆盖它。某一行也可能冻结在写入前投影单元被释放的那一刻,因此它的水位落后于日志的程度可能超过最近一次检查点间隔。在同一格式代内,cut 在 fork 时写死,不能区分其他字段区分不了的生命周期,而只读视图也从不播种 fold,所以 seeded(fork 出来的)会话与 unseeded 会话被同样地提供。`cachedPredecessorTitle(meta)` 是跨 Session 格式 edge 的更窄列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row,因为 title 文本在相邻 edge 之间保持不变。其他 predecessor row 仍不可用。`coldSnapshot(meta, inheritedEventCount, events)` 是 fold 面:接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。
|
||||
|
||||
### 缓存保证什么
|
||||
|
||||
|
||||
@@ -175,6 +175,23 @@ function whenWritten(ctx: Context, id: SessionId): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Register the secondary unit on its own fiber, so a test can release it before a Session detaches. */
|
||||
function releasableSecondaryUnit(ctx: Context) {
|
||||
return ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
await inner.sessionProjections.register(secondaryMarksUnit)
|
||||
}, { inject: ['sessionProjections'] }))
|
||||
}
|
||||
|
||||
/** Create a Session owned by its own fiber; disposing `owner` detaches it. */
|
||||
async function ownedSession(ctx: Context, id: SessionId) {
|
||||
let session: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
if (session === undefined) throw new Error('session was not created')
|
||||
return { session, owner }
|
||||
}
|
||||
|
||||
/** The stored record for one session id (undefined = absent or unreadable). */
|
||||
async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
|
||||
try {
|
||||
@@ -266,6 +283,95 @@ describe('SessionProjectionCache write policy', () => {
|
||||
expect((await storedRows(root, id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
|
||||
})
|
||||
|
||||
it('writes the last state of a unit released before the detach write', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const unitOwner = await releasableSecondaryUnit(ctx)
|
||||
const id = SessionId('released-exact')
|
||||
const created = whenWritten(ctx, id)
|
||||
const { session, owner } = await ownedSession(ctx, id)
|
||||
await created
|
||||
mark(session, ['a'])
|
||||
const ended = whenWritten(ctx, id)
|
||||
const end = endTurn(session)
|
||||
await ended
|
||||
// A plugin unloading ahead of the Sessions it describes releases its unit first.
|
||||
await unitOwner.dispose()
|
||||
const detached = whenWritten(ctx, id)
|
||||
await owner.dispose()
|
||||
await detached
|
||||
expect(await storedRows(root, id)).toEqual({
|
||||
'cache-test/marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } },
|
||||
'cache-test/secondary-marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a released unit at the event it reached while later events advance the live units', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const unitOwner = await releasableSecondaryUnit(ctx)
|
||||
const id = SessionId('released-behind')
|
||||
const created = whenWritten(ctx, id)
|
||||
const { session, owner } = await ownedSession(ctx, id)
|
||||
await created
|
||||
mark(session, ['a'])
|
||||
const ended = whenWritten(ctx, id)
|
||||
const end = endTurn(session)
|
||||
await ended
|
||||
await unitOwner.dispose()
|
||||
const later = mark(session, ['b'])
|
||||
const detached = whenWritten(ctx, id)
|
||||
await owner.dispose()
|
||||
await detached
|
||||
expect(await storedRows(root, id)).toEqual({
|
||||
'cache-test/marks': { ver: 1, seq: later.seq, val: { marks: ['b'] } },
|
||||
'cache-test/secondary-marks': { ver: 1, seq: end.seq, val: { marks: ['a'] } },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a stored row whose unit this process never registered', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const meta = headerOf(SessionId('carried'), 9)
|
||||
await seedRecord(root, meta.id, {
|
||||
'cache-test/marks': { ver: 1, seq: -1, val: null },
|
||||
'cache-test/secret': { ver: 1, seq: -1, val: 'written by another composition' },
|
||||
}, {
|
||||
formatVersion: SESSION_FORMAT_VERSION,
|
||||
createdAt: 9,
|
||||
isSeeded: false,
|
||||
inheritedEventCount: SessionLogOffset(0),
|
||||
})
|
||||
const { ctx } = await harness({ root })
|
||||
const events: SessionEvent[] = [{ type: 'cache-test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['cold'] } }]
|
||||
const written = whenWritten(ctx, meta.id)
|
||||
ctx.sessionProjectionCache.coldSnapshot(meta, SessionLogOffset(0), events)
|
||||
await written
|
||||
expect(await storedRows(root, meta.id)).toEqual({
|
||||
'cache-test/marks': { ver: 1, seq: 0, val: { marks: ['cold'] } },
|
||||
})
|
||||
})
|
||||
|
||||
it('never keeps a stored row of another lifecycle under the same Session id', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
|
||||
roots.push(root)
|
||||
const id = SessionId('recreated')
|
||||
// Same id and cut as the Session created below, but another creation time.
|
||||
await seedRecord(root, id, {
|
||||
'cache-test/secondary-marks': { ver: 1, seq: -1, val: { marks: ['foreign'] } },
|
||||
}, {
|
||||
formatVersion: SESSION_FORMAT_VERSION,
|
||||
createdAt: 1,
|
||||
isSeeded: false,
|
||||
inheritedEventCount: SessionLogOffset(0),
|
||||
})
|
||||
const { ctx } = await harness({ root })
|
||||
const created = whenWritten(ctx, id)
|
||||
ctx.sessions.create(id)
|
||||
await created
|
||||
expect(await storedRows(root, id)).toEqual({
|
||||
'cache-test/marks': { ver: 1, seq: -1, val: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes when the in-turn event count reaches the configured threshold', async () => {
|
||||
const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 09758d1475fad86e
|
||||
zh: 473637cb34aebe72
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/register-and-read:
|
||||
en: 84f39e64f252a4d3
|
||||
zh: e3f6dff8c5df9016
|
||||
en: 4ef214a05a5090b3
|
||||
zh: 2119a4566d9e33eb
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/bind-several-states-to-one-session:
|
||||
en: dac4ea4d67ce057d
|
||||
zh: 1cf3e5c7af5a5203
|
||||
@@ -33,8 +33,8 @@
|
||||
en: bfbeab7e33fc7f8f
|
||||
zh: 74e0b871f1639f14
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints:
|
||||
en: 3bbc61a438aeabd0
|
||||
zh: bd1824f4da278764
|
||||
en: b8196b8cfcacefac
|
||||
zh: 4dd8e04fca04a580
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/session-summaries:
|
||||
en: ee868b44413a126f
|
||||
zh: f98c66d8e4da9a72
|
||||
@@ -60,8 +60,8 @@
|
||||
en: 8a592dae9bc946ab
|
||||
zh: eca939995bb5f8e0
|
||||
/deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work:
|
||||
en: dda14db5a0fbe2fa
|
||||
zh: 9675b2a39e7aea8b
|
||||
en: 2c26ef65c8425be6
|
||||
zh: 4a797644f492772a
|
||||
/deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
|
||||
@@ -55,7 +55,7 @@ const definition = {
|
||||
|
||||
### Register and read
|
||||
|
||||
`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise<void>`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut, `onChanged(listener)` for one call per changed unit, and `onEventChanged(listener)` for one call per committed event that lists every client-visible unit whose view it changed.
|
||||
`await register(definition)` infers the host state type from `stateSchema`, installs the unit, and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise<void>`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, restores, and change notifications; `checkpoint(session)` keeps each Session's last cell of it, until the key registers again and the new unit refolds the log. The registry no longer advances that cell, but a retained handle read still advances it forward, and the checkpoint serves the advanced cell. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut, `onChanged(listener)` for one call per changed unit, and `onEventChanged(listener)` for one call per committed event that lists every client-visible unit whose view it changed.
|
||||
|
||||
```text
|
||||
const todoProjectionHandle = await ctx.sessionProjections.register(definition)
|
||||
@@ -97,7 +97,7 @@ Definitions install synchronously, and handle delivery remains asynchronous. Reg
|
||||
|
||||
### Persisted checkpoints
|
||||
|
||||
Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap.
|
||||
Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. `restore` folds only live units, so its refreshed rows omit released units, and a write-back of them drops their stored rows.
|
||||
|
||||
### Session summaries
|
||||
|
||||
@@ -168,6 +168,8 @@ These limits define where the projection registry needs care at scale. They are
|
||||
- **Only a key-subset read omits keys; a tail page still carries every client-visible key and there is no lazy per-value request** — a summary omits exactly the keys whose `SessionProjectionMap` entry carries `SessionProjectionSummaryExcluded<T>` and whose registration declares `wire.summary: false`. The opt-out is per key, not per value: no read defers one large value.
|
||||
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature.
|
||||
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters.
|
||||
- **A released unit's checkpoint row stops advancing** — after the last registrant releases a key, `checkpoint(session)` keeps each Session's last cell until the key registers again. Outside graceful shutdown, events appended meanwhile leave the stored row behind the log, and cold listings serve that older value.
|
||||
- **A released predecessor keeps its definition referenced** — the registry holds the released unit's definition closures until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old module referenced for the process lifetime. Cells are keyed weakly by Session and retain no Session.
|
||||
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
|
||||
- **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const definition = {
|
||||
|
||||
### 注册与读取
|
||||
|
||||
`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise<void>` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 按每个变化的单元接收一次调用,用 `onEventChanged(listener)` 按每个已提交事件接收一次调用,列出该事件改变了视图的全部客户端可见单元。
|
||||
`await register(definition)` 从 `stateSchema` 推导宿主状态类型,安装单元,并返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise<void>` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、恢复读取和变更通知;`checkpoint(session)` 仍保留每个 Session 中该单元的最后一个 cell,直到同一 key 再次注册、由新单元重新折叠日志。注册表不再推进这个 cell,但已保留的 handle 读取时仍会把它向前推进,检查点输出推进后的 cell。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 按每个变化的单元接收一次调用,用 `onEventChanged(listener)` 按每个已提交事件接收一次调用,列出该事件改变了视图的全部客户端可见单元。
|
||||
|
||||
```text
|
||||
const todoProjectionHandle = await ctx.sessionProjections.register(definition)
|
||||
@@ -97,7 +97,7 @@ const todo = projections.todo()
|
||||
|
||||
### 持久检查点
|
||||
|
||||
系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。
|
||||
系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。`restore` 只折叠在线单元,因此它刷新后的行不含已释放单元,用这些行回写会丢弃这些单元已存储的行。
|
||||
|
||||
### Session 摘要
|
||||
|
||||
@@ -168,6 +168,8 @@ Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectio
|
||||
- **只有带 key 子集的读取才省略 key;尾页仍携带每个客户端可见 key,也不存在惰性逐值请求**——摘要只省略那些 `SessionProjectionMap` 条目带有 `SessionProjectionSummaryExcluded<T>` 且注册声明了 `wire.summary: false` 的 key。该 opt-out 按 key 生效,不按值生效:没有任何读取会延后加载单个大值。
|
||||
- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。
|
||||
- **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。
|
||||
- **已释放单元的检查点行停止推进**——最后一个注册方释放 key 后,`checkpoint(session)` 保留每个 Session 的最后一个 cell,直到该 key 再次注册。在正常关停之外,这期间追加的事件会让存储的行落后于日志,冷列表读到的是这个较旧的值。
|
||||
- **已释放的前任会继续引用其定义**——注册表持有已释放单元的定义闭包,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧模块。cell 以 Session 为弱键,不会保留 Session。
|
||||
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
|
||||
- **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关。
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface ProjectionHandle<S> {
|
||||
/**
|
||||
* Read the current state, including after unregistering, by folding new
|
||||
* Session events with this handle's original unit. Do not mutate the live value.
|
||||
* After the final release, a read also advances the released cell that
|
||||
* `checkpoint(session)` serves, until the key registers again.
|
||||
* @param session - Session whose committed events are folded.
|
||||
* @returns the projection state at the Session's current cursor.
|
||||
*/
|
||||
@@ -233,8 +235,10 @@ interface UnitCell {
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared unit and its per-session cells, retained by acquired readers after
|
||||
* the final registration leaves the live table.
|
||||
* One shared unit and its per-session cells. After the final release the entry
|
||||
* stays in the table as a released predecessor until its key registers again,
|
||||
* keeping its definition closures referenced; acquired readers retain it beyond
|
||||
* that replacement.
|
||||
*
|
||||
* `refs` exists because one unit definition already serves every session — the
|
||||
* cells are keyed by `Session` — while registrants are per-session:
|
||||
@@ -246,7 +250,11 @@ interface UnitCell {
|
||||
interface Registration {
|
||||
readonly def: ErasedDefinition
|
||||
readonly cells: WeakMap<Session, UnitCell>
|
||||
/** Live registrants sharing this unit; the last one out removes the key. */
|
||||
/**
|
||||
* Live registrants sharing this unit. `0` marks a released predecessor: only
|
||||
* `checkpoint()` reads its cells through the registry, while a retained handle
|
||||
* read can still advance them.
|
||||
*/
|
||||
refs: number
|
||||
}
|
||||
|
||||
@@ -266,8 +274,10 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor {
|
||||
* touch (event or read). Consumers declare `sessionProjections` as a required
|
||||
* injection, await registration during activation, and retain its ready handle.
|
||||
* Registrants sharing a key share one unit and are counted. The last release
|
||||
* removes the unit from live drives, checkpoints, and client snapshots; retained
|
||||
* handles still fold new events on demand using the original unit.
|
||||
* removes the unit from live drives, restores, and client snapshots, while
|
||||
* checkpoints keep each Session's last cell of it until the key registers
|
||||
* again and the new unit refolds the log; retained handles still fold new
|
||||
* events on demand using the original unit.
|
||||
*/
|
||||
export class SessionProjectionRegistry extends Service {
|
||||
private readonly registrations = new Map<string, Registration>()
|
||||
@@ -287,6 +297,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
if (session.seq !== 0) return
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
if (registration.cells.has(session)) continue
|
||||
registration.cells.set(session, {
|
||||
state: registration.def.init(session.header, session.inheritedEventCount),
|
||||
@@ -395,7 +406,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const key = erased.key
|
||||
const existing = this.registrations.get(key)
|
||||
if (existing === undefined) {
|
||||
if (existing === undefined || existing.refs === 0) {
|
||||
registration = { def: erased, cells: new WeakMap(), refs: 1 }
|
||||
this.registrations.set(key, registration)
|
||||
this.summaryKeySet = undefined
|
||||
@@ -412,7 +423,6 @@ export class SessionProjectionRegistry extends Service {
|
||||
return () => {
|
||||
registration.refs -= 1
|
||||
if (registration.refs === 0) {
|
||||
this.registrations.delete(key)
|
||||
this.summaryKeySet = undefined
|
||||
}
|
||||
}
|
||||
@@ -449,7 +459,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
this.ctx.logger.warn(`sessionProjections.stateOf(session, ${JSON.stringify(key)}) is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service`)
|
||||
}
|
||||
const registration = this.registrations.get(key)
|
||||
if (registration === undefined) return undefined
|
||||
if (registration === undefined || registration.refs === 0) return undefined
|
||||
this.materializeCells(session)
|
||||
return this.cellFor(registration, session).state
|
||||
}
|
||||
@@ -508,6 +518,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const values: Record<string, unknown> = {}
|
||||
const selected = selectedKeys(keys)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
if (registration.def.wire === undefined) continue
|
||||
if (selected !== undefined && !selected.has(registration.def.key)) continue
|
||||
const cell = this.cellFor(registration, session)
|
||||
@@ -534,6 +545,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
let asOfSeq: SessionSeqCursor | undefined
|
||||
const selected = selectedKeys(keys)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
if (registration.def.wire === undefined) continue
|
||||
if (selected !== undefined && !selected.has(registration.def.key)) continue
|
||||
const cell = registration.cells.get(session)
|
||||
@@ -564,6 +576,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
private deriveSummaryKeys(): ReadonlySet<Extract<keyof SessionProjectionMap, string>> {
|
||||
const keys = new Set<Extract<keyof SessionProjectionMap, string>>()
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
if (registration.def.wire === undefined || !registration.def.wire.summary) continue
|
||||
keys.add(registration.def.key as Extract<keyof SessionProjectionMap, string>)
|
||||
}
|
||||
@@ -581,13 +594,21 @@ export class SessionProjectionRegistry extends Service {
|
||||
* mutable state, and a caller reaching the live reference could corrupt
|
||||
* every subsequent snapshot and frame through it (plain JSON by the unit
|
||||
* contract, so the clone is total).
|
||||
* A released unit contributes the Session's last cell. The registry no
|
||||
* longer advances that cell, while a retained handle read still advances it
|
||||
* forward, so a writer running after a plugin unloads keeps that unit's
|
||||
* latest state; a Session that has no cell of it gets no row. {@link restore}
|
||||
* folds only live units, so its refreshed rows omit released units.
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key.
|
||||
* @returns one row per registered key, plus one per released key with a cell for this Session.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint {
|
||||
const rows: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const cell = this.cellFor(registration, session)
|
||||
const cell = registration.refs === 0
|
||||
? registration.cells.get(session)
|
||||
: this.cellFor(registration, session)
|
||||
if (cell === undefined) continue
|
||||
rows[registration.def.key] = {
|
||||
ver: registration.def.stateVersion,
|
||||
seq: cell.observedSeq,
|
||||
@@ -610,12 +631,13 @@ export class SessionProjectionRegistry extends Service {
|
||||
* re-read.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns the offset for the stored-log suffix read (`SessionHandle.read`),
|
||||
* or `undefined` when no unit is registered (no read needed —
|
||||
* or `undefined` when no live unit is registered (released predecessors do not count; no read needed —
|
||||
* {@link restore} would serve empty values regardless).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined {
|
||||
let floor: number | undefined
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
const row = checkpoint[registration.def.key]
|
||||
const need = row !== undefined && row.ver === registration.def.stateVersion
|
||||
? Math.max(row.seq + 1, 0)
|
||||
@@ -643,6 +665,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const values: Record<string, unknown> = {}
|
||||
const selected = selectedKeys(keys)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
const def = registration.def
|
||||
if (def.wire === undefined) continue
|
||||
if (selected !== undefined && !selected.has(def.key)) continue
|
||||
@@ -682,6 +705,8 @@ export class SessionProjectionRegistry extends Service {
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
* The rows cover live units only: a released unit has no cell for a log
|
||||
* folded without a live `Session`, so a write-back of them omits it.
|
||||
*/
|
||||
restore(
|
||||
checkpoint: ProjectionCheckpoint,
|
||||
@@ -696,6 +721,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const values: Record<string, unknown> = {}
|
||||
const refreshed: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
const usable = row !== undefined
|
||||
@@ -749,6 +775,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq)
|
||||
let complete = true
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
const current = registration.cells.get(session)
|
||||
if (current?.observedSeq !== endSeq) {
|
||||
complete = false
|
||||
@@ -758,6 +785,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
if (complete) {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
if (registration.def.wire === undefined) continue
|
||||
const current = registration.cells.get(session) as UnitCell
|
||||
values[registration.def.key] = this.viewCell(registration, current)
|
||||
@@ -772,6 +800,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
session.inheritedEventCount,
|
||||
)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
const row = restored.checkpoint[registration.def.key]
|
||||
/* v8 ignore next -- restore() writes a row for every registration in the same synchronous pass. */
|
||||
if (row === undefined) continue
|
||||
@@ -788,7 +817,10 @@ export class SessionProjectionRegistry extends Service {
|
||||
|
||||
/** Materialize every registered unit cell at the Session's current cursor. */
|
||||
private materializeCells(session: Session): void {
|
||||
for (const registration of this.registrations.values()) this.cellFor(registration, session)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
this.cellFor(registration, session)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
||||
@@ -857,6 +889,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const changes: { key: Extract<keyof SessionProjectionMap, string>; val: unknown }[] = []
|
||||
try {
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.refs === 0) continue
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell !== undefined && cell.observedSeq >= event.seq) continue
|
||||
if (cell === undefined) {
|
||||
|
||||
@@ -154,7 +154,8 @@ describe('retained projection readers', () => {
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toEqual({})
|
||||
// The checkpoint carries the released unit's cell as the retained reader last advanced it.
|
||||
expect(ctx.sessionProjections.checkpoint(session)).toEqual({ 'test/reader': { ver: 1, seq: 1, val: 2 } })
|
||||
const later = ctx.sessions.create()
|
||||
later.append('turn/start', { turn: 1 })
|
||||
expect(handle.stateOf(later)).toBe(1)
|
||||
|
||||
@@ -574,7 +574,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
await expect(Promise.resolve(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 }))).rejects.toThrow(/stateVersion/)
|
||||
})
|
||||
|
||||
it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => {
|
||||
it('register() disposer releases the key from snapshots and frees it for re-registration', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const dispose = await ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['cached'])
|
||||
@@ -585,6 +585,69 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] })
|
||||
})
|
||||
|
||||
it('freezes a released unit: checkpoints keep its last cell while every live face skips it', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
await inner.sessionProjections.register(marksUnit())
|
||||
}, { inject: ['sessionProjections'] }))
|
||||
await ctx.sessionProjections.register(countUnit())
|
||||
const released = mark(session, ['released'])
|
||||
expect(ctx.sessionProjections.summaryKeys().has('test/marks')).toBe(true)
|
||||
await owner.dispose()
|
||||
|
||||
const after = mark(session, ['after'])
|
||||
const fresh = ctx.sessions.create()
|
||||
expect(ctx.sessionProjections.summaryKeys().has('test/marks')).toBe(false)
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- The deprecated cached face also skips a released unit.
|
||||
expect(ctx.sessionProjections.cachedSnapshot(session)).toBeUndefined()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- The deprecated key read reports a released key as absent.
|
||||
expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Its materializing read skips the released unit.
|
||||
expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(2)
|
||||
// No drive, snapshot, or materializing read advanced the released cell.
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
expect(rows).toEqual({
|
||||
'test/marks': { ver: 1, seq: released.seq, val: { marks: ['released'] } },
|
||||
'test/count': { ver: 1, seq: after.seq, val: 2 },
|
||||
})
|
||||
// A Session created after the release never gets a cell for the unit.
|
||||
expect(ctx.sessionProjections.checkpoint(fresh)).toEqual({ 'test/count': { ver: 1, seq: -1, val: 0 } })
|
||||
|
||||
// Restore paths consult only live units, so the frozen row neither lowers the floor nor gets viewed.
|
||||
expect(ctx.sessionProjections.restoreFloor(rows)).toBe(SessionLogOffset(after.seq))
|
||||
expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({})
|
||||
const events = [released, after]
|
||||
expect(ctx.sessionProjections.restore(rows, events, SessionLogOffset(0), RESTORE_HEADER, SessionLogOffset(0))).toEqual({
|
||||
snapshot: { asOfSeq: after.seq, values: {} },
|
||||
checkpoint: { 'test/count': { ver: 1, seq: after.seq, val: 2 } },
|
||||
})
|
||||
const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-released'), { seed: events })
|
||||
const hydrated = ctx.sessionProjections.hydrate(prepared, rows, events, SessionLogOffset(0))
|
||||
expect(hydrated).toEqual({ asOfSeq: after.seq, values: {} })
|
||||
expect(ctx.sessionProjections.hydrate(prepared, rows, events, SessionLogOffset(0))).toEqual(hydrated)
|
||||
expect(ctx.sessionProjections.checkpoint(prepared)).not.toHaveProperty('test/marks')
|
||||
})
|
||||
|
||||
it('re-registering a released key refolds the Session instead of continuing the frozen cell', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
await inner.sessionProjections.register(marksUnit())
|
||||
}, { inject: ['sessionProjections'] }))
|
||||
const released = mark(session, ['released'])
|
||||
await owner.dispose()
|
||||
const after = mark(session, ['after'])
|
||||
expect(ctx.sessionProjections.checkpoint(session)['test/marks'])
|
||||
.toEqual({ ver: 1, seq: released.seq, val: { marks: ['released'] } })
|
||||
|
||||
const replacement = await ctx.sessionProjections.register(marksUnit())
|
||||
expect(replacement.stateOf(session)).toEqual({ marks: ['after'] })
|
||||
expect(ctx.sessionProjections.checkpoint(session)['test/marks'])
|
||||
.toEqual({ ver: 1, seq: after.seq, val: { marks: ['after'] } })
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['after'] })
|
||||
})
|
||||
|
||||
it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const notifications: string[] = []
|
||||
|
||||
Reference in New Issue
Block a user