perf(sync): optimize multi-session event pipeline with per-directory queues and delta coalescing (#908)
* fix: hide archived section and empty folders when no sessions remain - Only push archived group in useSessionGrouping when there are archived sessions, preventing an empty archived section from rendering - Hide empty folders in archived bucket via shouldKeepFolder check in SessionGroupSection (folders with no sessions and no content in children are filtered out) - Always filter folders through shouldKeepFolder, not just during search * perf: memoize archived folder filtering * perf(sync): per-directory event queues to eliminate cross-session HoL blocking The SSE event pipeline previously used a single global queue and a single flush timer shared across all directories. Under concurrent multi-session workloads, a busy directory's delta storm would block other directories' status and state events from reaching the UI until the next flush tick, producing the "multi-session latency" symptom users report. Split the queue into one DirectoryQueue per directory, each with its own coalesce map, stale-delta set, and flush timer. Directories flush independently so a busy directory can no longer starve a quiet one. Coalesce keys are now scoped to a single directory's queue, so the directory prefix is removed from the key strings. Cross-directory behavior only; same-directory multi-session behavior is unchanged (React 18 auto-batching still collapses a single directory's flush into one render). * perf(sync): coalesce consecutive message.part.delta events per flush window Within a 16ms flush window, consecutive delta events for the same (messageID, partID, field) tuple are string-concatenated into a single accumulated delta rather than being queued individually. This directly addresses same-project multi-session workloads — most notably parent sessions with subagent tasks (child sessions share the same directory queue). Both parties stream deltas concurrently, which previously multiplied raw event count proportionally to the number of active sessions. Coalescing can reduce queue depth by 10-100x during active streaming. Safety: verified against event-reducer.ts — the delta handler is a pure string append (existingValue + props.delta) with no per-event side effects (no time.updated, no notifications, no diff calculations). The merged result is semantically identical to applying each delta separately. The staleDeltas skip mechanism is unaffected: accumulated delta payloads retain their type and identifiers, so message.part.updated supersession still works correctly. * test(sync): cover per-directory queues and delta coalescing Extend event-pipeline.test.js with behavioural coverage for both optimizations landed in 98d013a and 258acf0: P1 (per-directory queues) - Delivers events from two directories without loss - Keeps distinct sessionIDs in the same directory as independent coalesce slots (session.status is not overwritten across sessions) - Collapses repeated session.status for the same session down to latest Option C (delta coalescing) - Accumulates consecutive deltas for the same (messageID, partID, field) into a single dispatched event with concatenated content - Does not merge deltas across different fields on the same part - Does not merge deltas across different parts on the same message - Does not merge deltas across different directories (per-dir queues) - Skips accumulated deltas when message.part.updated is coalesced onto an earlier update, proving staleDeltas still works with C - Leaves non-delta coalescing (session.status replace semantics) intact All 13 tests pass under bun:test. Also adds event-pipeline.bench.js, a runnable synthetic benchmark that reports delta reduction and byte integrity across 8 workload scenarios from "single session, 500 tokens" up to "10 projects × 5 sessions × 1000 tokens". Run with: bun packages/ui/src/sync/__tests__/event-pipeline.bench.js Current numbers on this machine: 99.5% - 99.9% delta event reduction with full byte-level integrity (concatenated delta bytes always equal the input total). * fix(sync): remove staleDeltas — it silently drops delta events --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
5584537da5
commit
1656c3bb93
@@ -78,62 +78,102 @@ function resolveEventDirectory(event: unknown, payload: Event): string {
|
||||
return propertyDirectory && propertyDirectory.length > 0 ? propertyDirectory : "global"
|
||||
}
|
||||
|
||||
// Per-directory queue state. Each directory owns an independent flush timer
|
||||
// so a busy directory's delta storm cannot block another directory's events
|
||||
// from reaching the UI (head-of-line blocking across sessions).
|
||||
type DirectoryQueue = {
|
||||
queue: Event[]
|
||||
buffer: Event[]
|
||||
coalesced: Map<string, number>
|
||||
timer: ReturnType<typeof setTimeout> | undefined
|
||||
last: number
|
||||
}
|
||||
|
||||
export function createEventPipeline(input: EventPipelineInput) {
|
||||
const { sdk, onEvent, onReconnect } = input
|
||||
const abort = new AbortController()
|
||||
let hasConnected = false
|
||||
|
||||
// Queue state
|
||||
let queue: QueuedEvent[] = []
|
||||
let buffer: QueuedEvent[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
// One queue + one flush timer per directory. Lazily created on first event.
|
||||
const directories = new Map<string, DirectoryQueue>()
|
||||
|
||||
// Coalesce key — same-type events for the same entity replace earlier ones
|
||||
const key = (directory: string, payload: Event): string | undefined => {
|
||||
const getOrCreateDir = (directory: string): DirectoryQueue => {
|
||||
let d = directories.get(directory)
|
||||
if (d) return d
|
||||
d = {
|
||||
queue: [],
|
||||
buffer: [],
|
||||
coalesced: new Map(),
|
||||
timer: undefined,
|
||||
last: 0,
|
||||
}
|
||||
directories.set(directory, d)
|
||||
return d
|
||||
}
|
||||
|
||||
// Coalesce key — same-type events for the same entity replace earlier ones.
|
||||
// Keys are scoped to a single directory's queue, so directory is implicit.
|
||||
// message.part.delta is a special case: consecutive deltas for the same
|
||||
// (messageID, partID, field) are accumulated (string-concatenated) rather
|
||||
// than replaced, because the reducer is a pure append and merging is
|
||||
// semantically identical to applying each delta individually.
|
||||
const key = (payload: Event): string | undefined => {
|
||||
if (payload.type === "session.status") {
|
||||
const props = payload.properties as { sessionID: string }
|
||||
return `session.status:${directory}:${props.sessionID}`
|
||||
return `session.status:${props.sessionID}`
|
||||
}
|
||||
if (payload.type === "lsp.updated") {
|
||||
return `lsp.updated:${directory}`
|
||||
return `lsp.updated`
|
||||
}
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = (payload.properties as { part: { messageID: string; id: string } }).part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
return `message.part.updated:${part.messageID}:${part.id}`
|
||||
}
|
||||
if (payload.type === "message.part.delta") {
|
||||
const props = payload.properties as { messageID: string; partID: string; field: string }
|
||||
return `message.part.delta:${props.messageID}:${props.partID}:${props.field}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Flush — swap queue, dispatch events
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
// Flush one directory — swap queue, dispatch events.
|
||||
// React 18 auto-batching still collapses the setState calls inside a single
|
||||
// directory's flush into one render pass.
|
||||
const flushDir = (directory: string) => {
|
||||
const d = directories.get(directory)
|
||||
if (!d) return
|
||||
if (d.timer) {
|
||||
clearTimeout(d.timer)
|
||||
d.timer = undefined
|
||||
}
|
||||
if (d.queue.length === 0) return
|
||||
|
||||
if (queue.length === 0) return
|
||||
const events = d.queue
|
||||
d.queue = d.buffer
|
||||
d.buffer = events
|
||||
d.queue.length = 0
|
||||
d.coalesced.clear()
|
||||
|
||||
const events = queue
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
|
||||
last = Date.now()
|
||||
d.last = Date.now()
|
||||
syncDebug.pipeline.flush(events.length)
|
||||
// React 18 batches synchronous setState calls automatically,
|
||||
// equivalent to SolidJS batch()
|
||||
for (const event of events) {
|
||||
onEvent(event.directory, event.payload)
|
||||
for (const payload of events) {
|
||||
onEvent(directory, payload)
|
||||
}
|
||||
|
||||
buffer.length = 0
|
||||
d.buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
const flushAll = () => {
|
||||
for (const directory of directories.keys()) {
|
||||
flushDir(directory)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDir = (directory: string) => {
|
||||
const d = getOrCreateDir(directory)
|
||||
if (d.timer) return
|
||||
const elapsed = Date.now() - d.last
|
||||
d.timer = setTimeout(() => flushDir(directory), Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
// Helpers
|
||||
@@ -201,18 +241,34 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
}
|
||||
const normalizedPayload = normalizeEventType(payload)
|
||||
const directory = resolveEventDirectory(event, normalizedPayload)
|
||||
const k = key(directory, normalizedPayload)
|
||||
const d = getOrCreateDir(directory)
|
||||
const k = key(normalizedPayload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
const i = d.coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload: normalizedPayload }
|
||||
if (normalizedPayload.type === "message.part.delta") {
|
||||
// Accumulate delta strings — append to the already-queued event
|
||||
// rather than replacing it. The reducer is a pure string append so
|
||||
// this is semantically identical to applying each delta separately.
|
||||
const prev = d.queue[i] as unknown as { properties: { delta: string } }
|
||||
const inc = normalizedPayload.properties as { delta: string }
|
||||
d.queue[i] = {
|
||||
...normalizedPayload,
|
||||
properties: {
|
||||
...(normalizedPayload.properties as object),
|
||||
delta: prev.properties.delta + inc.delta,
|
||||
},
|
||||
} as unknown as Event
|
||||
} else {
|
||||
d.queue[i] = normalizedPayload
|
||||
}
|
||||
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
d.coalesced.set(k, d.queue.length)
|
||||
}
|
||||
queue.push({ directory, payload: normalizedPayload })
|
||||
schedule()
|
||||
d.queue.push(normalizedPayload)
|
||||
scheduleDir(directory)
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
@@ -232,7 +288,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
if (abort.signal.aborted) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(flush)
|
||||
})().finally(flushAll)
|
||||
|
||||
// Visibility handler — abort SSE on heartbeat timeout so the loop reconnects.
|
||||
// The reconnect triggers onReconnect above, which lets consumers resync state.
|
||||
@@ -262,7 +318,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
window.removeEventListener("pageshow", onPageShow)
|
||||
}
|
||||
abort.abort()
|
||||
flush()
|
||||
flushAll()
|
||||
}
|
||||
|
||||
return { cleanup }
|
||||
|
||||
Reference in New Issue
Block a user