From ebffb9c80eecbc984417a5a5e9a47aec8e4ae610 Mon Sep 17 00:00:00 2001 From: phyce Date: Mon, 6 Jul 2026 10:51:37 +0000 Subject: [PATCH] Fix ActivityLog perf: incremental append + cap in-memory entries mergeEntries rebuilt a Map and full-sorted entries on every appended event (O(n log n) per event, thrashes under log bursts). Append live entries in order with an id de-dupe instead, keeping the full merge only for bulk load-older/initial paths. Cap in-memory entries so a long session no longer grows unboundedly (older entries remain in the backing .jsonl and reload on demand). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/ActivityLog.vue | 34 ++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ActivityLog.vue b/frontend/src/components/ActivityLog.vue index c1bc44f..c322878 100644 --- a/frontend/src/components/ActivityLog.vue +++ b/frontend/src/components/ActivityLog.vue @@ -23,6 +23,10 @@ interface Chain { const severities = ["error", "warning", "success", "info", "status"]; const olderPageSize = 200; +// Cap the number of live entries kept in memory. Older entries remain in the +// backing .jsonl and are re-fetched on demand via loadOlder, so trimming the +// in-memory array keeps a long-running session from growing unboundedly. +const MAX_ENTRIES = 5000; const entries = ref([]); const severityFilters = reactive>({ @@ -38,6 +42,8 @@ const expandedChains = reactive>(new Set()); const hasMore = ref(true); const loadingOlder = ref(false); +// Bulk merge for initial-load and load-older paths: rebuilds the id-keyed set +// and re-sorts. Used only when entries arrive out of order or in a batch. function mergeEntries(incoming: Entry[]) { const byID = new Map(); for (const entry of entries.value) { @@ -49,11 +55,37 @@ function mergeEntries(incoming: Entry[]) { entries.value = Array.from(byID.values()).sort((a, b) => a.id - b.id); } +// Single live event: backend ids are monotonically increasing and arrive in +// order, so we append in place (with an id de-dupe) instead of a full +// merge+sort. This is O(1) amortised versus O(n log n) per event, which +// matters under a burst of log events. +function appendEntry(entry: Entry) { + const current = entries.value; + const last = current[current.length - 1]; + if (last) { + if (entry.id === last.id) { + return; // duplicate of the newest entry + } + if (entry.id < last.id) { + // Out-of-order arrival; fall back to the sorted merge (also de-dupes). + mergeEntries([entry]); + return; + } + } + current.push(entry); + // Trim the oldest live entries once over the cap. loadOlder cursors off + // entries.value[0].id, so dropping from the front keeps that consistent — + // the trimmed entries are still reachable via "load older". + if (current.length > MAX_ENTRIES) { + current.splice(0, current.length - MAX_ENTRIES); + } +} + function onAppended(entry: Entry) { if (!entry || typeof entry.id !== "number") { return; } - mergeEntries([entry]); + appendEntry(entry); } function onFocusChain(chainId: number) {