feat(session): bound persistence write batching

This commit is contained in:
Tianyi Cui
2026-08-08 15:43:52 +08:00
parent 7ab20890e6
commit 924c954469
52 changed files with 763 additions and 126 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-checkpoint-policy/README.md
README.md: 57be9e78236775c39971c925f4972398c0b20a97
README.zh.md: 66508382783c36c41be65afc758398d406c3a58f
README.md: 01ed3c694967b1a86245d01b9d9f7eecd1193348
README.zh.md: e2c197c0cc1d942a045c1ea22c1fb257f6e4059d

View File

@@ -16,7 +16,7 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend eagerly writes `session/event` appends and makes each requested `session/flush` an observation barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose the latest eagerly buffered events. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend starts bounded background batches for `session/event` appends and makes each requested `session/flush` an immediate quiescence barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose events still inside the configured batching window or an outstanding write. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/pre-step` persists the preceding response/result batch before request derivation.
@@ -41,5 +41,5 @@ The repair result is appended after the reusable prefix, so it does not invalida
## Known Limitations and Deferred Work
- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
- Streaming `assistant/chunk` events have no per-chunk checkpoint. They reach storage with the next semantic checkpoint, so a hard crash may lose the current partial response.
- Streaming `assistant/chunk` events have no per-chunk checkpoint. Bounded background batches normally persist them before the next semantic checkpoint, but a hard crash may lose the current in-memory batch or outstanding write.
- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically.

View File

@@ -16,7 +16,7 @@
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会主动写入追加的 `session/event`,并把每个已请求 `session/flush` 变成观测屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失最新的已缓冲事件。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。
持久化与检查点调度刻意拆分为独立 Cordis 插件。持久化后端会追加的 `session/event` 启动有界后台批次,并把每个已请求 `session/flush` 变成即时完全停稳屏障;该策略选择请求、工具分派和下一步骤屏障。不带此策略加载后端是有效的,但崩溃可能丢失仍位于已配置批处理窗口内的事件,或尚未完成的写入。第一方持久化应用和运行时显式挂载两个插件;专用部署可以刻意省略或替换策略。
策略延迟包装 `llm/stream`,因此下游流只会在活动会话中缓冲的请求事件已持久化后构造。它在预执行策略和防护机制之后包装 `tools/execute`;只有在已记录调用已持久化后,顶层工具正文才会运行。如果取消在 flush 等待期间到达,包装层会返回规范的 `ABORTED_BEFORE_DISPATCH` 结果,不进入工具正文。嵌套工具分派重用外层模型可见调用的检查点。`agent/pre-step` 在派生请求前持久化前一响应/结果批次。
@@ -41,5 +41,5 @@
## 已知限制与暂缓事项
- 该策略以持久方式记录执行意图,而非为通用副作用提供恰好一次保证。当提供方支持时,有副作用的工具应将 `exec.callId` 作为幂等键转发。
- 流式 `assistant/chunk` 事件没有分片检查点。它们在下一个语义检查点到达存储,因此硬崩溃可能丢失当前部分响应
- 流式 `assistant/chunk` 事件没有分片检查点。有界后台批次通常会在下一个语义检查点之前将其持久化,但硬崩溃可能丢失当前内存批次或尚未完成的写入
- 已持久化的调用没有结果时,无法证明其外部副作用是否完成。因此,恢复会记录未知结果,而不是自动重试。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-jsonl/README.md
README.md: cd087539bde2433fcdb70b2c511ff30880a877e1
README.zh.md: 144b404e04f8a5fd3623d9329e4fbcafd328524d
README.md: b7fa8fc2918711dd24eeba67132a267452d401f9
README.zh.md: 59d21c6171e857849c4ab48940d66962fffbfdeb

View File

@@ -27,6 +27,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
| `packChunks` | `boolean` (default `true`) | Write eligible delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Set `false` for one-event-per-line diagnostics; reading packed rows works regardless of this write-side switch. |
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
| `preparedSessionCacheSize` | positive integer (default `5`) | Maximum unpublished Sessions retained after cold history inspection for reuse by resume. |
| `writeBatchMaxDelayMs` | positive integer (default `200`) | Fixed coalescing window after an idle live-event queue receives work. Later events do not reset it; flush and teardown bypass it. It does not bound event-loop, serialized-operation, or backend latency. At most Node's `2_147_483_647` ms timer limit. |
`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix.
@@ -48,7 +49,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Write path
The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown.
The plugin copies frozen session events into one controller per live session. The first pending event starts the configured fixed batching window, and later events join without resetting it. Expiry starts one durable append; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains current and pending batches. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown. Every logical event remains present: batching only lets one compressed frame or raw fsync carry more records.
## Model Experience

View File

@@ -27,6 +27,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
| `packChunks` | `boolean`(默认 `true` | 将符合条件的 delta 分片连续段写为打包行(在真实编码会话上测得逻辑日志约小 60%)。设为 `false` 可用于每事件一行诊断;无论该写入侧开关如何,都能读取打包行。 |
| `compression` | `'zstd' \| 'none'` | 默认 `'zstd'``'none'` 保留换行分隔 UTF-8 文本。 |
| `preparedSessionCacheSize` | 正整数(默认 `5` | 冷历史检查后保留、供恢复复用的未发布 Session 数量上限。 |
| `writeBatchMaxDelayMs` | 正整数(默认 `200` | 空闲的活动事件队列收到待写入事件后开启的固定合并窗口。后续事件不会重置窗口flush 与 teardown 会绕过它。该值不限制事件循环、串行化操作或后端延迟。最大值为 Node 计时器上限 `2_147_483_647` ms。 |
`locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O可以在目录或文件存在前返回目标现有文件也只包含最近一次 flush 完成的前缀。
@@ -48,7 +49,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
## 写入路径
插件将冻结的会话事件复制到每个活动会话各自的 controller,并启动主动排空流程。并发事件共享当前写入;期间接纳的事件形成后续批次`session/flush` 则等待当前待处理批次完成持久化。每会话游标防止恢复后的会话重新 append 已存储事件插件加载时会为活动会话设置初始状态。所属后端实例串行化单会话操作dispose资源释放会在拆卸前排空每个保留的 controller。
插件将冻结的会话事件复制到每个活动会话各自的 controller。第一个待处理事件会开启配置的固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一次持久化追加;该次写入期间接纳的事件形成另一个独立有界的后续批次`session/flush` 会取消等待并排空当前待处理批次。每会话游标防止恢复后的会话重新 append 已存储事件插件加载时会为活动会话设置初始状态。所属后端实例串行化单会话操作dispose资源释放会在拆卸前排空每个保留的 controller。每个逻辑事件都会保留:批处理只让单个压缩帧或一次原始 JSONL fsync 承载更多记录。
## 模型体验

View File

@@ -15,7 +15,8 @@ import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import { randomBytes } from 'node:crypto'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
@@ -76,6 +77,8 @@ export interface Config {
compression?: JsonlCompression
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
writeBatchMaxDelayMs?: number
}
/** Opaque coordinator token for replacing bytes recovered from a torn frame. */
@@ -122,6 +125,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS),
compression: JsonlCompressionSchema,
preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS)
.default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS),
})
/**
@@ -144,11 +149,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs
?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS
this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.assertUsableRoot()
this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this, {
preparedSessionCacheSize,
writeBatchMaxDelayMs,
})
}

View File

@@ -166,6 +166,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const fiber = await ctx.plugin(SessionPersistenceJsonl, {
root: relative(process.cwd(), absoluteRoot),
compression: 'none',
writeBatchMaxDelayMs: 1,
})
const m = meta('relative-location', '/work')
expect(ctx.sessionPersistence.locate(m)).toEqual({

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-sqlite/README.md
README.md: d01ba6ebfa1f59a9e4d58f3032bbe1d016970290
README.zh.md: c11bef5467a3401948b58d5fd8e3301b72ff3d03
README.md: 4961f62bf6854c343d35b8406c9d721590274534
README.zh.md: 8610ef56f737bac8781536ea26a5b48dbf67ed48

View File

@@ -31,12 +31,13 @@ interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
preparedSessionCacheSize?: number // positive integer; default 5
writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
}
```
## Write path
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database.
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session. The first pending event starts the configured fixed batching window, and later events join without resetting it. Expiry starts one transaction; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains current and pending batches. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. Every event remains a separate SQLite row; batching only groups more INSERTs into one transaction and revision increment.
## Model Experience

View File

@@ -31,12 +31,13 @@ interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
preparedSessionCacheSize?: number // positive integer; default 5
writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
}
```
## 写入路径
与 JSONL 后端一样,插件将每个冻结的 `session/event` 复制到每个活动会话各自的 controller,并启动主动排空流程。并发事件共享当前事务;期间接纳的事件形成后续批次`session/flush` 则等待当前待处理批次完成持久化。Controller 会持久化一次 fork 种子,并保留写入游标,使恢复操作绝不重新 append 已存储事件;它还会在 apply 时为活动会话设置初始状态,因为 HMR热模块替换不回放 `session/created`。dispose资源释放会在关闭数据库前排空每个保留的 controller。
与 JSONL 后端一样,插件将每个冻结的 `session/event` 复制到每个活动会话各自的 controller。第一个待处理事件会开启配置的固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个事务;该次写入期间接纳的事件形成另一个独立有界的后续批次`session/flush` 会取消等待并排空当前待处理批次。Controller 会持久化一次 fork 种子,并保留写入游标,使恢复操作绝不重新 append 已存储事件;它还会在 apply 时为活动会话设置初始状态,因为 HMR热模块替换不回放 `session/created`。dispose资源释放会在关闭数据库前排空每个保留的 controller。每个事件仍各占一行 SQLite 记录;批处理只把更多 INSERT 归入同一个事务和同一次修订版本递增。
## 模型体验

View File

@@ -14,7 +14,8 @@ import { DatabaseSync } from 'node:sqlite'
import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
type StoredPrefix, type StoredSuffix,
@@ -83,6 +84,8 @@ export interface Config {
journalMode?: JournalMode
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
writeBatchMaxDelayMs?: number
}
/**
@@ -97,6 +100,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
path: z.string().required(),
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS)
.default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS),
})
/**
@@ -116,11 +121,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
// Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs
?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS
// Open asynchronously so directory creation does not block plugin apply;
// every storage hook awaits the same readiness promise.
this.ready = this.openDb(config.path, (config as Required<Config>).journalMode)
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this, {
preparedSessionCacheSize,
writeBatchMaxDelayMs,
})
}

View File

@@ -675,6 +675,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const fiber = await ctx.plugin(SessionPersistenceSqlite, {
path: ':memory:',
preparedSessionCacheSize: 1,
writeBatchMaxDelayMs: 1,
})
const m = meta('sqlite-preparation-cache')
await ctx.sessionPersistence.create(m)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md
README.md: b29ff5ba17f384e8d3b1700ed3ad6c80aeeb184c
README.zh.md: 8ca4b6a7128383fdca706f35914a06eb1f26de02
README.md: 89c7cd5ebaff6f9ce9df9b60a50121dff4ddeeb5
README.zh.md: 1ef7eb6c6c507167f61bb5df7c4ce8183aac5d0d

View File

@@ -29,9 +29,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
## The write coordinator
`PersistenceCoordinator` owns per-id state and serialization, one eager write controller per live session, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) and [flush-controller simplification](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md).
`PersistenceCoordinator` owns per-id state and serialization, one bounded write controller per live session, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [flush-controller simplification](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md), and [bounded batching decision](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md).
Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller.
Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure.
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.

View File

@@ -29,9 +29,9 @@
## 写入协调器
`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的主动写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose资源释放。第一方后端组合一个协调器实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)。
`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose资源释放。第一方后端组合一个协调器实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)
每个 `session/event` 将事件复制到会话 controller,并在不阻塞生产者的情况下立即启动 drain。并发通知共享当前 drain写入期间接纳的事件保持 pending并触发下一批。`session/flush` 是观察屏障,会等待 controller 无当前或 pending 批次。即时写入失败记录日志保留批次;下一次显式 flush 或后端拆卸会重试该批次,并失败返回给调用方
每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败记录一次日志保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并失败再次发生时向调用方暴露失败
崩溃修复只适用于冷状态。对于实时 id`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id检查只读取、验证、冻结并构造一次未发布 Session只有来源 revision 仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留精确 Session提交任何待处理的撕裂尾部或中断轮次修复并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -15,14 +15,22 @@ import {
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionInspection } from './index.ts'
import type { SessionPersistenceRevision } from './revision.ts'
import { observeQueuedAbort, SessionPreparations } from './preparations.ts'
import type { SessionPreparationReservation } from './preparations.ts'
import { SessionWriteBehind } from './write-behind.ts'
/** Default number of detached session preparations retained by a coordinator. */
export const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5
/** Default maximum intentional wait before a live session batch starts writing. */
export const DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200
/** Largest write batching delay accepted by Node's timer implementation. */
export const MAX_WRITE_BATCH_DELAY_MS = MAX_TIMER_DELAY_MS
/** Durable session contents failed validation after a successful backend read. */
export class SessionPersistenceCorruptionError extends Error {
/**
@@ -39,6 +47,8 @@ export class SessionPersistenceCorruptionError extends Error {
export interface PersistenceCoordinatorOptions {
/** Maximum completed unpublished preparations retained for reuse. */
readonly preparedSessionCacheSize: number
/** Maximum intentional batching wait after an idle live queue receives work. */
readonly writeBatchMaxDelayMs: number
}
/**
@@ -174,11 +184,10 @@ interface SessionState {
owner?: Session
}
/** One live session's initialization and eager write-behind controller. */
/** One live session's initialization and bounded write-behind controller. */
interface LiveSessionState {
pending: SessionEvent[]
init: Promise<void>
flush: Promise<void> | undefined
writes: SessionWriteBehind
}
/** One validated cold source and the exact unpublished Session built from it. */
@@ -531,7 +540,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private states = new Map<SessionId, SessionState>()
/** Lifecycle and write-behind state keyed by the exact live Session. */
private live = new Map<Session, LiveSessionState>()
/** Exact disposed lifecycles whose eager tail is still draining. */
/** Exact disposed lifecycles whose buffered tail is still draining. */
private retirements = new Map<SessionId, Promise<void>>()
/** Shared cold reads, unpublished reservations, and completed LRU entries. */
private readonly preparations: SessionPreparations<PreparedSessionSource<TornMarker>, SessionState>
@@ -540,18 +549,27 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* same id, so writes for one session never interleave. Keyed by session id.
*/
private chains = new Map<SessionId, Promise<unknown>>()
/** Resolved fixed write-batching window shared by per-session controllers. */
private readonly writeBatchMaxDelayMs: number
constructor(
private ctx: Context,
private backend: PersistenceBackend<TornMarker>,
options: PersistenceCoordinatorOptions = {
preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE,
writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
},
) {
if (!Number.isSafeInteger(options.preparedSessionCacheSize)
|| options.preparedSessionCacheSize < 1) {
throw new TypeError('preparedSessionCacheSize must be a positive safe integer')
}
if (!Number.isSafeInteger(options.writeBatchMaxDelayMs)
|| options.writeBatchMaxDelayMs < 1
|| options.writeBatchMaxDelayMs > MAX_WRITE_BATCH_DELAY_MS) {
throw new TypeError(`writeBatchMaxDelayMs must be an integer between 1 and ${MAX_WRITE_BATCH_DELAY_MS}`)
}
this.writeBatchMaxDelayMs = options.writeBatchMaxDelayMs
this.preparations = new SessionPreparations(options.preparedSessionCacheSize)
this.installWritePath()
}
@@ -1014,14 +1032,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
void this.initFor(session)
})
// Keep a persistence-owned copy of each frozen event and start an eager drain.
// Keep a persistence-owned copy of each frozen event and start its bounded window.
ctx.on('session/event', (session, event) => {
const live = this.initFor(session)
live.pending.push(structuredClone(event))
if (live.flush === undefined) this.scheduleDrain(session, live)
live.writes.enqueue(event)
})
// Callers use flush as the observation barrier for the eager write path.
// Callers use flush as the immediate durability barrier for buffered writes.
ctx.on('session/flush', session => this.flush(session))
// Session disposal is observe-only, so retirement contains its own failure.
@@ -1067,9 +1084,14 @@ export class PersistenceCoordinator<TornMarker = unknown> {
return restored
}
const seed = session.events.map(e => structuredClone(e))
const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined }
let init = Promise.resolve()
const live: LiveSessionState = {
init,
writes: this.createWriteBehind(session, () => init),
}
this.live.set(session, live)
live.init = this.serialize(session.header.id, () => this.onCreated(session, seed))
init = this.serialize(session.header.id, () => this.onCreated(session, seed))
live.init = init
live.init.catch(() => { /* observed by flush/dispose through the controller */ })
return live
}
@@ -1088,9 +1110,14 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const suffix = session.events.slice(state.cursor).map(event => structuredClone(event))
this.preparations.attach(reservation)
state.owner = session
const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined }
let init = Promise.resolve()
const live: LiveSessionState = {
init,
writes: this.createWriteBehind(session, () => init),
}
if (suffix.length > 0) {
live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix))
init = this.serialize(session.id, () => this.appendCore(session.id, suffix))
live.init = init
live.init.catch(() => { /* observed by flush/dispose through the controller */ })
}
return live
@@ -1152,7 +1179,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
return
}
const owner = this.live.get(tracked.owner)
if (!tracked.materialized && !owner?.pending.length) {
if (!tracked.materialized && !owner?.writes.hasWork) {
this.states.delete(id)
} else {
throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`)
@@ -1214,41 +1241,29 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async flush(session: Session): Promise<void> {
const live = this.initFor(session)
await live.init
const overlapping = live.flush
if (overlapping !== undefined) await Promise.allSettled([overlapping])
while (live.flush !== undefined || live.pending.length > 0) {
if (live.flush !== undefined) await live.flush
else await this.ensureFlush(session, live)
}
await live.writes.flush()
}
/** Start an eager drain without exposing its failure to the synchronous append. */
private scheduleDrain(session: Session, live: LiveSessionState): void {
void this.ensureFlush(session, live).catch((error: unknown) => {
this.ctx.logger.warn(`${this.backend.name}: eager drain for session "${session.id}" failed (buffered events retained): ${String(error)}`)
/** Build one package-private write controller around initialization and id serialization. */
private createWriteBehind(session: Session, ready: () => Promise<void>): SessionWriteBehind {
return new SessionWriteBehind({
maxDelayMs: this.writeBatchMaxDelayMs,
write: async (batch) => {
await ready()
await this.serialize(session.header.id, () => this.appendLiveBatch(session.header.id, batch))
},
reportBackgroundFailure: (error) => {
this.ctx.logger.warn(`${this.backend.name}: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`)
},
})
}
/** Start one drain for the complete pending batch. */
private ensureFlush(session: Session, live: LiveSessionState): Promise<void> {
const flush = live.init
.then(() => this.serialize(session.header.id, () => this.drain(session.header.id, live)))
.finally(() => { live.flush = undefined })
live.flush = flush
void flush.then(() => {
if (live.pending.length > 0) this.scheduleDrain(session, live)
}, () => {})
return flush
}
/** Drain one stable prefix; events admitted during the write remain pending. */
private async drain(id: SessionId, live: LiveSessionState): Promise<void> {
const batch = live.pending.slice()
/** Append one controller-owned prefix after filtering events initialization already stored. */
private async appendLiveBatch(id: SessionId, batch: readonly SessionEvent[]): Promise<void> {
const state = this.states.get(id)
/* v8 ignore next -- state is always set by the awaited init before flush */
/* v8 ignore next -- state is always set by the awaited initialization */
const cursor = state?.cursor ?? 0
const fresh = batch.filter(e => e.seq >= cursor)
await this.appendCore(id, fresh)
live.pending.splice(0, batch.length)
}
}

View File

@@ -33,6 +33,8 @@ export interface SessionInspection {
// The backend-agnostic write-path orchestration first-party backends compose.
export {
DEFAULT_PREPARED_SESSION_CACHE_SIZE,
DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
MAX_WRITE_BATCH_DELAY_MS,
PersistenceCoordinator,
SessionPersistenceCorruptionError,
} from './coordinator.ts'

View File

@@ -0,0 +1,153 @@
/**
* Bounded per-session write batching for the shared persistence coordinator.
* @module @deepseek-ai/dsh-session-persistence/write-behind
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** Dependencies and scheduling policy for one live session's write controller. */
export interface SessionWriteBehindOptions {
/** Maximum intentional batching wait after an idle queue receives work. */
readonly maxDelayMs: number
/** Persist one stable ordered prefix; resolves only after backend durability. */
readonly write: (events: readonly SessionEvent[]) => Promise<void>
/** Observe a detached background write failure without rejecting the producer. */
readonly reportBackgroundFailure: (error: unknown) => void
}
/**
* Owns one live session's pending events, fixed batching deadline, active write,
* failure retention, and explicit quiescence barrier.
*/
export class SessionWriteBehind {
private pending: SessionEvent[] = []
private timer: ReturnType<typeof setTimeout> | undefined
private active: Promise<void> | undefined
private barrier: Promise<void> | undefined
private deadlineExpired = false
private automaticPaused = false
/**
* @param options - fixed scheduling policy and durable batch sink.
*/
constructor(private readonly options: SessionWriteBehindOptions) {}
/** Whether this controller owns queued events or an active durable write. */
get hasWork(): boolean {
return this.pending.length > 0 || this.active !== undefined
}
/**
* Copy one event into the persistence-owned queue and start a fixed deadline
* when the automatic path is idle.
* @param event - frozen live event to retain independently of its producer.
*/
enqueue(event: SessionEvent): void {
const wasEmpty = this.pending.length === 0
this.pending.push(structuredClone(event))
if (this.barrier !== undefined) return
if (this.automaticPaused) {
this.automaticPaused = false
this.deadlineExpired = false
this.armTimer()
} else if (wasEmpty) {
this.armTimer()
}
}
/**
* Cancel the batching wait and durably drain through a quiescent point.
* Concurrent callers join the same barrier.
* @returns a promise that rejects if the barrier's durable retry fails.
*/
flush(): Promise<void> {
if (this.barrier !== undefined) return this.barrier
this.cancelTimer()
this.deadlineExpired = false
this.automaticPaused = false
const barrier = Promise.withResolvers<void>()
this.barrier = barrier.promise
void this.drainBarrier(barrier.resolve, barrier.reject)
return barrier.promise
}
/** Start the one fixed window for the current pending prefix. */
private armTimer(): void {
this.timer = setTimeout(() => { this.onDeadline() }, this.options.maxDelayMs)
}
/** Cancel any pending automatic deadline. */
private cancelTimer(): void {
if (this.timer === undefined) return
clearTimeout(this.timer)
this.timer = undefined
}
/** Start a background write now, or remember that an active write used the budget. */
private onDeadline(): void {
this.timer = undefined
if (this.active !== undefined) {
this.deadlineExpired = true
return
}
this.startBackground()
}
/** Start one detached write whose failure is reported and retained. */
private startBackground(): void {
const active = this.startWrite(true)
void active.then(() => { this.continueAutomatic() }, () => {})
}
/** Continue immediately after an over-budget active write, otherwise keep its timer. */
private continueAutomatic(): void {
if (this.barrier !== undefined || this.pending.length === 0) return
if (this.deadlineExpired) {
this.deadlineExpired = false
this.startBackground()
}
}
/** Await overlapping work, drain to quiescence, and settle the shared barrier. */
private async drainBarrier(resolve: () => void, reject: (reason?: unknown) => void): Promise<void> {
try {
const overlapping = this.active
if (overlapping !== undefined) {
await Promise.allSettled([overlapping])
this.automaticPaused = false
}
while (this.pending.length > 0) await this.startWrite(false)
} catch (error: unknown) {
this.barrier = undefined
reject(error)
return
}
// Close admission to this barrier in the same job that observes the empty
// queue, before resolving callers. A later enqueue therefore starts its own
// automatic window instead of being stranded behind a settled barrier.
this.barrier = undefined
resolve()
}
/** Start one stable pending prefix, retaining it in order if durability fails. */
private startWrite(background: boolean): Promise<void> {
const batch = this.pending.splice(0)
this.cancelTimer()
this.deadlineExpired = false
const operation = Promise.resolve().then(() => this.options.write(batch))
const active = operation
.catch((error: unknown) => {
this.pending.unshift(...batch)
this.cancelTimer()
this.deadlineExpired = false
this.automaticPaused = true
if (background) this.options.reportBackgroundFailure(error)
throw error
})
.finally(() => {
this.active = undefined
})
this.active = active
return active
}
}

View File

@@ -3,6 +3,7 @@ import { Context } from 'cordis'
import SessionStore, { Session, SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix,
} from '../src/index.ts'
@@ -53,7 +54,9 @@ interface MemoryConfig { store?: MemoryStore }
/** Test-only view of the coordinator containers whose retirement is the contract under test. */
interface CoordinatorInternals {
states: Map<unknown, unknown>
live: Map<unknown, { pending: unknown[]; flush: Promise<void> | undefined }>
live: Map<unknown, {
writes: { pending: unknown[]; active: Promise<void> | undefined; hasWork: boolean }
}>
chains: Map<unknown, unknown>
retirements: Map<unknown, Promise<void>>
}
@@ -253,7 +256,7 @@ runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
}
})
describe('PersistenceCoordinator eager writes', () => {
describe('PersistenceCoordinator bounded writes', () => {
it('starts a follow-up batch for events admitted during an in-flight write', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -263,11 +266,14 @@ describe('PersistenceCoordinator eager writes', () => {
if (attempt === 1) await appendGate.promise
}
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
new PersistenceCoordinator(inner, backend)
new PersistenceCoordinator(inner, backend, {
preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE,
writeBatchMaxDelayMs: 1,
})
}, { inject: ['sessions'] }))
try {
const session = ctx.sessions.create(SessionId('eager-follow-up'))
const session = ctx.sessions.create(SessionId('bounded-follow-up'))
await ctx.sessions.flush(session)
session.append('turn/start', { turn: 1 })
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
@@ -286,7 +292,7 @@ describe('PersistenceCoordinator eager writes', () => {
}
})
it('retries a failed overlapping eager write at the explicit flush barrier', async () => {
it('retries a failed overlapping background write at the explicit flush barrier', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
@@ -294,15 +300,18 @@ describe('PersistenceCoordinator eager writes', () => {
backend.beforeAppend = async (attempt) => {
if (attempt === 1) {
await appendGate.promise
throw new Error('transient eager failure')
throw new Error('transient background failure')
}
}
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
new PersistenceCoordinator(inner, backend)
new PersistenceCoordinator(inner, backend, {
preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE,
writeBatchMaxDelayMs: 1,
})
}, { inject: ['sessions'] }))
try {
const session = ctx.sessions.create(SessionId('eager-flush-retry'))
const session = ctx.sessions.create(SessionId('bounded-flush-retry'))
await ctx.sessions.flush(session)
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -401,9 +410,20 @@ describe('PersistenceCoordinator session preparations', () => {
expect(() => new PersistenceCoordinator(ctx, backend, {
preparedSessionCacheSize: capacity,
writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
})).toThrow(/positive safe integer/)
})
it.each([0, 1.5, MAX_WRITE_BATCH_DELAY_MS + 1])('rejects invalid write batch delay %s', (delay) => {
const ctx = new Context()
const backend = new ControlledBackend()
expect(() => new PersistenceCoordinator(ctx, backend, {
preparedSessionCacheSize: DEFAULT_PREPARED_SESSION_CACHE_SIZE,
writeBatchMaxDelayMs: delay,
})).toThrow(/writeBatchMaxDelayMs must be an integer between/)
})
it('retries invalidated prepare and load reservations', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -592,6 +612,39 @@ describe('PersistenceCoordinator session preparations', () => {
}
})
it('writes new events after publishing a preparation with no unpublished suffix', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('prepared-live-write')
const stored = [
...oneTurnLog(),
{ type: 'session/end-seed', seq: 6, time: 7, data: {} } as SessionEvent,
]
backend.store.set(id, { meta: meta(id), events: stored })
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const preparation = await coordinator.prepare(id)
const detach = ctx.sessions.enter(preparation.session)
try {
ctx.sessions.announce(preparation.session)
preparation.session.append('turn/start', { turn: 2 })
preparation.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await expect(ctx.sessions.flush(preparation.session)).resolves.toBe(true)
expect(backend.store.get(id)?.events.map(event => event.seq))
.toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
} finally {
detach()
preparation[Symbol.dispose]()
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('reuses the exact Session from inspect through repeated unpublished prepare calls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1004,7 +1057,10 @@ describe('PersistenceCoordinator session preparations', () => {
backend.store.set(secondId, { meta: meta(secondId), events: oneTurnLog() })
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend, { preparedSessionCacheSize: 1 })
coordinator = new PersistenceCoordinator(inner, backend, {
preparedSessionCacheSize: 1,
writeBatchMaxDelayMs: DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
})
}, { inject: ['sessions'] }))
try {
@@ -1529,7 +1585,7 @@ describe('PersistenceCoordinator retirement', () => {
await vi.waitFor(() => {
expect(backend.appendAttempts).toBeGreaterThanOrEqual(1)
expect([...internals.live.values()][0]?.pending).toEqual(expect.arrayContaining([
expect([...internals.live.values()][0]?.writes.pending).toEqual(expect.arrayContaining([
expect.objectContaining({ seq: 0 }),
expect.objectContaining({ seq: 1 }),
]))
@@ -1573,7 +1629,7 @@ describe('PersistenceCoordinator retirement', () => {
await vi.waitFor(() => {
expect(backend.appendAttempts).toBe(1)
expect(internals.live.size).toBe(1)
expect([...internals.live.values()][0]?.flush).toBeInstanceOf(Promise)
expect([...internals.live.values()][0]?.writes.active).toBeInstanceOf(Promise)
})
let disposed = false

View File

@@ -0,0 +1,252 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionWriteBehind } from '../src/write-behind.ts'
/** Minimal ordered event fixture; batching does not interpret event vocabulary. */
function event(seq: number): SessionEvent<'turn/start'> {
return {
type: 'turn/start',
seq,
time: seq,
data: { turn: seq + 1 },
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('SessionWriteBehind', () => {
it('uses one fixed window from the first queued event and owns its copy', async () => {
vi.useFakeTimers()
const batches: SessionEvent[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(structuredClone(events) as SessionEvent[]) },
reportBackgroundFailure: vi.fn(),
})
const first = event(0)
controller.enqueue(first)
first.data.turn = 99
await vi.advanceTimersByTimeAsync(150)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(49)
expect(batches).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[
expect.objectContaining({ seq: 0, data: { turn: 1 } }),
expect.objectContaining({ seq: 1 }),
]])
expect(controller.hasWork).toBe(false)
})
it('coalesces twenty events admitted ten milliseconds apart into one 200 ms batch', async () => {
vi.useFakeTimers()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(events.map(item => item.seq)) },
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
for (let seq = 1; seq < 20; seq += 1) {
await vi.advanceTimersByTimeAsync(10)
controller.enqueue(event(seq))
}
expect(batches).toEqual([])
await vi.advanceTimersByTimeAsync(10)
expect(batches).toEqual([Array.from({ length: 20 }, (_, seq) => seq)])
await controller.flush()
})
it('makes concurrent flushes one immediate barrier that drains admitted tails', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
const first = controller.flush()
const second = controller.flush()
expect(second).toBe(first)
await Promise.resolve()
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
gate.resolve(true)
await first
expect(batches).toEqual([[0], [1]])
expect(controller.hasWork).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('starts a new window for work admitted after an already-quiescent barrier', async () => {
vi.useFakeTimers()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(events.map(item => item.seq)) },
reportBackgroundFailure: vi.fn(),
})
const barrier = controller.flush()
controller.enqueue(event(0))
await barrier
expect(batches).toEqual([])
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
expect(controller.hasWork).toBe(false)
})
it('starts an over-budget tail immediately after the active write', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
gate.resolve(true)
await vi.advanceTimersByTimeAsync(0)
expect(batches).toEqual([[0], [1]])
await controller.flush()
})
it('keeps a tail deadline that has not expired when the active write finishes', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(50)
gate.resolve(true)
await vi.advanceTimersByTimeAsync(0)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(149)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[0], [1]])
await controller.flush()
})
it('pauses automatic retries after failure and preserves order for new work', async () => {
vi.useFakeTimers()
const failure = new Error('storage unavailable')
const report = vi.fn()
const batches: number[][] = []
let attempt = 0
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (++attempt === 1) throw failure
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
expect(report).toHaveBeenCalledWith(failure)
expect(controller.hasWork).toBe(true)
await vi.advanceTimersByTimeAsync(1_000)
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(199)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[0], [0, 1]])
await controller.flush()
})
it('observes an overlapping background failure and retries it inside flush', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const report = vi.fn()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) {
await gate.promise
throw new Error('transient')
}
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
const first = controller.flush()
const second = controller.flush()
gate.resolve(true)
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
expect(batches).toEqual([[0], [0]])
expect(report).toHaveBeenCalledOnce()
expect(controller.hasWork).toBe(false)
})
it('surfaces a barrier failure without detached logging and retains its batch', async () => {
vi.useFakeTimers()
const failure = new Error('durability failed')
const report = vi.fn()
const batches: number[][] = []
let attempt = 0
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (++attempt === 1) throw failure
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await expect(controller.flush()).rejects.toBe(failure)
expect(report).not.toHaveBeenCalled()
expect(controller.hasWork).toBe(true)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0], [0, 1]])
await controller.flush()
})
})