/** * DataCache service - LRU cache for parsed session data. * * Responsibilities: * - Cache parsed SessionDetail objects to avoid re-parsing * - LRU eviction policy with configurable max size * - TTL-based expiration * - Provide cache invalidation for file changes */ import { type SessionDetail, type SubagentDetail } from '@main/types';import { createLogger } from '@shared/utils/logger'; const logger = createLogger('Service:DataCache'); interface CacheEntry<T> { value: T; timestamp: number; version: number; // Cache schema version} // Union type for cached values type CachedValue = SessionDetail | SubagentDetail; export class DataCache { private cache: Map<string, CacheEntry<CachedValue>>; private maxSize: number; private ttl: number; // Time-to-live in milliseconds private enabled: boolean; // Whether caching is enabled private disposed = false; // Flag to prevent reuse after disposal private static readonly CURRENT_VERSION = 2; // Increment when cache structure changes constructor(maxSize: number = 50, ttlMinutes: number = 10, enabled: boolean = true) { this.cache = new Map(); this.maxSize = maxSize; this.ttl = ttlMinutes * 60 * 1000; this.enabled = enabled; } /** * Enable or disable caching. */ setEnabled(enabled: boolean): void { this.enabled = enabled; if (!enabled) { // Clear cache when disabling this.cache.clear(); } } /** * Check if caching is enabled. */ isEnabled(): boolean { return this.enabled; } // =========================================================================== // Cache Operations // =========================================================================== /** * Gets a cached session detail. * @param key - Cache key in format "projectId/sessionId" * @returns The cached SessionDetail, or undefined if not found or expired */ get(key: string): SessionDetail | undefined { if (!this.enabled) { return undefined; } const entry = this.cache.get(key); if (!entry) { return undefined; } // Check if entry version is outdated if (entry.version !== DataCache.CURRENT_VERSION) { logger.info(`DataCache: Invalidating outdated cache entry (v${entry.version}): ${key}`); this.cache.delete(key); return undefined; } // Check if entry has expired const now = Date.now(); if (now - entry.timestamp > this.ttl) { this.cache.delete(key); return undefined; } // Move to end (mark as recently used) this.cache.delete(key); this.cache.set(key, entry); return entry.value as SessionDetail; } /** * Gets a cached subagent detail. * @param key - Cache key in format "subagent-projectId-sessionId-subagentId" * @returns The cached SubagentDetail, or undefined if not found or expired */ getSubagent(key: string): SubagentDetail | undefined { if (!this.enabled) { return undefined; } const entry = this.cache.get(key); if (!entry) { return undefined; } // Check if entry version is outdated if (entry.version !== DataCache.CURRENT_VERSION) { logger.info( `DataCache: Invalidating outdated subagent cache entry (v${entry.version}): ${key}` ); this.cache.delete(key); return undefined; } // Check if entry has expired const now = Date.now(); if (now - entry.timestamp > this.ttl) { this.cache.delete(key); return undefined; } // Move to end (mark as recently used) this.cache.delete(key); this.cache.set(key, entry); return entry.value as SubagentDetail; } /** * Internal method to set a value in the cache. * Handles LRU eviction and cache entry creation. */ private setInternal(key: string, value: CachedValue): void { if (!this.enabled) { return; } // If at max size, remove least recently used (first entry) if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; if (firstKey) { this.cache.delete(firstKey); } } this.cache.set(key, { value, timestamp: Date.now(), version: DataCache.CURRENT_VERSION, }); } /** * Sets a value in the cache. * @param key - Cache key in format "projectId/sessionId" * @param value - The SessionDetail to cache */ set(key: string, value: SessionDetail): void { this.setInternal(key, value); } /** * Sets a subagent detail value in the cache. * @param key - Cache key in format "subagent-projectId-sessionId-subagentId" * @param value - The SubagentDetail to cache */ setSubagent(key: string, value: SubagentDetail): void { this.setInternal(key, value); } /** * Checks if a key exists in the cache and is not expired. * @param key - Cache key to check * @returns true if key exists and is valid, false otherwise */ has(key: string): boolean { return this.get(key) !== undefined; } // =========================================================================== // Key Building // =========================================================================== /** * Build a cache key from project and session IDs. */ static buildKey(projectId: string, sessionId: string): string { return `${projectId}/${sessionId}`; } /** * Parse a cache key into project and session IDs. */ static parseKey(key: string): { projectId: string; sessionId: string } | null { const parts = key.split('/'); if (parts.length !== 2) return null; return { projectId: parts[0], sessionId: parts[1] }; } // =========================================================================== // Invalidation // =========================================================================== /** * Invalidates a specific cache entry. * @param key - Cache key to invalidate */ invalidate(key: string): void { this.cache.delete(key); } /** * Invalidates a cache entry by project and session IDs. */ invalidateSession(projectId: string, sessionId: string): void { const keysToDelete: string[] = []; const sessionToken = `-${sessionId}-`; for (const key of this.cache.keys()) { const parsed = DataCache.parseKey(key); if ( parsed?.sessionId === sessionId && this.matchesProjectOrComposite(parsed.projectId, projectId) ) { keysToDelete.push(key); continue; } if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.cache.delete(key); } } /** * Invalidates all cached subagent details for a session. */ invalidateSubagentSession(projectId: string, sessionId: string): void { const sessionToken = `-${sessionId}-`; const keysToDelete: string[] = []; for (const key of this.cache.keys()) { if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.cache.delete(key); } } /** * Invalidates all cache entries for a project. * @param projectId - The project ID */ invalidateProject(projectId: string): void { const keysToDelete: string[] = []; for (const key of this.cache.keys()) { const parsed = DataCache.parseKey(key); if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) { keysToDelete.push(key); continue; } if (this.isSubagentKeyForProject(key, projectId)) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.cache.delete(key); } } /** * Clears the entire cache. */ clear(): void { this.cache.clear(); } // =========================================================================== // Cache Management // =========================================================================== /** * Gets current cache size. * @returns Number of entries in the cache */ size(): number { return this.cache.size; } /** * Gets cache statistics. * @returns Object with cache stats */ stats(): { size: number; maxSize: number; ttlMinutes: number; keys: string[]; } { return { size: this.cache.size, maxSize: this.maxSize, ttlMinutes: this.ttl / 60000, keys: Array.from(this.cache.keys()), }; } /** * Removes expired and outdated entries from the cache. * Should be called periodically to prevent memory bloat. */ cleanExpired(): number { const now = Date.now(); const keysToDelete: string[] = []; for (const [key, entry] of this.cache.entries()) { // Remove if expired OR outdated version if (now - entry.timestamp > this.ttl || entry.version !== DataCache.CURRENT_VERSION) { keysToDelete.push(key); } } for (const key of keysToDelete) { this.cache.delete(key); } if (keysToDelete.length > 0) { logger.info(`DataCache: Cleaned ${keysToDelete.length} expired/outdated entries`); } return keysToDelete.length; } /** * Starts automatic cleanup of expired entries. * @param intervalMinutes - How often to run cleanup (default: 5 minutes) * @returns Timer handle that can be used to stop cleanup */ startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout { const intervalMs = intervalMinutes * 60 * 1000; return setInterval(() => { this.cleanExpired(); }, intervalMs); } /** * Gets all cached session IDs for a project. */ getProjectSessionIds(projectId: string): string[] { const sessionIds: string[] = []; for (const key of this.cache.keys()) { const parsed = DataCache.parseKey(key); if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) { sessionIds.push(parsed.sessionId); } } return sessionIds; } private matchesProjectOrComposite(projectId: string, baseProjectId: string): boolean { return projectId === baseProjectId || projectId.startsWith(`${baseProjectId}::`); } private isSubagentKeyForProject(key: string, baseProjectId: string): boolean { if (!key.startsWith('subagent-')) { return false; } const prefix = `subagent-${baseProjectId}`; return key.startsWith(`${prefix}-`) || key.startsWith(`${prefix}::`); } /** * Disposes the cache and prevents further use. * Clears all cached data and disables caching. * * Note: The auto-cleanup interval returned by startAutoCleanup() is managed * by the caller (ServiceContext), not stored internally, so we only need to * clear the cache and disable it. */ dispose(): void { if (this.disposed) { logger.info('DataCache already disposed'); return; } logger.info('Disposing DataCache'); // Clear all cached data this.cache.clear(); // Disable caching this.enabled = false; // Mark as disposed this.disposed = true; logger.info('DataCache disposed'); }}
/**
* DataCache service - LRU cache for parsed session data.
*
* Responsibilities:
* - Cache parsed SessionDetail objects to avoid re-parsing
* - LRU eviction policy with configurable max size
* - TTL-based expiration
* - Provide cache invalidation for file changes
*/
import { type SessionDetail, type SubagentDetail } from '@main/types';
import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Service:DataCache');
interface CacheEntry<T> {
value: T;
timestamp: number;
version: number; // Cache schema version
}
// Union type for cached values
type CachedValue = SessionDetail | SubagentDetail;
export class DataCache {
private cache: Map<string, CacheEntry<CachedValue>>;
private maxSize: number;
private ttl: number; // Time-to-live in milliseconds
private enabled: boolean; // Whether caching is enabled
private disposed = false; // Flag to prevent reuse after disposal
private static readonly CURRENT_VERSION = 2; // Increment when cache structure changes
constructor(maxSize: number = 50, ttlMinutes: number = 10, enabled: boolean = true) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttl = ttlMinutes * 60 * 1000;
this.enabled = enabled;
}
/**
* Enable or disable caching.
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
// Clear cache when disabling
this.cache.clear();
}
}
/**
* Check if caching is enabled.
*/
isEnabled(): boolean {
return this.enabled;
}
// ===========================================================================
// Cache Operations
// ===========================================================================
/**
* Gets a cached session detail.
* @param key - Cache key in format "projectId/sessionId"
* @returns The cached SessionDetail, or undefined if not found or expired
*/
get(key: string): SessionDetail | undefined {
if (!this.enabled) {
return undefined;
}
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
// Check if entry version is outdated
if (entry.version !== DataCache.CURRENT_VERSION) {
logger.info(`DataCache: Invalidating outdated cache entry (v${entry.version}): ${key}`);
this.cache.delete(key);
return undefined;
}
// Check if entry has expired
const now = Date.now();
if (now - entry.timestamp > this.ttl) {
this.cache.delete(key);
return undefined;
}
// Move to end (mark as recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value as SessionDetail;
}
/**
* Gets a cached subagent detail.
* @param key - Cache key in format "subagent-projectId-sessionId-subagentId"
* @returns The cached SubagentDetail, or undefined if not found or expired
*/
getSubagent(key: string): SubagentDetail | undefined {
if (!this.enabled) {
return undefined;
}
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
// Check if entry version is outdated
if (entry.version !== DataCache.CURRENT_VERSION) {
logger.info(
`DataCache: Invalidating outdated subagent cache entry (v${entry.version}): ${key}`
);
this.cache.delete(key);
return undefined;
}
// Check if entry has expired
const now = Date.now();
if (now - entry.timestamp > this.ttl) {
this.cache.delete(key);
return undefined;
}
// Move to end (mark as recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value as SubagentDetail;
}
/**
* Internal method to set a value in the cache.
* Handles LRU eviction and cache entry creation.
*/
private setInternal(key: string, value: CachedValue): void {
if (!this.enabled) {
return;
}
// If at max size, remove least recently used (first entry)
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
value,
timestamp: Date.now(),
version: DataCache.CURRENT_VERSION,
});
}
/**
* Sets a value in the cache.
* @param key - Cache key in format "projectId/sessionId"
* @param value - The SessionDetail to cache
*/
set(key: string, value: SessionDetail): void {
this.setInternal(key, value);
}
/**
* Sets a subagent detail value in the cache.
* @param key - Cache key in format "subagent-projectId-sessionId-subagentId"
* @param value - The SubagentDetail to cache
*/
setSubagent(key: string, value: SubagentDetail): void {
this.setInternal(key, value);
}
/**
* Checks if a key exists in the cache and is not expired.
* @param key - Cache key to check
* @returns true if key exists and is valid, false otherwise
*/
has(key: string): boolean {
return this.get(key) !== undefined;
}
// ===========================================================================
// Key Building
// ===========================================================================
/**
* Build a cache key from project and session IDs.
*/
static buildKey(projectId: string, sessionId: string): string {
return `${projectId}/${sessionId}`;
}
/**
* Parse a cache key into project and session IDs.
*/
static parseKey(key: string): { projectId: string; sessionId: string } | null {
const parts = key.split('/');
if (parts.length !== 2) return null;
return { projectId: parts[0], sessionId: parts[1] };
}
// ===========================================================================
// Invalidation
// ===========================================================================
/**
* Invalidates a specific cache entry.
* @param key - Cache key to invalidate
*/
invalidate(key: string): void {
this.cache.delete(key);
}
/**
* Invalidates a cache entry by project and session IDs.
*/
invalidateSession(projectId: string, sessionId: string): void {
const keysToDelete: string[] = [];
const sessionToken = `-${sessionId}-`;
for (const key of this.cache.keys()) {
const parsed = DataCache.parseKey(key);
if (
parsed?.sessionId === sessionId &&
this.matchesProjectOrComposite(parsed.projectId, projectId)
) {
keysToDelete.push(key);
continue;
}
if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
}
/**
* Invalidates all cached subagent details for a session.
*/
invalidateSubagentSession(projectId: string, sessionId: string): void {
const sessionToken = `-${sessionId}-`;
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
if (this.isSubagentKeyForProject(key, projectId) && key.includes(sessionToken)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
}
/**
* Invalidates all cache entries for a project.
* @param projectId - The project ID
*/
invalidateProject(projectId: string): void {
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
const parsed = DataCache.parseKey(key);
if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) {
keysToDelete.push(key);
continue;
}
if (this.isSubagentKeyForProject(key, projectId)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
}
/**
* Clears the entire cache.
*/
clear(): void {
this.cache.clear();
}
// ===========================================================================
// Cache Management
// ===========================================================================
/**
* Gets current cache size.
* @returns Number of entries in the cache
*/
size(): number {
return this.cache.size;
}
/**
* Gets cache statistics.
* @returns Object with cache stats
*/
stats(): {
size: number;
maxSize: number;
ttlMinutes: number;
keys: string[];
} {
return {
size: this.cache.size,
maxSize: this.maxSize,
ttlMinutes: this.ttl / 60000,
keys: Array.from(this.cache.keys()),
};
}
/**
* Removes expired and outdated entries from the cache.
* Should be called periodically to prevent memory bloat.
*/
cleanExpired(): number {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
// Remove if expired OR outdated version
if (now - entry.timestamp > this.ttl || entry.version !== DataCache.CURRENT_VERSION) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
if (keysToDelete.length > 0) {
logger.info(`DataCache: Cleaned ${keysToDelete.length} expired/outdated entries`);
}
return keysToDelete.length;
}
/**
* Starts automatic cleanup of expired entries.
* @param intervalMinutes - How often to run cleanup (default: 5 minutes)
* @returns Timer handle that can be used to stop cleanup
*/
startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout {
const intervalMs = intervalMinutes * 60 * 1000;
return setInterval(() => {
this.cleanExpired();
}, intervalMs);
}
/**
* Gets all cached session IDs for a project.
*/
getProjectSessionIds(projectId: string): string[] {
const sessionIds: string[] = [];
for (const key of this.cache.keys()) {
const parsed = DataCache.parseKey(key);
if (parsed && this.matchesProjectOrComposite(parsed.projectId, projectId)) {
sessionIds.push(parsed.sessionId);
}
}
return sessionIds;
}
private matchesProjectOrComposite(projectId: string, baseProjectId: string): boolean {
return projectId === baseProjectId || projectId.startsWith(`${baseProjectId}::`);
}
private isSubagentKeyForProject(key: string, baseProjectId: string): boolean {
if (!key.startsWith('subagent-')) {
return false;
}
const prefix = `subagent-${baseProjectId}`;
return key.startsWith(`${prefix}-`) || key.startsWith(`${prefix}::`);
}
/**
* Disposes the cache and prevents further use.
* Clears all cached data and disables caching.
*
* Note: The auto-cleanup interval returned by startAutoCleanup() is managed
* by the caller (ServiceContext), not stored internally, so we only need to
* clear the cache and disable it.
*/
dispose(): void {
if (this.disposed) {
logger.info('DataCache already disposed');
return;
}
logger.info('Disposing DataCache');
// Clear all cached data
this.cache.clear();
// Disable caching
this.enabled = false;
// Mark as disposed
this.disposed = true;
logger.info('DataCache disposed');
}
}