workspace-message-latency.ts 6.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. /** Run with: npx tsx tests/perf/workspace-message-latency.ts [output.json]
  2. * Owned temporary SQLite homes and loopback SSE only; no model or user service. */
  3. import Koa from 'koa'
  4. import { once } from 'node:events'
  5. import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
  9. import assert from 'node:assert/strict'
  10. import { WorkspaceStore } from '../../src/server/workspaceStore.js'
  11. import { streamWorkspace } from '../../src/server/workspaceSync.js'
  12. import { WorkspaceMessageReconciler } from '../../src/shared/workspaceMessagePatch.js'
  13. import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '../../src/shared/workspace.js'
  14. import type { LocalAuthStore } from '../../src/server/localAuth.js'
  15. const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
  16. const quantile = (values: number[], p: number) => [...values].sort((a, b) => a - b)[Math.min(values.length - 1, Math.floor(values.length * p))] ?? 0
  17. const auth = { require: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => 1 } as unknown as LocalAuthStore
  18. async function measure(patches: boolean) {
  19. const home = mkdtempSync(join(tmpdir(), 'workspace-message-perf-')), store = new WorkspaceStore(home, { messagePatches: patches })
  20. const messages: WorkspaceMessage[] = []
  21. for (let i = 0; i < 5; i++) {
  22. store.createAgent('owner', { name: `Bot ${i}`, profile: 'default' })
  23. const c = store.list<WorkspaceConversation>('owner', 'conversation').find(c => c.name === `Bot ${i}`)!
  24. const message: WorkspaceMessage = { id: `m${i}`, conversationId: c.id, seq: 0, role: 'assistant', content: '', reasoning: '', status: 'streaming', attachments: [], tools: [], createdAt: Date.now() }
  25. store.saveMessage('owner', message); messages.push(message)
  26. }
  27. const startCursor = store.cursor('owner'), folder = new WorkspaceMessageReconciler()
  28. messages.forEach(m => folder.remember(structuredClone(m)))
  29. const app = new Koa(); app.use(ctx => streamWorkspace(ctx, store, auth))
  30. const server = app.listen(0, '127.0.0.1'); await once(server, 'listening')
  31. const controller = new AbortController(), saves: number[] = [], completed = new Map<string, string>()
  32. const loop = monitorEventLoopDelay({ resolution: 10 }); loop.enable()
  33. let bytes = 0, events = 0, buffer = ''
  34. let consume: Promise<void> | undefined
  35. try {
  36. const response = await fetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/?format=${patches ? 'patch-v1' : ''}`, { headers: { 'Last-Event-ID': String(startCursor) }, signal: controller.signal })
  37. const reader = response.body!.getReader(), decoder = new TextDecoder()
  38. consume = (async () => {
  39. try {
  40. while (true) {
  41. const next = await reader.read(); if (next.done) break
  42. bytes += next.value.length; buffer += decoder.decode(next.value, { stream: true })
  43. let boundary: number
  44. while ((boundary = buffer.indexOf('\n\n')) >= 0) {
  45. const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2)
  46. if (!frame.includes('event: workspace')) continue
  47. const line = frame.split('\n').find(l => l.startsWith('data: '))!
  48. const event = folder.normalize(JSON.parse(line.slice(6)) as WorkspaceEvent); events++
  49. if (event.type === 'message.changed') {
  50. const message = event.data as WorkspaceMessage
  51. if (message.status === 'complete') completed.set(message.id, message.content)
  52. }
  53. }
  54. }
  55. } catch (error) { if (!controller.signal.aborted) throw error }
  56. })()
  57. const cpu = process.cpuUsage(), started = performance.now()
  58. const last = messages.map(() => -Infinity), timers: Array<ReturnType<typeof setTimeout> | undefined> = []
  59. const flush = (index: number) => {
  60. clearTimeout(timers[index]); timers[index] = undefined
  61. const start = performance.now(); store.saveMessage('owner', messages[index]!); saves.push(performance.now() - start); last[index] = performance.now()
  62. }
  63. for (let i = 0; i < 200; i++) {
  64. for (const [index, message] of messages.entries()) {
  65. message.content += `第${String(i + 1).padStart(3, '0')}段。用于比较两个移动端的连续文本显示表现,全部内容来自隔离测试服务。\n\n`
  66. const remaining = (patches ? 33 : 100) - (performance.now() - last[index]!)
  67. if (remaining <= 0) flush(index)
  68. else if (timers[index] === undefined) timers[index] = setTimeout(() => flush(index), remaining)
  69. }
  70. await sleep(20)
  71. }
  72. for (const [index, message] of messages.entries()) { message.status = 'complete'; flush(index) }
  73. const deadline = performance.now() + 3000
  74. while (completed.size !== 5 && performance.now() < deadline) await sleep(10)
  75. for (const message of messages) assert.equal(completed.get(message.id), message.content, 'complete streamed text equals the full stored message')
  76. const consumed = process.cpuUsage(cpu)
  77. return { format: patches ? 'patch-v1' : 'full-v1', concurrentBots: 5, sourceChunksPerBot: 200, finalCharactersPerBot: messages[0]!.content.length,
  78. sseBytes: bytes, events, saveCount: saves.length, saveMedianMs: quantile(saves, .5), saveP95Ms: quantile(saves, .95), saveMaxMs: Math.max(...saves),
  79. elapsedMs: performance.now() - started, cpuMs: (consumed.user + consumed.system) / 1000,
  80. eventLoopP95Ms: loop.percentile(95) / 1e6, walBytes: statSync(join(home, 'workspace.sqlite3-wal')).size,
  81. eventLogBytes: Number(store.db.prepare('SELECT sum(length(cast(data AS BLOB))) AS n FROM workspace_events WHERE seq>?').get(startCursor)!.n), completedMessages: completed.size }
  82. } finally {
  83. controller.abort(); await consume; loop.disable(); store.close(); server.closeAllConnections()
  84. await new Promise<void>(resolve => server.close(() => resolve())); rmSync(home, { recursive: true, force: true })
  85. }
  86. }
  87. const baseline = await measure(false), optimized = await measure(true)
  88. const result = { baseline, optimized, sseReduction: 1 - optimized.sseBytes / baseline.sseBytes }
  89. console.log(JSON.stringify(result, null, 2))
  90. if (process.argv[2]) writeFileSync(process.argv[2], JSON.stringify(result, null, 2))
  91. assert.ok(result.sseReduction >= .6, 'total SSE bytes reduced by at least 60%')
  92. assert.ok(optimized.saveP95Ms <= 10, 'five concurrent Bots keep persistence and event encoding below 10ms P95')