diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 533975f1494..ad3726af098 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -297,6 +297,45 @@ export namespace Config { bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), }) .optional(), + memory: z + .object({ + maxSessions: z + .number() + .min(1) + .max(1000) + .optional() + .describe("Maximum number of sessions to keep in memory cache (default: 100)"), + maxMessagesPerSession: z + .number() + .min(10) + .max(10000) + .optional() + .describe("Maximum number of messages to cache per session (default: 1000)"), + sessionTtlMs: z + .number() + .min(60000) // 1 minute minimum + .max(7 * 24 * 60 * 60 * 1000) // 1 week maximum + .optional() + .describe("Session time-to-live in milliseconds (default: 24 hours)"), + inactiveTtlMs: z + .number() + .min(60000) // 1 minute minimum + .max(24 * 60 * 60 * 1000) // 1 day maximum + .optional() + .describe("Inactive session time-to-live in milliseconds (default: 4 hours)"), + cleanupIntervalMs: z + .number() + .min(30000) // 30 seconds minimum + .max(30 * 60 * 1000) // 30 minutes maximum + .optional() + .describe("Memory cleanup interval in milliseconds (default: 5 minutes)"), + enableLogging: z + .boolean() + .optional() + .describe("Enable detailed memory usage logging (default: false)"), + }) + .optional() + .describe("Memory management configuration for session caching"), experimental: z .object({ hook: z diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 401645fa001..1237538754e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -41,6 +41,7 @@ import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { mergeDeep, pipe, splitWhen } from "remeda" import { ToolRegistry } from "../tool/registry" +import { SessionMemoryManager } from "./memory-manager" export namespace Session { const log = Log.create({ service: "session" }) @@ -114,11 +115,32 @@ export namespace Session { ), } + // Initialize memory manager with configuration from config file + const memoryManager = (async () => { + const config = await Config.get() + const memoryConfig = config.memory || {} + + return new SessionMemoryManager.SessionCache({ + maxSessions: memoryConfig.maxSessions, + maxMessagesPerSession: memoryConfig.maxMessagesPerSession, + sessionTtlMs: memoryConfig.sessionTtlMs, + inactiveTtlMs: memoryConfig.inactiveTtlMs, + cleanupIntervalMs: memoryConfig.cleanupIntervalMs, + }) + })() + + // Helper to get the initialized memory manager + async function getMemoryManager() { + return await memoryManager + } + const state = App.state( "session", - () => { - const sessions = new Map() - const messages = new Map() + async () => { + // Initialize memory manager from config + const manager = await getMemoryManager() + + // Legacy maps for backward compatibility and non-cached data const pending = new Map() const queued = new Map< string, @@ -132,19 +154,83 @@ export namespace Session { >() return { - sessions, - messages, + // Use memory manager for sessions and messages + get sessions() { + // Return a Map-like interface for backward compatibility + return { + get: (id: string) => manager.getSession(id), + set: (id: string, value: Info) => manager.setSession(id, value), + delete: (id: string) => manager.deleteSession(id), + has: (id: string) => manager.hasSession(id), + keys: () => manager.getSessionKeys(), + size: manager.size().sessions, + } + }, + get messages() { + return { + get: (id: string) => manager.getMessages(id), + set: (id: string, value: MessageV2.Info[]) => manager.setMessages(id, value), + delete: (id: string) => manager.deleteMessages(id), + has: (id: string) => manager.getMessages(id) !== undefined, + } + }, pending, queued, + // Add memory management utilities + memoryManager: manager, } }, async (state) => { + // Cleanup on shutdown for (const [_, controller] of state.pending) { controller.abort() } + + // Log memory statistics before shutdown + const stats = state.memoryManager.getStats() + log.info("session cleanup on shutdown", { + sessionsInMemory: stats.sessions.size, + messagesInMemory: stats.messages.size, + sessionCacheHits: stats.sessions.hits, + sessionCacheMisses: stats.sessions.misses, + evictions: stats.sessions.evictions, + }) + + // Destroy memory manager + state.memoryManager.destroy() }, ) + // Memory monitoring and management functions + export function getMemoryStats() { + return state().memoryManager.getStats() + } + + export function isUnderMemoryPressure(): boolean { + return state().memoryManager.isUnderMemoryPressure() + } + + export function forceMemoryCleanup(): void { + log.info("forcing memory cleanup due to memory pressure") + state().memoryManager.forceCleanup() + } + + // Helper function to log memory usage periodically + function logMemoryUsage(context: string): void { + const stats = getMemoryStats() + if (stats.totalMemoryUsage > 0.7 || stats.sessions.size > 50) { // Log when getting full + log.info("memory usage", { + context, + sessions: stats.sessions.size, + messages: stats.messages.size, + memoryPressure: (stats.totalMemoryUsage * 100).toFixed(1) + "%", + cacheHitRate: stats.sessions.hits > 0 ? + ((stats.sessions.hits / (stats.sessions.hits + stats.sessions.misses)) * 100).toFixed(1) + "%" : "0%", + evictions: stats.sessions.evictions, + }) + } + } + export async function create(parentID?: string) { const result: Info = { id: Identifier.descending("session"), @@ -157,8 +243,18 @@ export namespace Session { }, } log.info("created", result) + + // Check memory pressure before adding new session + if (isUnderMemoryPressure()) { + log.warn("memory pressure detected during session creation, forcing cleanup") + forceMemoryCleanup() + } + state().sessions.set(result.id, result) await Storage.writeJSON("session/info/" + result.id, result) + + // Log memory usage after session creation + logMemoryUsage("session-created") const cfg = await Config.get() if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) share(result.id) @@ -177,13 +273,36 @@ export namespace Session { } export async function get(id: string) { - const result = state().sessions.get(id) - if (result) { - return result + // Try to get from memory cache first + const cached = state().sessions.get(id) + if (cached) { + return cached + } + + // Load from storage if not in cache + try { + const read = await Storage.readJSON("session/info/" + id) + + // Check memory pressure before caching + if (isUnderMemoryPressure()) { + log.debug("memory pressure detected during session load, forcing cleanup") + forceMemoryCleanup() + } + + // Cache the loaded session + state().sessions.set(id, read) + + // Log memory usage occasionally + const stats = getMemoryStats() + if (stats.sessions.size % 10 === 0) { // Log every 10th session load + logMemoryUsage("session-loaded") + } + + return read as Info + } catch (error) { + log.error("failed to load session from storage", { sessionId: id, error }) + throw error } - const read = await Storage.readJSON("session/info/" + id) - state().sessions.set(id, read) - return read as Info } export async function getShare(id: string) { @@ -305,21 +424,36 @@ export namespace Session { try { abort(sessionID) const session = await get(sessionID) + + // Remove child sessions recursively for (const child of await children(sessionID)) { await remove(child.id, false) } + + // Clean up external resources await unshare(sessionID).catch(() => {}) await Storage.remove(`session/info/${sessionID}`).catch(() => {}) await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {}) - state().sessions.delete(sessionID) - state().messages.delete(sessionID) + + // Remove from memory caches + const sessionDeleted = state().sessions.delete(sessionID) + const messagesDeleted = state().messages.delete(sessionID) + + log.info("session removed", { + sessionID, + fromMemory: { session: sessionDeleted, messages: messagesDeleted } + }) + + // Log memory usage after cleanup + logMemoryUsage("session-removed") + if (emitEvent) { Bus.publish(Event.Deleted, { info: session, }) } } catch (e) { - log.error(e) + log.error("failed to remove session", { sessionID, error: e }) } } @@ -378,6 +512,18 @@ export namespace Session { ): Promise<{ info: MessageV2.Assistant; parts: MessageV2.Part[] }> { const l = log.clone().tag("session", input.sessionID) l.info("chatting") + + // Check memory pressure before processing chat + if (isUnderMemoryPressure()) { + l.warn("memory pressure detected before chat processing, forcing cleanup") + forceMemoryCleanup() + } + + // Log memory stats for long conversations + const stats = getMemoryStats() + if (stats.sessions.size > 20) { + logMemoryUsage("chat-start") + } const inputMode = input.mode ?? "build" @@ -874,6 +1020,17 @@ export namespace Session { item.callback(result) } state().queued.delete(input.sessionID) + + // Log memory usage after chat processing + logMemoryUsage("chat-completed") + + // Force cleanup if memory pressure is high after processing + if (isUnderMemoryPressure()) { + l.info("memory pressure high after chat processing, scheduling cleanup") + // Use setTimeout to avoid blocking the response + setTimeout(() => forceMemoryCleanup(), 100) + } + return result } diff --git a/packages/opencode/src/session/memory-manager.ts b/packages/opencode/src/session/memory-manager.ts new file mode 100644 index 00000000000..5a7b4a7d667 --- /dev/null +++ b/packages/opencode/src/session/memory-manager.ts @@ -0,0 +1,387 @@ +import { Log } from "../util/log" + +export namespace SessionMemoryManager { + const log = Log.create({ service: "session-memory-manager" }) + + // Configuration constants + const DEFAULT_MAX_SESSIONS = 100 + const DEFAULT_MAX_MESSAGES_PER_SESSION = 1000 + const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours + const DEFAULT_INACTIVE_TTL_MS = 4 * 60 * 60 * 1000 // 4 hours + const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes + const MEMORY_PRESSURE_THRESHOLD = 0.8 // 80% of max sessions + + interface SessionCacheEntry { + value: T + lastAccessed: number + created: number + accessCount: number + } + + interface CacheConfig { + maxSize: number + ttlMs: number + inactiveTtlMs: number + } + + interface CacheStats { + size: number + hits: number + misses: number + evictions: number + memoryPressure: number + } + + export class LRUCache { + private cache = new Map>() + private accessOrder: string[] = [] + private stats: CacheStats = { + size: 0, + hits: 0, + misses: 0, + evictions: 0, + memoryPressure: 0, + } + + constructor(private config: CacheConfig) {} + + get(key: string): T | undefined { + const entry = this.cache.get(key) + if (!entry) { + this.stats.misses++ + return undefined + } + + // Check TTL expiration + const now = Date.now() + const age = now - entry.created + const inactiveTime = now - entry.lastAccessed + + if (age > this.config.ttlMs || inactiveTime > this.config.inactiveTtlMs) { + this.delete(key) + this.stats.misses++ + return undefined + } + + // Update access tracking + entry.lastAccessed = now + entry.accessCount++ + this.updateAccessOrder(key) + this.stats.hits++ + + return entry.value + } + + set(key: string, value: T): void { + const now = Date.now() + + // If key exists, update it + if (this.cache.has(key)) { + const entry = this.cache.get(key)! + entry.value = value + entry.lastAccessed = now + entry.accessCount++ + this.updateAccessOrder(key) + return + } + + // Check if we need to evict + this.evictIfNeeded() + + // Add new entry + const entry: SessionCacheEntry = { + value, + lastAccessed: now, + created: now, + accessCount: 1, + } + + this.cache.set(key, entry) + this.accessOrder.push(key) + this.stats.size = this.cache.size + this.stats.memoryPressure = this.cache.size / this.config.maxSize + } + + delete(key: string): boolean { + const deleted = this.cache.delete(key) + if (deleted) { + this.accessOrder = this.accessOrder.filter(k => k !== key) + this.stats.size = this.cache.size + this.stats.memoryPressure = this.cache.size / this.config.maxSize + } + return deleted + } + + has(key: string): boolean { + const entry = this.cache.get(key) + if (!entry) return false + + // Check TTL expiration + const now = Date.now() + const age = now - entry.created + const inactiveTime = now - entry.lastAccessed + + if (age > this.config.ttlMs || inactiveTime > this.config.inactiveTtlMs) { + this.delete(key) + return false + } + + return true + } + + clear(): void { + this.cache.clear() + this.accessOrder = [] + this.stats.size = 0 + this.stats.memoryPressure = 0 + } + + size(): number { + return this.cache.size + } + + keys(): IterableIterator { + return this.cache.keys() + } + + getStats(): CacheStats { + return { ...this.stats } + } + + private updateAccessOrder(key: string): void { + // Move to end (most recently used) + this.accessOrder = this.accessOrder.filter(k => k !== key) + this.accessOrder.push(key) + } + + private evictIfNeeded(): void { + // Evict expired entries first + this.evictExpired() + + // If still over limit, evict LRU entries + while (this.cache.size >= this.config.maxSize) { + this.evictLRU() + } + + // Under memory pressure, be more aggressive + if (this.stats.memoryPressure > MEMORY_PRESSURE_THRESHOLD) { + this.evictByMemoryPressure() + } + } + + private evictExpired(): void { + const now = Date.now() + const expiredKeys: string[] = [] + + for (const [key, entry] of this.cache.entries()) { + const age = now - entry.created + const inactiveTime = now - entry.lastAccessed + + if (age > this.config.ttlMs || inactiveTime > this.config.inactiveTtlMs) { + expiredKeys.push(key) + } + } + + for (const key of expiredKeys) { + this.delete(key) + this.stats.evictions++ + log.debug("evicted expired session", { key, reason: "ttl" }) + } + } + + private evictLRU(): void { + if (this.accessOrder.length === 0) return + + const lruKey = this.accessOrder[0] + this.delete(lruKey) + this.stats.evictions++ + log.debug("evicted session", { key: lruKey, reason: "lru" }) + } + + private evictByMemoryPressure(): void { + // Under memory pressure, evict sessions with low access count + const entries = Array.from(this.cache.entries()) + .sort(([, a], [, b]) => a.accessCount - b.accessCount) + + const toEvict = Math.ceil(this.cache.size * 0.1) // Evict 10% + + for (let i = 0; i < toEvict && i < entries.length; i++) { + const [key] = entries[i] + this.delete(key) + this.stats.evictions++ + log.debug("evicted session", { key, reason: "memory-pressure" }) + } + } + } + + export class SessionCache { + private sessionCache: LRUCache + private messageCache: LRUCache + private cleanupInterval: NodeJS.Timeout | undefined + + constructor(config?: Partial<{ + maxSessions: number + maxMessagesPerSession: number + sessionTtlMs: number + inactiveTtlMs: number + cleanupIntervalMs: number + }>) { + const sessionConfig: CacheConfig = { + maxSize: config?.maxSessions ?? DEFAULT_MAX_SESSIONS, + ttlMs: config?.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS, + inactiveTtlMs: config?.inactiveTtlMs ?? DEFAULT_INACTIVE_TTL_MS, + } + + const messageConfig: CacheConfig = { + maxSize: config?.maxMessagesPerSession ?? DEFAULT_MAX_MESSAGES_PER_SESSION, + ttlMs: config?.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS, + inactiveTtlMs: config?.inactiveTtlMs ?? DEFAULT_INACTIVE_TTL_MS, + } + + this.sessionCache = new LRUCache(sessionConfig) + this.messageCache = new LRUCache(messageConfig) + + // Start background cleanup + this.startCleanup(config?.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS) + + log.info("initialized session memory manager", { + maxSessions: sessionConfig.maxSize, + maxMessages: messageConfig.maxSize, + sessionTtlMs: sessionConfig.ttlMs, + inactiveTtlMs: sessionConfig.inactiveTtlMs, + }) + } + + // Session cache methods + getSession(key: string): any | undefined { + return this.sessionCache.get(key) + } + + setSession(key: string, value: any): void { + this.sessionCache.set(key, value) + } + + deleteSession(key: string): boolean { + const deleted = this.sessionCache.delete(key) + if (deleted) { + // Also delete associated messages + this.messageCache.delete(key) + } + return deleted + } + + hasSession(key: string): boolean { + return this.sessionCache.has(key) + } + + // Message cache methods + getMessages(sessionId: string): any[] | undefined { + return this.messageCache.get(sessionId) + } + + setMessages(sessionId: string, messages: any[]): void { + this.messageCache.set(sessionId, messages) + } + + deleteMessages(sessionId: string): boolean { + return this.messageCache.delete(sessionId) + } + + // Utility methods + clear(): void { + this.sessionCache.clear() + this.messageCache.clear() + log.info("cleared all caches") + } + + getStats(): { + sessions: CacheStats + messages: CacheStats + totalMemoryUsage: number + } { + const sessionStats = this.sessionCache.getStats() + const messageStats = this.messageCache.getStats() + + return { + sessions: sessionStats, + messages: messageStats, + totalMemoryUsage: sessionStats.memoryPressure + messageStats.memoryPressure, + } + } + + getSessionKeys(): IterableIterator { + return this.sessionCache.keys() + } + + size(): { sessions: number; messages: number } { + return { + sessions: this.sessionCache.size(), + messages: this.messageCache.size(), + } + } + + // Memory pressure handling + isUnderMemoryPressure(): boolean { + const stats = this.getStats() + return stats.totalMemoryUsage > MEMORY_PRESSURE_THRESHOLD + } + + forceCleanup(): void { + log.info("forcing memory cleanup") + + // Force eviction of expired entries + const sessionKeys = Array.from(this.sessionCache.keys()) + const messageKeys = Array.from(this.messageCache.keys()) + + for (const key of sessionKeys) { + // Re-accessing will trigger TTL checks + this.sessionCache.get(key) + } + + for (const key of messageKeys) { + this.messageCache.get(key) + } + + const stats = this.getStats() + log.info("memory cleanup completed", { + sessionsRemaining: stats.sessions.size, + messagesRemaining: stats.messages.size, + memoryPressure: stats.totalMemoryUsage, + }) + } + + private startCleanup(intervalMs: number): void { + this.cleanupInterval = setInterval(() => { + this.forceCleanup() + }, intervalMs) + + log.debug("started background cleanup", { intervalMs }) + } + + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = undefined + } + this.clear() + log.info("destroyed session memory manager") + } + } + + // Singleton instance for global use + let globalCache: SessionCache | undefined + + export function getGlobalCache(): SessionCache { + if (!globalCache) { + globalCache = new SessionCache() + } + return globalCache + } + + export function destroyGlobalCache(): void { + if (globalCache) { + globalCache.destroy() + globalCache = undefined + } + } +} \ No newline at end of file diff --git a/packages/opencode/test/session/memory-manager.test.ts b/packages/opencode/test/session/memory-manager.test.ts new file mode 100644 index 00000000000..325b8fe965e --- /dev/null +++ b/packages/opencode/test/session/memory-manager.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { SessionMemoryManager } from "../../src/session/memory-manager" + +describe("SessionMemoryManager", () => { + describe("LRUCache", () => { + let cache: SessionMemoryManager.LRUCache + + beforeEach(() => { + cache = new SessionMemoryManager.LRUCache({ + maxSize: 3, + ttlMs: 1000, + inactiveTtlMs: 500, + }) + }) + + afterEach(() => { + cache.clear() + }) + + it("should store and retrieve values", () => { + cache.set("key1", "value1") + expect(cache.get("key1")).toBe("value1") + }) + + it("should return undefined for non-existent keys", () => { + expect(cache.get("nonexistent")).toBeUndefined() + }) + + it("should evict least recently used items when at capacity", () => { + cache.set("key1", "value1") + cache.set("key2", "value2") + cache.set("key3", "value3") + + // key1 should still exist + expect(cache.get("key1")).toBe("value1") + + // Adding key4 should evict key2 (least recently used) + cache.set("key4", "value4") + expect(cache.get("key2")).toBeUndefined() + expect(cache.get("key1")).toBe("value1") // Recently accessed + expect(cache.get("key3")).toBe("value3") + expect(cache.get("key4")).toBe("value4") + }) + + it("should evict expired items based on TTL", async () => { + cache.set("key1", "value1") + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 1100)) + + expect(cache.get("key1")).toBeUndefined() + }) + + it("should evict inactive items", async () => { + cache.set("key1", "value1") + + // Access the item + expect(cache.get("key1")).toBe("value1") + + // Wait for inactive expiration + await new Promise(resolve => setTimeout(resolve, 600)) + + expect(cache.get("key1")).toBeUndefined() + }) + + it("should track cache statistics", () => { + cache.set("key1", "value1") + cache.get("key1") // hit + cache.get("nonexistent") // miss + + const stats = cache.getStats() + expect(stats.hits).toBe(1) + expect(stats.misses).toBe(1) + expect(stats.size).toBe(1) + }) + + it("should handle has() with TTL expiration", async () => { + cache.set("key1", "value1") + expect(cache.has("key1")).toBe(true) + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 1100)) + + expect(cache.has("key1")).toBe(false) + }) + + it("should clear all items", () => { + cache.set("key1", "value1") + cache.set("key2", "value2") + + cache.clear() + + expect(cache.size()).toBe(0) + expect(cache.get("key1")).toBeUndefined() + expect(cache.get("key2")).toBeUndefined() + }) + + it("should update existing items without increasing size", () => { + cache.set("key1", "value1") + expect(cache.size()).toBe(1) + + cache.set("key1", "value2") + expect(cache.size()).toBe(1) + expect(cache.get("key1")).toBe("value2") + }) + + it("should handle memory pressure eviction", () => { + const config = { + maxSize: 5, + ttlMs: 10000, + inactiveTtlMs: 10000, + } + const pressureCache = new SessionMemoryManager.LRUCache(config) + + // Fill to capacity + for (let i = 0; i < 5; i++) { + pressureCache.set(`key${i}`, `value${i}`) + } + + // Access some items multiple times to increase access count + for (let i = 0; i < 3; i++) { + pressureCache.get("key4") + pressureCache.get("key3") + } + + // Adding more items should trigger memory pressure eviction + pressureCache.set("key5", "value5") + pressureCache.set("key6", "value6") + + // Items with lower access count should be evicted first + expect(pressureCache.get("key3")).toBeDefined() + expect(pressureCache.get("key4")).toBeDefined() + + pressureCache.clear() + }) + }) + + describe("SessionCache", () => { + let sessionCache: SessionMemoryManager.SessionCache + + beforeEach(() => { + sessionCache = new SessionMemoryManager.SessionCache({ + maxSessions: 5, + maxMessagesPerSession: 10, + sessionTtlMs: 1000, + inactiveTtlMs: 500, + cleanupIntervalMs: 100, + }) + }) + + afterEach(() => { + sessionCache.destroy() + }) + + it("should store and retrieve sessions", () => { + const session = { id: "session1", title: "Test Session" } + sessionCache.setSession("session1", session) + + expect(sessionCache.getSession("session1")).toEqual(session) + }) + + it("should store and retrieve messages", () => { + const messages = [ + { id: "msg1", content: "Hello" }, + { id: "msg2", content: "World" }, + ] + sessionCache.setMessages("session1", messages) + + expect(sessionCache.getMessages("session1")).toEqual(messages) + }) + + it("should delete sessions and associated messages", () => { + const session = { id: "session1", title: "Test Session" } + const messages = [{ id: "msg1", content: "Hello" }] + + sessionCache.setSession("session1", session) + sessionCache.setMessages("session1", messages) + + sessionCache.deleteSession("session1") + + expect(sessionCache.getSession("session1")).toBeUndefined() + expect(sessionCache.getMessages("session1")).toBeUndefined() + }) + + it("should report memory pressure correctly", () => { + // Fill cache to capacity + for (let i = 0; i < 4; i++) { + sessionCache.setSession(`session${i}`, { id: `session${i}` }) + } + + expect(sessionCache.isUnderMemoryPressure()).toBe(true) + }) + + it("should provide accurate statistics", () => { + sessionCache.setSession("session1", { id: "session1" }) + sessionCache.setMessages("session1", [{ id: "msg1" }]) + + // Access to generate stats + sessionCache.getSession("session1") + sessionCache.getSession("nonexistent") + + const stats = sessionCache.getStats() + expect(stats.sessions.size).toBe(1) + expect(stats.messages.size).toBe(1) + expect(stats.sessions.hits).toBeGreaterThan(0) + expect(stats.sessions.misses).toBeGreaterThan(0) + }) + + it("should force cleanup on demand", () => { + // Add expired sessions + sessionCache.setSession("session1", { id: "session1" }) + + // Force cleanup + sessionCache.forceCleanup() + + // Should still exist since not expired yet + expect(sessionCache.getSession("session1")).toBeDefined() + }) + + it("should handle size reporting", () => { + sessionCache.setSession("session1", { id: "session1" }) + sessionCache.setMessages("session1", [{ id: "msg1" }]) + + const size = sessionCache.size() + expect(size.sessions).toBe(1) + expect(size.messages).toBe(1) + }) + + it("should clear all caches", () => { + sessionCache.setSession("session1", { id: "session1" }) + sessionCache.setMessages("session1", [{ id: "msg1" }]) + + sessionCache.clear() + + const size = sessionCache.size() + expect(size.sessions).toBe(0) + expect(size.messages).toBe(0) + }) + + it("should handle background cleanup", async () => { + // The cleanup interval is set to 100ms in beforeEach + // This test verifies the cleanup timer is working + const initialStats = sessionCache.getStats() + + // Wait for at least one cleanup cycle + await new Promise(resolve => setTimeout(resolve, 150)) + + // The cleanup should have run (we can't easily test eviction without expired items) + // But we can verify the timer mechanism is working by checking the cache is still functional + sessionCache.setSession("test", { id: "test" }) + expect(sessionCache.getSession("test")).toBeDefined() + }) + }) + + describe("Global Cache Management", () => { + afterEach(() => { + SessionMemoryManager.destroyGlobalCache() + }) + + it("should provide a global cache instance", () => { + const cache1 = SessionMemoryManager.getGlobalCache() + const cache2 = SessionMemoryManager.getGlobalCache() + + expect(cache1).toBe(cache2) // Should be the same instance + }) + + it("should destroy global cache", () => { + const cache1 = SessionMemoryManager.getGlobalCache() + SessionMemoryManager.destroyGlobalCache() + + const cache2 = SessionMemoryManager.getGlobalCache() + expect(cache1).not.toBe(cache2) // Should be a new instance + }) + }) + + describe("Memory Pressure Scenarios", () => { + let cache: SessionMemoryManager.SessionCache + + beforeEach(() => { + cache = new SessionMemoryManager.SessionCache({ + maxSessions: 3, + maxMessagesPerSession: 5, + sessionTtlMs: 10000, // Long TTL to test capacity limits + inactiveTtlMs: 10000, + cleanupIntervalMs: 50, + }) + }) + + afterEach(() => { + cache.destroy() + }) + + it("should handle rapid session creation under memory pressure", () => { + // Rapidly create sessions beyond capacity + for (let i = 0; i < 10; i++) { + cache.setSession(`session${i}`, { id: `session${i}`, data: `data${i}` }) + } + + // Should only keep the most recent sessions due to LRU eviction + const size = cache.size() + expect(size.sessions).toBeLessThanOrEqual(3) + + // Most recent sessions should still be accessible + expect(cache.getSession("session9")).toBeDefined() + expect(cache.getSession("session8")).toBeDefined() + expect(cache.getSession("session7")).toBeDefined() + + // Older sessions should be evicted + expect(cache.getSession("session0")).toBeUndefined() + }) + + it("should maintain performance under memory pressure", () => { + const startTime = Date.now() + + // Perform many operations + for (let i = 0; i < 100; i++) { + cache.setSession(`session${i}`, { id: `session${i}` }) + cache.getSession(`session${i}`) + } + + const endTime = Date.now() + const duration = endTime - startTime + + // Should complete operations reasonably quickly (less than 1 second) + expect(duration).toBeLessThan(1000) + }) + }) + + describe("Error Handling", () => { + it("should handle invalid cache configurations gracefully", () => { + expect(() => { + new SessionMemoryManager.SessionCache({ + maxSessions: 0, // Invalid + maxMessagesPerSession: -1, // Invalid + }) + }).not.toThrow() + }) + + it("should handle null/undefined values", () => { + const cache = new SessionMemoryManager.SessionCache() + + // Should handle undefined values without throwing + expect(() => { + cache.setSession("test", undefined as any) + }).not.toThrow() + + expect(() => { + cache.setMessages("test", null as any) + }).not.toThrow() + }) + }) +}) \ No newline at end of file diff --git a/packages/opencode/test/session/session-memory-integration.test.ts b/packages/opencode/test/session/session-memory-integration.test.ts new file mode 100644 index 00000000000..540fc3e7b6a --- /dev/null +++ b/packages/opencode/test/session/session-memory-integration.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" + +// Mock dependencies +vi.mock("../../src/app/app", () => ({ + App: { + state: vi.fn(() => vi.fn()), + info: vi.fn(() => ({ path: { cwd: "/test", root: "/test" } })), + }, +})) + +vi.mock("../../src/storage/storage", () => ({ + Storage: { + writeJSON: vi.fn(), + readJSON: vi.fn(), + remove: vi.fn(), + removeDir: vi.fn(), + list: vi.fn(() => []), + }, +})) + +vi.mock("../../src/bus", () => ({ + Bus: { + publish: vi.fn(), + event: vi.fn((name, schema) => ({ name, schema })), + }, +})) + +vi.mock("../../src/installation", () => ({ + Installation: { + VERSION: "test-version", + }, +})) + +vi.mock("../../src/config/config", () => ({ + Config: { + get: vi.fn(() => Promise.resolve({ share: "disabled" })), + }, +})) + +vi.mock("../../src/id/id", () => ({ + Identifier: { + descending: vi.fn((prefix) => `${prefix}-${Date.now()}`), + ascending: vi.fn((prefix) => `${prefix}-${Date.now()}`), + schema: vi.fn(() => ({ parse: vi.fn(id => id) })), + }, +})) + +// Import after mocking +import { SessionMemoryManager } from "../../src/session/memory-manager" + +describe("Session Memory Integration", () => { + let mockState: any + let sessionCache: SessionMemoryManager.SessionCache + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + + // Create a fresh session cache for each test + sessionCache = new SessionMemoryManager.SessionCache({ + maxSessions: 5, + maxMessagesPerSession: 10, + sessionTtlMs: 5000, + inactiveTtlMs: 2000, + cleanupIntervalMs: 100, + }) + + // Mock the state function to return our controlled state + mockState = { + sessions: { + get: vi.fn((id: string) => sessionCache.getSession(id)), + set: vi.fn((id: string, value: any) => sessionCache.setSession(id, value)), + delete: vi.fn((id: string) => sessionCache.deleteSession(id)), + has: vi.fn((id: string) => sessionCache.hasSession(id)), + keys: vi.fn(() => sessionCache.getSessionKeys()), + size: sessionCache.size().sessions, + }, + messages: { + get: vi.fn((id: string) => sessionCache.getMessages(id)), + set: vi.fn((id: string, value: any) => sessionCache.setMessages(id, value)), + delete: vi.fn((id: string) => sessionCache.deleteMessages(id)), + has: vi.fn((id: string) => sessionCache.getMessages(id) !== undefined), + }, + pending: new Map(), + queued: new Map(), + memoryManager: sessionCache, + } + + const { App } = require("../../src/app/app") + App.state.mockReturnValue(() => mockState) + }) + + afterEach(() => { + sessionCache.destroy() + SessionMemoryManager.destroyGlobalCache() + }) + + describe("Memory Management in Session Operations", () => { + it("should handle session creation with memory management", async () => { + // Import Session after mocking + const { Session } = await import("../../src/session/index") + + // Create a session + const session = await Session.create() + + expect(session).toBeDefined() + expect(session.id).toBeDefined() + expect(mockState.sessions.set).toHaveBeenCalledWith(session.id, session) + + // Verify session is in memory cache + expect(sessionCache.getSession(session.id)).toEqual(session) + }) + + it("should handle session retrieval with cache hits and misses", async () => { + const { Session } = await import("../../src/session/index") + const { Storage } = require("../../src/storage/storage") + + const testSession = { + id: "test-session-123", + title: "Test Session", + version: "test-version", + time: { created: Date.now(), updated: Date.now() }, + } + + // Mock storage to return the session + Storage.readJSON.mockResolvedValue(testSession) + + // First call should miss cache and load from storage + const session1 = await Session.get("test-session-123") + expect(Storage.readJSON).toHaveBeenCalledWith("session/info/test-session-123") + expect(session1).toEqual(testSession) + + // Second call should hit cache + Storage.readJSON.mockClear() + const session2 = await Session.get("test-session-123") + expect(Storage.readJSON).not.toHaveBeenCalled() + expect(session2).toEqual(testSession) + }) + + it("should handle memory pressure during session operations", async () => { + const { Session } = await import("../../src/session/index") + + // Create sessions beyond cache capacity + const sessions = [] + for (let i = 0; i < 10; i++) { + const session = await Session.create() + sessions.push(session) + } + + // Cache should only retain the most recent sessions + const stats = sessionCache.getStats() + expect(stats.sessions.size).toBeLessThanOrEqual(5) + + // Most recent sessions should be accessible + const recentSession = sessions[sessions.length - 1] + expect(sessionCache.getSession(recentSession.id)).toBeDefined() + + // Oldest sessions might be evicted + const oldestSession = sessions[0] + const oldestFromCache = sessionCache.getSession(oldestSession.id) + // It might or might not be in cache depending on LRU eviction + }) + + it("should clean up memory when sessions are removed", async () => { + const { Session } = await import("../../src/session/index") + const { Storage } = require("../../src/storage/storage") + + // Create a session + const session = await Session.create() + const sessionId = session.id + + // Add some messages + const messages = [ + { id: "msg1", content: "Hello" }, + { id: "msg2", content: "World" }, + ] + sessionCache.setMessages(sessionId, messages) + + // Verify session and messages are in cache + expect(sessionCache.getSession(sessionId)).toBeDefined() + expect(sessionCache.getMessages(sessionId)).toBeDefined() + + // Remove the session + await Session.remove(sessionId) + + // Verify cleanup + expect(mockState.sessions.delete).toHaveBeenCalledWith(sessionId) + expect(mockState.messages.delete).toHaveBeenCalledWith(sessionId) + expect(Storage.remove).toHaveBeenCalledWith(`session/info/${sessionId}`) + expect(Storage.removeDir).toHaveBeenCalledWith(`session/message/${sessionId}/`) + }) + + it("should handle concurrent session operations", async () => { + const { Session } = await import("../../src/session/index") + + // Create multiple sessions concurrently + const sessionPromises = Array.from({ length: 5 }, () => Session.create()) + const sessions = await Promise.all(sessionPromises) + + expect(sessions).toHaveLength(5) + sessions.forEach(session => { + expect(session.id).toBeDefined() + expect(sessionCache.getSession(session.id)).toBeDefined() + }) + }) + + it("should provide memory statistics", () => { + // Add some test data + for (let i = 0; i < 3; i++) { + sessionCache.setSession(`session${i}`, { id: `session${i}` }) + sessionCache.setMessages(`session${i}`, [{ id: `msg${i}` }]) + } + + // Get some sessions to generate hit stats + sessionCache.getSession("session0") + sessionCache.getSession("session1") + sessionCache.getSession("nonexistent") // Miss + + const stats = sessionCache.getStats() + expect(stats.sessions.size).toBe(3) + expect(stats.messages.size).toBe(3) + expect(stats.sessions.hits).toBeGreaterThan(0) + expect(stats.sessions.misses).toBeGreaterThan(0) + }) + }) + + describe("TTL and Cleanup Behavior", () => { + it("should respect TTL for sessions", async () => { + // Create cache with short TTL for testing + const shortTtlCache = new SessionMemoryManager.SessionCache({ + maxSessions: 10, + sessionTtlMs: 100, // Very short TTL + inactiveTtlMs: 50, + cleanupIntervalMs: 25, + }) + + try { + shortTtlCache.setSession("test-session", { id: "test-session" }) + + // Should be available immediately + expect(shortTtlCache.getSession("test-session")).toBeDefined() + + // Wait for TTL expiration + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should be evicted due to TTL + expect(shortTtlCache.getSession("test-session")).toBeUndefined() + } finally { + shortTtlCache.destroy() + } + }) + + it("should handle inactive session cleanup", async () => { + const shortTtlCache = new SessionMemoryManager.SessionCache({ + maxSessions: 10, + sessionTtlMs: 1000, + inactiveTtlMs: 100, // Short inactive TTL + cleanupIntervalMs: 25, + }) + + try { + shortTtlCache.setSession("test-session", { id: "test-session" }) + + // Access the session + expect(shortTtlCache.getSession("test-session")).toBeDefined() + + // Wait for inactive TTL + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should be evicted due to inactivity + expect(shortTtlCache.getSession("test-session")).toBeUndefined() + } finally { + shortTtlCache.destroy() + } + }) + + it("should run background cleanup", async () => { + const cleanupCache = new SessionMemoryManager.SessionCache({ + maxSessions: 10, + sessionTtlMs: 50, + inactiveTtlMs: 50, + cleanupIntervalMs: 30, // Frequent cleanup + }) + + try { + // Add sessions that will expire + cleanupCache.setSession("session1", { id: "session1" }) + cleanupCache.setSession("session2", { id: "session2" }) + + // Wait for cleanup to run + await new Promise(resolve => setTimeout(resolve, 100)) + + // Sessions should be cleaned up by background process + expect(cleanupCache.size().sessions).toBeLessThanOrEqual(2) + } finally { + cleanupCache.destroy() + } + }) + }) + + describe("Error Handling and Edge Cases", () => { + it("should handle storage errors gracefully", async () => { + const { Session } = await import("../../src/session/index") + const { Storage } = require("../../src/storage/storage") + + // Mock storage to throw an error + Storage.readJSON.mockRejectedValue(new Error("Storage error")) + + // Should throw the storage error + await expect(Session.get("nonexistent-session")).rejects.toThrow("Storage error") + }) + + it("should handle invalid session data", () => { + // Test with invalid data + expect(() => { + sessionCache.setSession("invalid", null as any) + }).not.toThrow() + + expect(() => { + sessionCache.setMessages("invalid", undefined as any) + }).not.toThrow() + + // Should handle retrieval of invalid data + expect(sessionCache.getSession("invalid")).toBeNull() + expect(sessionCache.getMessages("invalid")).toBeUndefined() + }) + + it("should handle memory pressure gracefully", () => { + // Fill cache beyond capacity rapidly + for (let i = 0; i < 20; i++) { + sessionCache.setSession(`stress-session-${i}`, { + id: `stress-session-${i}`, + data: `large-data-${i}`.repeat(100), // Some larger data + }) + } + + // Cache should still be functional + const stats = sessionCache.getStats() + expect(stats.sessions.size).toBeLessThanOrEqual(5) + expect(stats.sessions.evictions).toBeGreaterThan(0) + + // Should be able to add more sessions + sessionCache.setSession("final-session", { id: "final-session" }) + expect(sessionCache.getSession("final-session")).toBeDefined() + }) + }) +}) \ No newline at end of file