perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -1,17 +1,30 @@
|
||||
/**
|
||||
* Persisted child-store metadata caches.
|
||||
*
|
||||
* VCS info, project metadata, and icons are cached to localStorage
|
||||
* per directory so they survive page reloads.
|
||||
* Only metadata is persisted — session/message/part data is always fresh
|
||||
* from the server via SSE bootstrap.
|
||||
* VCS info, project metadata, icons, and a bounded session-list snapshot are
|
||||
* cached to localStorage per runtime and directory so they survive reloads.
|
||||
* Message/part data is always loaded from the server.
|
||||
*/
|
||||
|
||||
import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProjectMeta } from "./types"
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
|
||||
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
|
||||
|
||||
/** Cap persisted session lists so localStorage stays bounded per directory. */
|
||||
const PERSISTED_SESSION_LIMIT = 50
|
||||
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
|
||||
const SESSION_PERSIST_DEBOUNCE_MS = 50
|
||||
|
||||
type PendingSessionWrite = {
|
||||
runtimeKey: string
|
||||
key: string
|
||||
legacyKey: string
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
const pendingSessionWrites = new Map<string, PendingSessionWrite>()
|
||||
let pendingSessionWriteTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage key generation
|
||||
@@ -27,11 +40,20 @@ function hashCode(str: string): string {
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
function storagePrefix(directory: string): string {
|
||||
function legacyStoragePrefix(directory: string): string {
|
||||
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
|
||||
return `oc.dir.${head}.${hashCode(directory)}`
|
||||
}
|
||||
|
||||
function storagePrefix(directory: string): string {
|
||||
return storagePrefixForRuntime(getRuntimeKey() || "local", directory)
|
||||
}
|
||||
|
||||
function storagePrefixForRuntime(runtimeKey: string, directory: string): string {
|
||||
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
|
||||
return `oc.dir.v2.${head}.${hashCode(`${runtimeKey}\0${directory}`)}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed cache helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -42,9 +64,19 @@ function cacheKey(directory: string, key: CacheKey): string {
|
||||
return `${storagePrefix(directory)}.${key}`
|
||||
}
|
||||
|
||||
function legacyCacheKey(directory: string, key: CacheKey): string {
|
||||
return `${legacyStoragePrefix(directory)}.${key}`
|
||||
}
|
||||
|
||||
function readCache<T>(directory: string, key: CacheKey): T | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(cacheKey(directory, key))
|
||||
const currentKey = cacheKey(directory, key)
|
||||
if (key === "sessions") {
|
||||
const pending = pendingSessionWrites.get(currentKey)
|
||||
if (pending) return pending.sessions as T
|
||||
}
|
||||
const raw = localStorage.getItem(currentKey)
|
||||
?? localStorage.getItem(legacyCacheKey(directory, key))
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
@@ -54,17 +86,121 @@ function readCache<T>(directory: string, key: CacheKey): T | undefined {
|
||||
|
||||
function writeCache<T>(directory: string, key: CacheKey, value: T | undefined): void {
|
||||
try {
|
||||
const k = cacheKey(directory, key)
|
||||
const currentKey = cacheKey(directory, key)
|
||||
if (value === undefined) {
|
||||
localStorage.removeItem(k)
|
||||
localStorage.removeItem(currentKey)
|
||||
localStorage.removeItem(legacyCacheKey(directory, key))
|
||||
} else {
|
||||
localStorage.setItem(k, JSON.stringify(value))
|
||||
localStorage.setItem(currentKey, JSON.stringify(value))
|
||||
localStorage.removeItem(legacyCacheKey(directory, key))
|
||||
}
|
||||
} catch {
|
||||
// localStorage quota exceeded — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRecencyTimestamp(session: Session): number {
|
||||
const updated = session.time?.updated
|
||||
if (typeof updated === "number" && Number.isFinite(updated)) return updated
|
||||
const created = session.time?.created
|
||||
return typeof created === "number" && Number.isFinite(created) ? created : 0
|
||||
}
|
||||
|
||||
function selectRecentSessions(sessions: Session[], limit: number): Session[] {
|
||||
if (sessions.length <= limit) return sessions
|
||||
const recentIds = new Set(
|
||||
[...sessions]
|
||||
.sort((left, right) => sessionRecencyTimestamp(right) - sessionRecencyTimestamp(left) || right.id.localeCompare(left.id))
|
||||
.slice(0, limit)
|
||||
.map((session) => session.id),
|
||||
)
|
||||
return sessions.filter((session) => recentIds.has(session.id))
|
||||
}
|
||||
|
||||
function tryWriteCacheValue<T>(key: string, legacyKey: string, value: T): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(value)
|
||||
countSyncPersistenceSerialization(serialized)
|
||||
countSyncPersistenceStorageWrite()
|
||||
localStorage.setItem(key, serialized)
|
||||
localStorage.removeItem(legacyKey)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionCache(key: string, legacyKey: string, sessions: Session[]): void {
|
||||
const recentSessions = selectRecentSessions(sessions, PERSISTED_SESSION_LIMIT)
|
||||
if (tryWriteCacheValue(key, legacyKey, recentSessions)) return
|
||||
|
||||
// Replacing a stale value can fail when unrelated localStorage data has
|
||||
// grown. Remove that value and retain as much recent history as still fits.
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
localStorage.removeItem(legacyKey)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const limit of SESSION_CACHE_FALLBACK_LIMITS) {
|
||||
const candidate = selectRecentSessions(recentSessions, limit)
|
||||
if (tryWriteCacheValue(key, legacyKey, candidate)) return
|
||||
}
|
||||
|
||||
// An empty v2 value is a tombstone: never resurrect stale legacy sessions.
|
||||
tryWriteCacheValue(key, legacyKey, [])
|
||||
}
|
||||
|
||||
function flushPendingSessionWrites(): void {
|
||||
if (pendingSessionWriteTimer !== undefined) {
|
||||
clearTimeout(pendingSessionWriteTimer)
|
||||
pendingSessionWriteTimer = undefined
|
||||
}
|
||||
if (pendingSessionWrites.size === 0) return
|
||||
const writes = [...pendingSessionWrites.values()]
|
||||
pendingSessionWrites.clear()
|
||||
const currentRuntimeKey = getRuntimeKey() || "local"
|
||||
for (const pending of writes) {
|
||||
if (pending.runtimeKey !== currentRuntimeKey) continue
|
||||
writeSessionCache(pending.key, pending.legacyKey, pending.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSessionCacheWrite(directory: string, sessions: Session[]): void {
|
||||
const runtimeKey = getRuntimeKey() || "local"
|
||||
const key = `${storagePrefixForRuntime(runtimeKey, directory)}.sessions`
|
||||
for (const [pendingKey, pending] of pendingSessionWrites) {
|
||||
if (pending.runtimeKey !== runtimeKey) pendingSessionWrites.delete(pendingKey)
|
||||
}
|
||||
pendingSessionWrites.set(key, { runtimeKey, key, legacyKey: legacyCacheKey(directory, "sessions"), sessions })
|
||||
if (pendingSessionWriteTimer !== undefined) return
|
||||
pendingSessionWriteTimer = setTimeout(flushPendingSessionWrites, SESSION_PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function cancelPendingSessionWrites(runtimeKey: string): void {
|
||||
for (const [key, pending] of pendingSessionWrites) {
|
||||
if (pending.runtimeKey === runtimeKey) pendingSessionWrites.delete(key)
|
||||
}
|
||||
if (pendingSessionWrites.size === 0 && pendingSessionWriteTimer !== undefined) {
|
||||
clearTimeout(pendingSessionWriteTimer)
|
||||
pendingSessionWriteTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
subscribeRuntimeEndpointWillChange(({ previousRuntimeKey }) => cancelPendingSessionWrites(previousRuntimeKey))
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", flushPendingSessionWrites, { capture: true })
|
||||
window.addEventListener("beforeunload", flushPendingSessionWrites, { capture: true })
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushPendingSessionWrites()
|
||||
})
|
||||
document.addEventListener("freeze", flushPendingSessionWrites)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,15 +227,18 @@ export function readDirCache(directory: string): PersistedDirCache {
|
||||
* can paint chats instantly on cold start. Refreshed by bootstrap loadSessions.
|
||||
*/
|
||||
export function persistSessions(directory: string, sessions: Session[] | undefined): void {
|
||||
if (!sessions || sessions.length === 0) {
|
||||
const key = cacheKey(directory, "sessions")
|
||||
if (!sessions) {
|
||||
pendingSessionWrites.delete(key)
|
||||
writeCache(directory, "sessions", undefined)
|
||||
return
|
||||
}
|
||||
// Keep the most recent N by id (ids are time-ordered hex) to bound storage.
|
||||
const capped = sessions.length > PERSISTED_SESSION_LIMIT
|
||||
? [...sessions].sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, PERSISTED_SESSION_LIMIT)
|
||||
: sessions
|
||||
writeCache(directory, "sessions", capped)
|
||||
if (sessions.length === 0) {
|
||||
pendingSessionWrites.delete(key)
|
||||
writeSessionCache(key, legacyCacheKey(directory, "sessions"), sessions)
|
||||
return
|
||||
}
|
||||
scheduleSessionCacheWrite(directory, sessions)
|
||||
}
|
||||
|
||||
/** Write vcs info to cache */
|
||||
|
||||
Reference in New Issue
Block a user