mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
perf(sessions): cap warm session runtimes to bound /sessions memory growth
The /sessions sidebar eagerly warmed a full runtime (AIChatManager + loaded chat history + an IndexedDB connection) for every visible session, so the page's mounted weight grew linearly (O(number of non-archived in-family sessions)) and never released as sessions accumulate over time. Cap the warm runtimes with an MRU (the active session + up to MAX_WARM_RUNTIMES) and dispose the rest, mirroring the existing preview-tab and editor MRU caps. Sessions holding volatile state — a mid-stream generation, a pending tool confirmation, or unsent composer text — are kept warm so nothing is cancelled or lost (protect-not-persist; the composer draft lives in AIChatInput local state and isn't persisted). Cold (evicted) sessions show the neutral status dot with no unread badge until reopened. Measured ~1.5-2x the transcript size per resident session; the change converts O(N) growth into O(1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
95031903eb
commit
086a2c3e4b
@@ -36,7 +36,6 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
getSessionChatStatus,
|
||||
removeSession,
|
||||
@@ -80,7 +79,9 @@
|
||||
// read manager.instructions directly (not the derived chat status)
|
||||
// so the draft cue still shows during streaming/needs-confirmation —
|
||||
// those override the icon slot but shouldn't hide the fact that the
|
||||
// user has unsent text in this session.
|
||||
// user has unsent text in this session. A cold (evicted) session has
|
||||
// no live runtime, so no cue — but a session with an unsent draft is
|
||||
// kept warm (see evictColdRuntimes), so this stays accurate.
|
||||
function hasDraft(session: Session): boolean {
|
||||
const rt = getRuntime(session.id)
|
||||
return !!rt && rt.manager.instructions.trim().length > 0
|
||||
@@ -269,17 +270,12 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Eagerly create a runtime per VISIBLE session so the status dot reflects
|
||||
// the persisted chat (last message, pending confirmation, etc.) without
|
||||
// requiring the user to open the session first. Sessions outside the
|
||||
// current workspace scope are left cold to avoid opening IDB connections
|
||||
// for unrelated work.
|
||||
$effect(() => {
|
||||
for (const session of visibleSessions) {
|
||||
getOrCreateRuntime(session)
|
||||
}
|
||||
})
|
||||
|
||||
// The picker no longer eagerly warms a runtime per visible session — that made
|
||||
// mounted weight grow unbounded with the family size. Runtimes are created on
|
||||
// activation and MRU-capped (see sessionRuntime), so a cold session shows the
|
||||
// neutral status dot and no unread badge until it's opened; sessions with
|
||||
// volatile state (pending confirmation / unsent draft / streaming) are kept
|
||||
// warm so their live dot stays correct.
|
||||
function isUnavailableFork(session: Session): boolean {
|
||||
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,12 @@
|
||||
syncWorkspaceTo,
|
||||
type SessionTarget
|
||||
} from './sessionState.svelte'
|
||||
import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
|
||||
import {
|
||||
editorWarmIds,
|
||||
getOrCreateRuntime,
|
||||
removeSession,
|
||||
setSessionDraftFlag
|
||||
} from './sessionRuntime.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { slide } from 'svelte/transition'
|
||||
@@ -533,7 +538,13 @@
|
||||
hideModeSelector
|
||||
wideLayout
|
||||
initialInstructions={restoredDraftPrompt}
|
||||
onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)}
|
||||
onDraftChange={(text) => {
|
||||
queueTransientDraftPrompt(sessionId, text)
|
||||
// Keep the runtime warm while the composer holds unsent text — the
|
||||
// draft is local to AIChatInput and isn't persisted, so eviction
|
||||
// would lose it (see setSessionDraftFlag).
|
||||
setSessionDraftFlag(sessionId, text.trim().length > 0)
|
||||
}}
|
||||
forceDisabled={isUnavailable || !!session.archived}
|
||||
forceDisabledMessage={isUnavailable
|
||||
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
|
||||
|
||||
@@ -738,6 +738,8 @@ export function disposeRuntime(sessionId: string) {
|
||||
runtime.manager.cancel('runtime disposed')
|
||||
runtime.manager.historyManager.close()
|
||||
runtimes.delete(sessionId)
|
||||
runtimeWarmIds.delete(sessionId)
|
||||
sessionsWithDraft.delete(sessionId)
|
||||
}
|
||||
|
||||
export function listRuntimes(): SessionRuntime[] {
|
||||
@@ -800,6 +802,63 @@ export function promoteEditorWarm(sessionId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// MRU of session ids whose runtime should stay warm. A full SessionRuntime
|
||||
// (AIChatManager + loaded history + IDB connection) is ~tens of MB, so warming
|
||||
// one per visible session made the page scale linearly with the family size.
|
||||
// We keep the active session + this MRU + any session with an in-flight stream
|
||||
// warm (see evictColdRuntimes) and dispose the rest; a cold session re-warms on
|
||||
// activation from its persisted chat + snapshot.
|
||||
const MAX_WARM_RUNTIMES = 3
|
||||
const runtimeWarmIds = new SvelteSet<string>()
|
||||
|
||||
// Sessions whose composer holds unsent text. The draft lives in AIChatInput's
|
||||
// local state (not manager.instructions — that only carries programmatic
|
||||
// prompts), and it isn't persisted, so a session with a draft must be kept warm
|
||||
// or the text is lost. SessionWrapper reports changes via setSessionDraftFlag as
|
||||
// the user types; read at eviction time (see hasVolatileState).
|
||||
const sessionsWithDraft = new Set<string>()
|
||||
|
||||
export function setSessionDraftFlag(sessionId: string, hasDraft: boolean): void {
|
||||
if (hasDraft) sessionsWithDraft.add(sessionId)
|
||||
else sessionsWithDraft.delete(sessionId)
|
||||
}
|
||||
|
||||
export function promoteRuntimeWarm(sessionId: string): void {
|
||||
runtimeWarmIds.delete(sessionId)
|
||||
runtimeWarmIds.add(sessionId)
|
||||
while (runtimeWarmIds.size > MAX_WARM_RUNTIMES) {
|
||||
const oldest = runtimeWarmIds.values().next().value
|
||||
if (oldest === undefined) break
|
||||
runtimeWarmIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// Live state that would be lost by eviction, so the runtime must stay warm even
|
||||
// when it falls out of the MRU. We don't persist these (that machinery isn't
|
||||
// worth its complexity — see the perf memo), so keeping the session warm is how
|
||||
// its picker dot stays right and its unsent draft survives:
|
||||
// - a mid-stream generation (disposeRuntime cancels it),
|
||||
// - a pending tool confirmation (the amber "needs you" dot is read live),
|
||||
// - unsent composer text (there is no persisted copy to restore from).
|
||||
function hasVolatileState(runtime: SessionRuntime): boolean {
|
||||
const m = runtime.manager
|
||||
if (m.loading) return true
|
||||
if (sessionsWithDraft.has(runtime.sessionId)) return true
|
||||
const last = m.displayMessages[m.displayMessages.length - 1]
|
||||
return last?.role === 'tool' && !!last.needsConfirmation
|
||||
}
|
||||
|
||||
// Dispose every warm runtime that isn't the active session, in the warm MRU, or
|
||||
// holding volatile state. Idle sessions that fall out of the MRU go cold; their
|
||||
// picker dot reverts to the neutral icon until reopened.
|
||||
export function evictColdRuntimes(activeId: string | undefined): void {
|
||||
for (const runtime of runtimes.values()) {
|
||||
const id = runtime.sessionId
|
||||
if (id === activeId || runtimeWarmIds.has(id) || hasVolatileState(runtime)) continue
|
||||
disposeRuntime(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the global open_preview tool handler once at module load. It
|
||||
// dispatches to the *calling* session (sessionId threaded from the tool ctx),
|
||||
// falling back to the UI-active session only when none was passed — so a
|
||||
|
||||
@@ -18,7 +18,8 @@ export function markSessionSeen(sessionId: string, count: number) {
|
||||
}
|
||||
|
||||
// Number of unread messages for a session. Undefined / unloaded runtime
|
||||
// returns 0 — until messages are hydrated we don't know what's new.
|
||||
// returns 0 — a cold (evicted / not-yet-warmed) session has no live message
|
||||
// count to compare against, so its badge only appears once it's warmed.
|
||||
export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number {
|
||||
if (!runtime) return 0
|
||||
const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
evictColdRuntimes,
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
listRuntimes,
|
||||
promoteEditorWarm
|
||||
promoteEditorWarm,
|
||||
promoteRuntimeWarm
|
||||
} from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
@@ -119,19 +121,32 @@
|
||||
// deep links the same way they react to picker clicks.
|
||||
selectSession(session.id)
|
||||
getOrCreateRuntime(session)
|
||||
// Mark this session most-recently-used so the eviction pass keeps it (and
|
||||
// the last few) warm while disposing the rest.
|
||||
promoteRuntimeWarm(session.id)
|
||||
})
|
||||
})
|
||||
|
||||
// Warm = sessions with a live runtime. The picker eagerly creates runtimes
|
||||
// for its visible sessions, so this tracks whatever it shows. Keeping warm
|
||||
// chats mounted (stacked, visibility-toggled) preserves their scroll/draft
|
||||
// state across switches.
|
||||
// Warm = sessions with a live runtime (active + MRU + any mid-stream session —
|
||||
// see the eviction effect). Keeping warm chats mounted (stacked, visibility-
|
||||
// toggled) preserves their scroll/draft state across switches, and capping the
|
||||
// set bounds the page's mounted weight regardless of family size.
|
||||
const warmSessions = $derived(
|
||||
listRuntimes()
|
||||
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
|
||||
.filter((s): s is NonNullable<typeof s> => s != null)
|
||||
)
|
||||
|
||||
// Dispose runtimes that fell out of the warm set. Re-runs when the active
|
||||
// session changes, and — by reading each runtime's `loading` — when a
|
||||
// background stream finishes, so a session kept warm only because it was
|
||||
// streaming gets evicted once it settles (unless it's back in the MRU).
|
||||
$effect(() => {
|
||||
const activeId = activeSession?.id
|
||||
for (const rt of listRuntimes()) void rt.manager.loading
|
||||
untrack(() => evictColdRuntimes(activeId))
|
||||
})
|
||||
|
||||
// Promote the active session in the LRU. Mutations untracked so the effect
|
||||
// only re-runs when activeSession changes, not on its own writes.
|
||||
$effect(() => {
|
||||
|
||||
Reference in New Issue
Block a user