agent-ecosystem/src/main/services/infrastructure/DataCache.ts
matt 767c985947 feat(02-01): add comprehensive dispose() to FileWatcher and DataCache
FileWatcher.dispose():
- Calls stop() to close watchers
- Clears all timers: retry, debounce, catch-up, polling
- Clears all tracking maps: lastProcessedLineCount, lastProcessedSize, activeSessionFiles, polledFileSizes, processingInProgress, pendingReprocess
- Calls removeAllListeners() LAST to prevent events during cleanup
- Sets disposed flag to prevent reuse

DataCache.dispose():
- Clears cache Map
- Disables caching
- Sets disposed flag to prevent reuse
- Auto-cleanup interval managed by caller (ServiceContext)

Both services prevent restart after disposal for proper lifecycle management.
2026-02-12 00:55:44 +00:00

385 lines
9.8 KiB
TypeScript

/**
* 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 {
this.invalidate(DataCache.buildKey(projectId, sessionId));
this.invalidateSubagentSession(projectId, sessionId);
}
/**
* Invalidates all cached subagent details for a session.
*/
invalidateSubagentSession(projectId: string, sessionId: string): void {
const prefix = `subagent-${projectId}-${sessionId}-`;
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) {
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()) {
if (key.startsWith(`${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()) {
if (key.startsWith(`${projectId}/`)) {
const parsed = DataCache.parseKey(key);
if (parsed) {
sessionIds.push(parsed.sessionId);
}
}
}
return sessionIds;
}
/**
* 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');
}
}