diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml index 086cb661be..8ec78e7bbe 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md -2026-08-15-packed-session-history-transport.md: 9f4c49b8c106e65371b54d07817a58d2bf324fd5 -2026-08-15-packed-session-history-transport.zh.md: 883dd72346efdf5b50ca64b64fc709b257ba8ed7 +2026-08-15-packed-session-history-transport.md: 64835e4588a2b01d2afc135dd14a95a6f6e8009d +2026-08-15-packed-session-history-transport.zh.md: 47b6544fbb05a123c93a274449f030a06c1aa57a diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md index 9f4c49b8c1..64835e4588 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md @@ -32,7 +32,7 @@ A production-sized private session sample was measured without retaining or comm Packing reduced uncompressed JSON by 90.8% relative to raw logical events and by 83.4% relative to the lossy completed-step projection candidate. Brotli output was 73.2% smaller than raw and 44.8% smaller than that projection candidate. These figures describe this sample rather than a protocol guarantee; savings scale with the length and regularity of delta runs. -The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports the median of five exact decodes for 10,000-, 20,000-, and 40,000-member whitespace-prefix runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/decoding/fold stage, relative to the same initialized benchmark state. They do not measure process RSS and can miss transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, exact decoded event count, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture. +The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports the median of five exact decodes for 10,000-, 20,000-, and 40,000-member whitespace-prefix runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, projection and presenter work, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/decoding/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, exact decoded event count, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md index 883dd72346..47b6544fbb 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md @@ -32,7 +32,7 @@ Status: implemented 与原始逻辑事件相比,打包使未压缩 JSON 减少 90.8%;与有损的已完成步骤投影候选相比减少 83.4%。Brotli 输出相对原始形式减少 73.2%,相对该投影候选减少 44.8%。这些数字描述该样本,并非协议保证;收益随 delta run 的长度与规律性变化。 -可选运行的 `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时与采样的额外 V8 堆峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员的空白前缀 run 各五次精确解码的中位数。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/解码/折叠各主要阶段之后所观察峰值的中位数。该指标不测量进程 RSS,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、精确解码事件数,以及双消费方 Assistant 折叠 fixture 的一致最终状态,包括 delta 数量与末个 delta 序号。 +可选运行的 `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样的额外 V8 堆峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员的空白前缀 run 各五次精确解码的中位数。组合计时从内存事件数组开始,不包含冷持久化读取、projection 与 presenter 工作、生产 API bridge 与 RPC 信封,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/解码/折叠各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、精确解码事件数,以及双消费方 Assistant 折叠 fixture 的一致最终状态,包括 delta 数量与末个 delta 序号。 ## 曾考虑的替代方案 diff --git a/packages/api/session-controller/tests/history-transport.perf.client.ts b/packages/api/session-controller/tests/history-transport.perf.client.ts index 6f7d34e103..9bf17ae64d 100644 --- a/packages/api/session-controller/tests/history-transport.perf.client.ts +++ b/packages/api/session-controller/tests/history-transport.perf.client.ts @@ -1,6 +1,7 @@ /** Opt-in synthetic benchmark for packed session-history transport and exact replay. */ import { createHash } from 'node:crypto' +import { createServer, type Server } from 'node:http' import { performance } from 'node:perf_hooks' import { brotliCompressSync, gzipSync } from 'node:zlib' import { expect, it } from 'vitest' @@ -38,6 +39,19 @@ interface HeapPeaks { readonly peakBytes: readonly number[] } +interface TransferSample { + readonly headersMs: number + readonly bodyMs: number + readonly totalMs: number +} + +interface TransferTimings { + readonly headersMs: number + readonly bodyMs: number + readonly totalMs: number + readonly samples: readonly TransferSample[] +} + interface FoldState { readonly blocks: readonly string[] readonly deltaCount: number @@ -83,6 +97,67 @@ function median(values: readonly number[]): number { return ordered[Math.floor(ordered.length / 2)]! } +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + const failed = (error: Error): void => { reject(error) } + server.once('error', failed) + server.listen(0, '127.0.0.1', () => { + server.off('error', failed) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('history transport benchmark server has no TCP port') + return address.port +} + +async function close(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +async function loopbackTransfer(json: string): Promise { + const server = createServer((_request, response) => { + // Production Response.json reaches the bridge without content-length, so + // leave Node's response chunked for the same body-transfer behavior. + response.writeHead(200, { 'content-type': 'application/json' }) + response.write(json) + response.end() + }) + const port = await listen(server) + const once = async (): Promise => { + const started = performance.now() + const response = await fetch(`http://127.0.0.1:${String(port)}/`) + const headers = performance.now() + const body = await response.text() + const completed = performance.now() + if (body.length !== json.length) throw new Error('history transport benchmark received a truncated body') + return { + headersMs: headers - started, + bodyMs: completed - headers, + totalMs: completed - started, + } + } + try { + await once() + const samples: TransferSample[] = [] + for (let index = 0; index < 5; index++) samples.push(await once()) + return { + headersMs: median(samples.map(sample => sample.headersMs)), + bodyMs: median(samples.map(sample => sample.bodyMs)), + totalMs: median(samples.map(sample => sample.totalMs)), + samples, + } + } finally { + await close(server) + } +} + /** Measure caller-sampled additional V8 heap from forced-GC baselines. */ function sampledPeakHeap(run: (sample: () => void) => T): HeapPeaks { const forceGc = globalThis.gc @@ -246,7 +321,7 @@ function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex') } -it('reports packed history transport and exact replay costs', () => { +it('reports packed history transport and exact replay costs', async () => { const fixture = timed(buildEvents) assemble(conversationInputs(fixture.value.slice(0, 1_000).map(event => ({ event })))) @@ -291,6 +366,8 @@ it('reports packed history transport and exact replay costs', () => { const packedGzip = timed(() => gzipSync(packedJson.value).byteLength) const rawBrotli = timed(() => brotliCompressSync(rawJson.value).byteLength) const packedBrotli = timed(() => brotliCompressSync(packedJson.value).byteLength) + const rawTransfer = await loopbackTransfer(rawJson.value) + const packedTransfer = await loopbackTransfer(packedJson.value) const rawClientHeap = sampledPeakHeap((sample) => { const parsed = JSON.parse(rawJson.value) as RawHistoryValue @@ -347,6 +424,10 @@ it('reports packed history transport and exact replay costs', () => { const packedResponseMs = packed.ms + packedRecords.ms + packedJson.ms const rawClientMs = parsedRaw.ms + rawValidation.ms + rawPreparation.ms + rawFold.ms const packedClientMs = parsedPacked.ms + packedValidation.ms + packedPreparation.ms + packedFold.ms + const rawApiWaitMs = rawResponseMs + rawTransfer.totalMs + parsedRaw.ms + rawValidation.ms + const packedApiWaitMs = packedResponseMs + packedTransfer.totalMs + parsedPacked.ms + packedValidation.ms + const rawReadyMs = rawResponseMs + rawTransfer.totalMs + rawClientMs + const packedReadyMs = packedResponseMs + packedTransfer.totalMs + packedClientMs process.stdout.write(`HISTORY_TRANSPORT_PERF_RESULT ${JSON.stringify({ fixture: { buildMs: rounded(fixture.ms), @@ -395,6 +476,26 @@ it('reports packed history transport and exact replay costs', () => { packedResponseMs: rounded(packedResponseMs), responseReductionPct: reduction(rawResponseMs, packedResponseMs), }, + transport: { + samples: 5, + rawHeadersMs: rounded(rawTransfer.headersMs), + packedHeadersMs: rounded(packedTransfer.headersMs), + rawBodyMs: rounded(rawTransfer.bodyMs), + packedBodyMs: rounded(packedTransfer.bodyMs), + rawTotalMs: rounded(rawTransfer.totalMs), + packedTotalMs: rounded(packedTransfer.totalMs), + totalReductionPct: reduction(rawTransfer.totalMs, packedTransfer.totalMs), + rawSamples: rawTransfer.samples.map(sample => ({ + headersMs: rounded(sample.headersMs), + bodyMs: rounded(sample.bodyMs), + totalMs: rounded(sample.totalMs), + })), + packedSamples: packedTransfer.samples.map(sample => ({ + headersMs: rounded(sample.headersMs), + bodyMs: rounded(sample.bodyMs), + totalMs: rounded(sample.totalMs), + })), + }, client: { rawParseMs: rounded(parsedRaw.ms), packedParseMs: rounded(parsedPacked.ms), @@ -408,6 +509,14 @@ it('reports packed history transport and exact replay costs', () => { packedHistoryMs: rounded(packedClientMs), historyReductionPct: reduction(rawClientMs, packedClientMs), }, + combined: { + rawApiWaitMs: rounded(rawApiWaitMs), + packedApiWaitMs: rounded(packedApiWaitMs), + apiWaitReductionPct: reduction(rawApiWaitMs, packedApiWaitMs), + rawReadyMs: rounded(rawReadyMs), + packedReadyMs: rounded(packedReadyMs), + readyReductionPct: reduction(rawReadyMs, packedReadyMs), + }, })}\n`) }, 600_000)