diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index a6df22f7e..c26a404c1 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -36,9 +36,36 @@ class OnyxCache { */ private pendingPromises: Map | OnyxKey[]>>; - /** Maximum size of the keys store din cache */ + /** Maximum size of the keys stored in cache (legacy property for backward compatibility) */ private maxRecentKeysSize = 0; + /** Memory usage limit in bytes (default: 50MB) */ + private memoryUsageLimit = 10 * 1024 * 1024; + + /** Current estimated memory usage in bytes */ + private currentMemoryUsage = 0; + + /** Memory threshold percentage that triggers cleanup (default: 85%) */ + private memoryThreshold = 0.85; + + /** Minimum key size to track for memory estimation (bytes) */ + private minKeySize = 100; + + /** Map of key timestamps */ + // private keyTimestamps = new Map(); + + /** Set of keys that should not be evicted */ + private nonEvictableKeys = new Set(); + + /** Count of expired keys cleaned since last reset */ + private expiredKeysCleanedCount = 0; + + /** Timestamp of last key expiration cleanup */ + private lastExpirationCleanupTime = 0; + + /** Expiration time in milliseconds */ + private readonly EXPIRATION_TIME_MS = 10 * 60 * 60 * 1000; + constructor() { this.storageKeys = new Set(); this.nullishStorageKeys = new Set(); @@ -65,6 +92,9 @@ class OnyxCache { 'removeLeastRecentlyUsedKeys', 'setRecentKeysLimit', 'setAllKeys', + 'markKeyAsNonEvictable', + 'markKeyAsEvictable', + 'isKeyEvictable', ); } @@ -131,6 +161,7 @@ class OnyxCache { * Adds the key to the storage keys list as well */ set(key: OnyxKey, value: OnyxValue): OnyxValue { + // this.cleanExpiredKeys(); // this.addKey(key); this.addToAccessedKeys(key); @@ -139,10 +170,22 @@ class OnyxCache { this.nullishStorageKeys.delete(key); if (value === null || value === undefined) { + // Track memory usage reduction + if (this.storageMap[key] !== undefined) { + this.reduceMemoryUsage(key, this.storageMap[key]); + } delete this.storageMap[key]; return undefined; } + // Track memory usage + this.trackMemoryUsage(key, value); + + // Check if we need to free up memory + if (this.shouldReduceMemoryUsage()) { + this.freeMemory(); + } + this.storageMap[key] = value; return value; @@ -215,38 +258,224 @@ class OnyxCache { addToAccessedKeys(key: OnyxKey): void { this.recentKeys.delete(key); this.recentKeys.add(key); + // this.keyTimestamps.set(key, Date.now()); } - /** Remove keys that don't fall into the range of recently used keys */ - removeLeastRecentlyUsedKeys(): void { - let numKeysToRemove = this.recentKeys.size - this.maxRecentKeysSize; - if (numKeysToRemove <= 0) { - return; + /** + * Tracks memory usage for a key-value pair + */ + trackMemoryUsage(key: OnyxKey, value: OnyxValue): void { + // If this key already exists, first reduce its current memory usage + if (this.storageMap[key] !== undefined) { + this.reduceMemoryUsage(key, this.storageMap[key]); } - const iterator = this.recentKeys.values(); - const temp = []; - while (numKeysToRemove > 0) { - const value = iterator.next().value; - temp.push(value); - numKeysToRemove--; + + // Calculate approximate size of the value + let valueSize = 0; + + try { + // Using JSON.stringify for a rough estimate + const valueStr = JSON.stringify(value); + valueSize = valueStr.length * 2; // UTF-16 encoding uses 2 bytes per character + } catch (e) { + // Fallback to minimum size if stringification fails + valueSize = this.minKeySize; + } + + // Update memory usage + this.currentMemoryUsage += valueSize; + } + + /** + * Reduces tracked memory usage when a key is removed or updated + */ + reduceMemoryUsage(key: OnyxKey, value: OnyxValue): void { + let valueSize = 0; + + try { + // Using JSON.stringify for a rough estimate + const valueStr = JSON.stringify(value); + valueSize = valueStr.length * 2; // UTF-16 encoding uses 2 bytes per character + } catch (e) { + // Fallback to minimum size if stringification fails + valueSize = this.minKeySize; } - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < temp.length; ++i) { - delete this.storageMap[temp[i]]; - this.recentKeys.delete(temp[i]); + this.currentMemoryUsage = Math.max(0, this.currentMemoryUsage - valueSize); + } + + /** + * Checks if memory usage has exceeded the threshold + */ + shouldReduceMemoryUsage(): boolean { + return this.currentMemoryUsage > this.memoryUsageLimit * this.memoryThreshold; + } + + /** + * Frees memory by removing least recently used keys that are safe to evict + */ + freeMemory(): void { + const targetMemoryUsage = this.memoryUsageLimit * 0.7; // Target 70% usage after cleanup + const keysToRemove: OnyxKey[] = []; + + // If we're under the limit, no need to free memory + if (this.currentMemoryUsage <= targetMemoryUsage) { + return; } + + // Build list of keys to remove (least recently used first) + const orderedKeys = Array.from(this.recentKeys); + + // Use array iteration instead of for...of loop + orderedKeys.some((key) => { + if (this.currentMemoryUsage <= targetMemoryUsage) { + return true; // Stop iteration once we're under the target + } + + if (this.isKeyEvictable(key)) { + keysToRemove.push(key); + this.reduceMemoryUsage(key, this.storageMap[key]); + } + return false; + }); + + // Remove the keys from cache + keysToRemove.forEach((key) => { + delete this.storageMap[key]; + this.recentKeys.delete(key); + }); + } + + // cleanExpiredKeys(): void { + // const now = Date.now(); + // const expiredKeys: OnyxKey[] = []; + + // this.keyTimestamps.forEach((timestamp, key) => { + // // Skip keys that are not safe for eviction + // // if (!OnyxUtils.isSafeEvictionKey(key)) return; + // if (now - timestamp < this.EXPIRATION_TIME_MS) { + // return; + // } + + // expiredKeys.push(key); + // }); + + // // Remove expired keys + // expiredKeys.forEach((key) => { + // this.storageMap[key] = undefined; + // this.recentKeys.delete(key); + // this.keyTimestamps.delete(key); + // }); + + // if (expiredKeys.length > 0) { + // this.expiredKeysCleanedCount += expiredKeys.length; + // this.lastExpirationCleanupTime = now; + // Logger.logInfo(`Cleaned ${expiredKeys.length} expired keys from cache`); + // } + // } + + /** + * Marks a key as non-evictable, meaning it won't be automatically evicted + * when the cache size limit is reached + */ + markKeyAsNonEvictable(key: OnyxKey): void { + this.nonEvictableKeys.add(key); + } + + /** + * Marks a key as evictable, allowing it to be automatically evicted + * when the cache size limit is reached + */ + markKeyAsEvictable(key: OnyxKey): void { + this.nonEvictableKeys.delete(key); + } + + /** + * Checks if a key can be evicted + */ + isKeyEvictable(key: OnyxKey): boolean { + return !this.nonEvictableKeys.has(key); + } + + /** Remove keys that don't fall into the range of recently used keys */ + removeLeastRecentlyUsedKeys(): void { + // For backward compatibility with code that may still call this method + this.freeMemory(); } /** Set the recent keys list size */ setRecentKeysLimit(limit: number): void { + // For backward compatibility with code that may still call this method this.maxRecentKeysSize = limit; + + // Adjust memory limit based on the key limit (rough heuristic) + // This ensures systems that call setRecentKeysLimit still have some control over cache size + this.memoryUsageLimit = Math.max(this.memoryUsageLimit, limit * this.minKeySize * 10); + } + + /** + * Sets the memory usage limit in megabytes + */ + setMemoryLimit(limitInMB: number): void { + this.memoryUsageLimit = limitInMB * 1024 * 1024; + + // If we're already over the new limit, trigger cleanup + if (this.shouldReduceMemoryUsage()) { + this.freeMemory(); + } + } + + /** + * Gets the current memory usage in megabytes + */ + getMemoryUsage(): number { + return this.currentMemoryUsage / (1024 * 1024); + } + + /** + * Gets the memory usage limit in megabytes + */ + getMemoryLimit(): number { + return this.memoryUsageLimit / (1024 * 1024); } /** Check if the value has changed */ hasValueChanged(key: OnyxKey, value: OnyxValue): boolean { return !deepEqual(this.storageMap[key], value); } + + getRecentlyUsedKeys(count = 20): OnyxKey[] { + const keys = Array.from(this.recentKeys).slice(-count); + return keys.reverse(); // Most recent first + } + + /** + * Gets the number of expired keys cleaned since last reset + */ + getExpiredKeysCleanedCount(): number { + return this.expiredKeysCleanedCount; + } + + /** + * Gets the timestamp of the last key expiration cleanup + */ + getLastExpirationCleanupTime(): number { + return this.lastExpirationCleanupTime; + } + + /** + * Gets the expiration time in milliseconds + */ + getExpirationTimeMs(): number { + return this.EXPIRATION_TIME_MS; + } + + /** + * Resets the expired keys cleaned count + */ + resetExpiredKeysCleanedCount(): void { + this.expiredKeysCleanedCount = 0; + } } const instance = new OnyxCache(); diff --git a/lib/OnyxCacheMonitor.ts b/lib/OnyxCacheMonitor.ts new file mode 100644 index 000000000..71b867095 --- /dev/null +++ b/lib/OnyxCacheMonitor.ts @@ -0,0 +1,389 @@ +import cache from './OnyxCache'; +import * as Logger from './Logger'; +import type {OnyxKey} from './types'; + +type CacheMetrics = { + timestamp: number; + storageKeysSize: number; + nullishStorageKeysSize: number; + recentKeysSize: number; + storageMapSize: number; + storageMapMemoryEstimate: number; + operationCounters: Record; + maxCacheLimit: number; + memoryUsage: number; + memoryLimit: number; + expiredKeysCleanedCount: number; + lastExpirationCleanupTime: number; + expirationTimeMs: number; +}; + +type CacheMethod = 'get' | 'getAllKeys' | 'set' | 'drop' | 'merge' | 'hasCacheForKey'; + +// Define a type for the private cache structure +interface PrivateCache { + storageKeys: Set; + nullishStorageKeys: Set; + recentKeys: Set; + storageMap: Record; + maxRecentKeysSize: number; +} + +/** + * Monitors the OnyxCache to track size metrics and operation performance + */ +class OnyxCacheMonitor { + private isEnabled = false; + + private metricsHistory: CacheMetrics[] = []; + + private operationCounters: Record = {}; + + private sampleInterval: number | null = null; + + private maxHistoryLength = 100; + + private originalMethods: Record unknown> = {} as Record unknown>; + + constructor() { + // Initialize operation counters for all public methods + ['get', 'getAllKeys', 'set', 'drop', 'merge', 'hasCacheForKey'].forEach((method) => { + this.operationCounters[method] = 0; + }); + } + + /** + * Enable the cache monitor + * @param sampleIntervalMs How often to sample cache metrics (milliseconds) + * @param maxHistory Maximum number of history entries to keep + */ + enable(sampleIntervalMs = 5000, maxHistory = 100): void { + if (this.isEnabled) return; + + this.isEnabled = true; + this.maxHistoryLength = maxHistory; + + // Monkey patch the cache methods to track operation counts + this.monkeyPatchCacheMethods(); + + // Take initial measurement + this.measureCacheMetrics(); + + // Set up interval for periodic measurements + this.sampleInterval = setInterval(() => { + this.measureCacheMetrics(); + }, sampleIntervalMs) as unknown as number; + + Logger.logInfo('OnyxCacheMonitor enabled'); + } + + /** + * Disable the cache monitor + */ + disable(): void { + if (!this.isEnabled) return; + + this.isEnabled = false; + + if (this.sampleInterval !== null) { + clearInterval(this.sampleInterval as unknown as number); + this.sampleInterval = null; + } + + // Remove the monkey patching + this.restoreCacheMethods(); + + Logger.logInfo('OnyxCacheMonitor disabled'); + } + + /** + * Create a report of the current cache state + */ + generateReport(): string { + const currentMetrics = this.getCurrentMetrics(); + const operationCounts = this.getOperationCounts(); + const performanceInsights = this.analyzePerformance(currentMetrics); + + this.logCacheState(currentMetrics); + this.logMostRecentKeys(); + + // Format the last expiration cleanup time + const lastCleanupTimeFormatted = currentMetrics.lastExpirationCleanupTime > 0 ? new Date(currentMetrics.lastExpirationCleanupTime).toISOString() : 'Never'; + + // Calculate expiration time in readable format + const expirationHours = Math.round(currentMetrics.expirationTimeMs / (60 * 60 * 1000)); + + return ` +OnyxCache Performance Report: +============================= +Time: ${new Date(currentMetrics.timestamp).toISOString()} + +Cache Configuration: +- Max Cached Keys Limit: ${currentMetrics.maxCacheLimit} +- Memory Limit: ${currentMetrics.memoryLimit.toFixed(2)} MB +- Key Expiration Time: ${expirationHours} hours + +Cache Sizes: +- Storage Keys: ${currentMetrics.storageKeysSize} +- Nullish Storage Keys: ${currentMetrics.nullishStorageKeysSize} +- Recent Keys: ${currentMetrics.recentKeysSize} +- Storage Map Entries: ${currentMetrics.storageMapSize} +- Memory Usage: ${currentMetrics.memoryUsage.toFixed(2)} MB (${((currentMetrics.memoryUsage / currentMetrics.memoryLimit) * 100).toFixed(2)}%) +- Estimated Memory Usage (alternative): ${Math.round(currentMetrics.storageMapMemoryEstimate / 1024)} KB + +Expiration Metrics: +- Total Expired Keys Cleaned: ${currentMetrics.expiredKeysCleanedCount} +- Last Cleanup Time: ${lastCleanupTimeFormatted} + +Operation Counts: +${Object.entries(operationCounts) + .map(([method, count]) => `- ${method}: ${count} calls`) + .join('\n')} + +Performance Insights: +${performanceInsights.length > 0 ? performanceInsights.map((insight) => `- ${insight}`).join('\n') : '- No performance issues detected'} + +History: +- Samples: ${this.metricsHistory.length} +- Duration: ${this.metricsHistory.length > 1 ? Math.round((currentMetrics.timestamp - this.metricsHistory[0].timestamp) / 1000) : 0} seconds +`.trim(); + } + + /** + * Get the current cache metrics + */ + private getCurrentMetrics(): CacheMetrics { + return this.metricsHistory.length > 0 ? this.metricsHistory[this.metricsHistory.length - 1] : this.measureCacheMetrics(); + } + + /** + * Get operation counts + */ + private getOperationCounts(): Record { + return {...this.operationCounters}; + } + + /** + * Estimate the number of bytes used by the cache + */ + private estimateStorageMapSize(): number { + try { + // Using JSON.stringify to get a rough estimate of object size + // Access the private storageMap through an indirect approach + const cacheAsUnknown = cache as unknown as {storageMap: Record}; + const storageMapString = JSON.stringify(cacheAsUnknown.storageMap); + return storageMapString.length; + } catch (e) { + return 0; + } + } + + /** + * Measure current cache metrics and add to history + */ + private measureCacheMetrics(): CacheMetrics { + const privateCache = cache as unknown as PrivateCache; + + // Ensure we get the maxRecentKeysSize correctly, add extra check for debugging + const maxCacheLimitValue = privateCache.maxRecentKeysSize || 0; + if (maxCacheLimitValue === 0) { + Logger.logInfo('OnyxCacheMonitor: Warning - maxRecentKeysSize is 0, cache limit tracking will be inaccurate'); + } + + // Get memory metrics from the cache (new method) + const memoryUsage = (cache as unknown as {getMemoryUsage: () => number}).getMemoryUsage?.() || 0; + const memoryLimit = (cache as unknown as {getMemoryLimit: () => number}).getMemoryLimit?.() || 0; + + // Get expiration metrics from the cache + const expiredKeysCleanedCount = (cache as unknown as {getExpiredKeysCleanedCount: () => number}).getExpiredKeysCleanedCount?.() || 0; + const lastExpirationCleanupTime = (cache as unknown as {getLastExpirationCleanupTime: () => number}).getLastExpirationCleanupTime?.() || 0; + const expirationTimeMs = (cache as unknown as {getExpirationTimeMs: () => number}).getExpirationTimeMs?.() || 0; + + const metrics: CacheMetrics = { + timestamp: Date.now(), + storageKeysSize: privateCache.storageKeys?.size || 0, + nullishStorageKeysSize: privateCache.nullishStorageKeys?.size || 0, + recentKeysSize: privateCache.recentKeys?.size || 0, + storageMapSize: Object.keys(privateCache.storageMap || {}).length, + storageMapMemoryEstimate: this.estimateStorageMapSize(), + operationCounters: {...this.operationCounters}, + maxCacheLimit: maxCacheLimitValue, + memoryUsage, + memoryLimit, + expiredKeysCleanedCount, + lastExpirationCleanupTime, + expirationTimeMs, + }; + + this.metricsHistory.push(metrics); + + // Trim history if needed + if (this.metricsHistory.length > this.maxHistoryLength) { + this.metricsHistory = this.metricsHistory.slice(-this.maxHistoryLength); + } + + return metrics; + } + + /** + * Apply performance tracking to cache methods + */ + private monkeyPatchCacheMethods(): void { + const methodsToTrack: CacheMethod[] = ['get', 'getAllKeys', 'set', 'drop', 'merge', 'hasCacheForKey']; + + methodsToTrack.forEach((method) => { + if (typeof cache[method as keyof typeof cache] !== 'function') { + return; + } + + const originalMethod = cache[method as keyof typeof cache] as unknown as (key: OnyxKey, ...args: unknown[]) => unknown; + this.originalMethods[method] = originalMethod; + + const cacheAsUnknown = cache as unknown; + (cacheAsUnknown as Record)[method] = (...args: [OnyxKey, ...unknown[]]) => { + this.operationCounters[method]++; + + return this.originalMethods[method].apply(cache, args); + }; + }); + + Logger.logInfo('OnyxCacheMonitor: Methods patched for operation counting'); + } + + /** + * Restore original cache methods + */ + private restoreCacheMethods(): void { + if (!this.originalMethods) { + return; + } + + Object.keys(this.originalMethods).forEach((method) => { + const cacheAsUnknown = cache as unknown; + (cacheAsUnknown as Record)[method] = this.originalMethods[method as CacheMethod]; + }); + this.originalMethods = {} as Record unknown>; + } + + /** + * Analyze the current metrics and identify potential performance issues + */ + private analyzePerformance(metrics: CacheMetrics): string[] { + const insights: string[] = []; + + // Check for high memory usage (threshold: 10MB) + const memoryMB = metrics.memoryUsage; + if (memoryMB > 10) { + insights.push(`HIGH MEMORY USAGE: ${memoryMB.toFixed(2)}MB exceeds recommended limit of 10MB`); + } + + // Memory usage threshold analysis (if memory limit is set) + if (metrics.memoryLimit > 0) { + const memoryUsagePercentage = (metrics.memoryUsage / metrics.memoryLimit) * 100; + + if (memoryUsagePercentage > 90) { + insights.push(`CRITICAL: Memory usage at ${memoryUsagePercentage.toFixed(2)}% of maximum limit`); + } else if (memoryUsagePercentage > 75) { + insights.push(`WARNING: Memory usage at ${memoryUsagePercentage.toFixed(2)}% of maximum limit`); + } + + // Memory usage trend analysis + if (this.metricsHistory.length > 2) { + const previousMetric = this.metricsHistory[this.metricsHistory.length - 2]; + const memoryGrowthRate = ((metrics.memoryUsage - previousMetric.memoryUsage) / previousMetric.memoryUsage) * 100; + + if (memoryGrowthRate > 20) { + insights.push(`RAPID GROWTH: Memory usage increased by ${memoryGrowthRate.toFixed(2)}% since last check`); + } + } + } + + // Analyze expiration patterns + if (metrics.expiredKeysCleanedCount > 0) { + // Check if a large number of keys were expired in the last sample + if (this.metricsHistory.length > 1) { + const previousMetric = this.metricsHistory[this.metricsHistory.length - 2]; + const recentlyExpiredKeys = metrics.expiredKeysCleanedCount - previousMetric.expiredKeysCleanedCount; + + if (recentlyExpiredKeys > 50) { + insights.push(`HIGH EXPIRATION RATE: ${recentlyExpiredKeys} keys expired since last check`); + } + } + } + + // Cache limit analysis - maintain for backward compatibility + if (metrics.maxCacheLimit > 0) { + // LRU cache utilization percentage - only show warning if we have at least 10 keys to avoid false positives + const utilizationPercentage = (metrics.recentKeysSize / metrics.maxCacheLimit) * 100; + if (metrics.recentKeysSize > 10 && utilizationPercentage > 90) { + insights.push(`HIGH KEY COUNT: Using ${utilizationPercentage.toFixed(2)}% of available key slots (${metrics.recentKeysSize}/${metrics.maxCacheLimit})`); + } + + // Check growth rate + if (this.metricsHistory.length > 2) { + const previousMetric = this.metricsHistory[this.metricsHistory.length - 2]; + const growthRate = (metrics.recentKeysSize - previousMetric.recentKeysSize) / previousMetric.recentKeysSize; + + if (growthRate > 0.2 && metrics.recentKeysSize > previousMetric.recentKeysSize + 10) { + insights.push(`RAPID KEY GROWTH: ${metrics.recentKeysSize - previousMetric.recentKeysSize} keys added since last check (${(growthRate * 100).toFixed(2)}% increase)`); + } + } + } + + // Check for large disparity between storage keys and recent keys + const keyDisparity = metrics.storageKeysSize - metrics.recentKeysSize; + if (keyDisparity > 100 && metrics.storageKeysSize > 0) { + const disparityPercentage = (keyDisparity / metrics.storageKeysSize) * 100; + if (disparityPercentage > 50) { + insights.push(`CACHE MISS RISK: ${disparityPercentage.toFixed(2)}% of storage keys (${keyDisparity}) not in recent keys cache`); + } + } + + // Analyze operation distribution + const writeOps = (metrics.operationCounters.set || 0) + (metrics.operationCounters.merge || 0); + const totalOps = Object.values(metrics.operationCounters).reduce((sum, count) => sum + count, 0); + + if (totalOps > 100) { + const writePercentage = (writeOps / totalOps) * 100; + if (writePercentage > 70) { + insights.push(`WRITE-HEAVY: ${writePercentage.toFixed(2)}% of operations are writes, consider optimizing`); + } + } + + return insights; + } + + /** + * Log the current cache state + */ + logCacheState(metrics?: CacheMetrics): void { + const currentMetrics = metrics || this.getCurrentMetrics(); + + Logger.logInfo(` + === OnyxCache State === + Total keys in storage: ${currentMetrics.storageKeysSize} + Total keys in recentKeys (LRU): ${currentMetrics.recentKeysSize} + Keys with nullish values: ${currentMetrics.nullishStorageKeysSize} + Storage map entries: ${currentMetrics.storageMapSize} + Memory usage: ${currentMetrics.memoryUsage.toFixed(2)}MB / ${currentMetrics.memoryLimit.toFixed(2)}MB (${((currentMetrics.memoryUsage / currentMetrics.memoryLimit) * 100).toFixed(2)}%) + ====================== + `); + } + + /** + * Log the most recently used keys + */ + logMostRecentKeys(keyCount = 20): void { + const recentKeys = (cache as unknown as {getRecentlyUsedKeys: (count: number) => OnyxKey[]}).getRecentlyUsedKeys(keyCount); + + Logger.logInfo(` + === ${keyCount} Most Recently Used Keys === + ${recentKeys.map((key, index) => `${index + 1}. ${key}`).join('\n')} + ================================ + `); + } +} + +const instance = new OnyxCacheMonitor(); + +export default instance; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index a9740a6b6..e9e6e35d7 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -171,6 +171,56 @@ function initStoreValues(keys: DeepRecord, initialKeyStates: Pa if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') { snapshotKey = keys.COLLECTION.SNAPSHOT; } + + // Mark any existing keys in storage that are NOT in safeEvictionKeys as non-evictable + cache.getAllKeys().forEach((key) => { + // If key is in safeEvictionKeys or starts with a safe key prefix, it's safe to evict, so return early + if (isSafeEvictionKey(key)) { + return; + } + + // Otherwise, mark the key as non-evictable to protect it + cache.markKeyAsNonEvictable(key); + }); +} + +/** + * Check if a key is in the safe eviction list (allowed to be evicted) + * This avoids duplicate logic between here and OnyxCache + */ +function isSafeEvictionKey(testKey: OnyxKey): boolean { + // Check if the key exactly matches any safe eviction key + if (evictionAllowList.includes(testKey)) { + return true; + } + + // Check if the key starts with any safe eviction key prefix + return evictionAllowList.some((safeKey) => testKey.startsWith(safeKey)); +} + +/** + * Any time a key is accessed or added, we should check if it needs to be protected + * from eviction (if it's not in the safe eviction list) + */ +function addLastAccessedKey(key: OnyxKey): void { + // First, always add the key to the accessed keys for LRU tracking + cache.addToAccessedKeys(key); + + // Check if this key is safe for eviction or not + const isSafeForEviction = isSafeEvictionKey(key); + + // If it's a collection key or not safe for eviction, mark it as non-evictable + if (isCollectionKey(key) || !isSafeForEviction) { + cache.markKeyAsNonEvictable(key); + return; + } + + // For safe eviction keys, mark them as evictable + cache.markKeyAsEvictable(key); + + // Update the recently accessed keys list + removeLastAccessedKey(key); + recentlyAccessedKeys.push(key); } /** @@ -499,11 +549,6 @@ function isKeyMatch(configKey: OnyxKey, key: OnyxKey): boolean { return isCollectionKey(configKey) ? Str.startsWith(key, configKey) : configKey === key; } -/** Checks to see if this key has been flagged as safe for removal. */ -function isSafeEvictionKey(testKey: OnyxKey): boolean { - return evictionAllowList.some((key) => isKeyMatch(key, testKey)); -} - /** * Extracts the collection identifier of a given collection member key. * @@ -582,21 +627,6 @@ function removeLastAccessedKey(key: OnyxKey): void { recentlyAccessedKeys = recentlyAccessedKeys.filter((recentlyAccessedKey) => recentlyAccessedKey !== key); } -/** - * Add a key to the list of recently accessed keys. The least - * recently accessed key should be at the head and the most - * recently accessed key at the tail. - */ -function addLastAccessedKey(key: OnyxKey): void { - // Only specific keys belong in this list since we cannot remove an entire collection. - if (isCollectionKey(key) || !isSafeEvictionKey(key)) { - return; - } - - removeLastAccessedKey(key); - recentlyAccessedKeys.push(key); -} - /** * Take all the keys that are safe to evict and add them to * the recently accessed list when initializing the app. This @@ -1069,6 +1099,8 @@ function sendDataToConnection(mapping: Mapping, valu */ function addKeyToRecentlyAccessedIfNeeded(mapping: Mapping): void { if (!isSafeEvictionKey(mapping.key)) { + // Mark this key as non-evictable since it's not in the safe eviction list + cache.markKeyAsNonEvictable(mapping.key); return; } @@ -1081,6 +1113,15 @@ function addKeyToRecentlyAccessedIfNeeded(mapping: Mapping throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`); } + // If a component specifies it can't evict this key, we mark it as non-evictable + if (mapping.canEvict === false) { + cache.markKeyAsNonEvictable(mapping.key); + } else { + cache.markKeyAsEvictable(mapping.key); + addLastAccessedKey(mapping.key); + } + } else { + cache.markKeyAsEvictable(mapping.key); addLastAccessedKey(mapping.key); } } diff --git a/lib/index.ts b/lib/index.ts index 8e3e53f35..cfb849285 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -22,9 +22,11 @@ import type {Connection} from './OnyxConnectionManager'; import useOnyx from './useOnyx'; import withOnyx from './withOnyx'; import type {WithOnyxState} from './withOnyx/types'; +import OnyxCacheMonitor from './OnyxCacheMonitor'; export default Onyx; export {useOnyx, withOnyx}; +export {OnyxCacheMonitor}; export type { ConnectOptions, CustomTypeOptions,