From da7968f7cb3ec9c9e7fdd063e41716c3d8308670 Mon Sep 17 00:00:00 2001 From: Sughter99 Date: Sun, 28 Jun 2026 18:26:51 +0100 Subject: [PATCH] feat: add LRU eviction and size cap to image cache --- src/__tests__/utils/imageCacheLRU.test.ts | 146 ++++++++++++++++++++++ src/services/memoryPressureService.ts | 18 +-- src/utils/imageCache.ts | 68 +++++++++- 3 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/utils/imageCacheLRU.test.ts diff --git a/src/__tests__/utils/imageCacheLRU.test.ts b/src/__tests__/utils/imageCacheLRU.test.ts new file mode 100644 index 00000000..c4115965 --- /dev/null +++ b/src/__tests__/utils/imageCacheLRU.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for imageCache LRU eviction — issue #677. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Image } from 'expo-image'; + +import { ImageCache, getCacheStats } from '../../utils/imageCache'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('expo-image', () => ({ + Image: { + prefetch: jest.fn().mockResolvedValue([true]), + clearMemoryCache: jest.fn().mockResolvedValue(undefined), + clearDiskCache: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('../../services/mobileAnalytics', () => ({ + mobileAnalyticsService: { trackEvent: jest.fn() }, +})); + +jest.mock('../../utils/logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +global.fetch = jest.fn() as any; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const ONE_MB = 1024 * 1024; +const MAX_CACHE_BYTES = 100 * ONE_MB; +const EVICTION_THRESHOLD = MAX_CACHE_BYTES * 0.8; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeFetchWithSize(sizeBytes: number) { + return jest.fn().mockResolvedValue({ + headers: { + get: (key: string) => (key === 'content-length' ? String(sizeBytes) : null), + }, + }); +} + +function seedMetadata(entries: { url: string; sizeMB: number; lastAccessedOffset?: number }[]) { + const now = Date.now(); + const built = entries.map(({ url, sizeMB, lastAccessedOffset = 0 }, i) => ({ + url, + size: sizeMB * ONE_MB, + timestamp: now - 10000 + i, + lastAccessed: now + lastAccessedOffset, + hitCount: 0, + })); + const totalSize = built.reduce((sum, e) => sum + e.size, 0); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ entries: built, totalSize }) + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ImageCache LRU eviction — issue #677', () => { + beforeEach(async () => { + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + // Reset cache between tests + await ImageCache.clearCache(); + }); + + it('eviction threshold constant is 80% of 100 MB', () => { + expect(ImageCache.getEvictionThresholdBytes()).toBe(EVICTION_THRESHOLD); + }); + + it('getCacheSizeBytes returns 0 on empty cache', async () => { + await ImageCache.prefetchImages([]); + expect(ImageCache.getCacheSizeBytes()).toBe(0); + }); + + it('eviction fires when cache exceeds 80 MB threshold', async () => { + // Seed 80 MB (at threshold) + seedMetadata( + Array.from({ length: 80 }, (_, i) => ({ + url: `https://example.com/img${i}.jpg`, + sizeMB: 1, + lastAccessedOffset: i * 100, + })) + ); + + global.fetch = makeFetchWithSize(ONE_MB); + (Image.prefetch as jest.Mock).mockResolvedValue([true]); + + // Push 1 more MB — crosses 80 MB threshold → eviction fires + await ImageCache.prefetchImages(['https://example.com/new.jpg']); + + expect(ImageCache.getCacheSizeBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES); + // After eviction, should be below threshold + expect(ImageCache.getCacheSizeBytes()).toBeLessThan(EVICTION_THRESHOLD + ONE_MB); + }); + + it('clearNonCritical removes all entries and resets size to 0', async () => { + seedMetadata([ + { url: 'https://example.com/a.jpg', sizeMB: 10 }, + { url: 'https://example.com/b.jpg', sizeMB: 20 }, + ]); + + // Load metadata by calling prefetch with no new urls + await ImageCache.prefetchImages([]); + + await ImageCache.clearNonCritical(); + + expect(ImageCache.getCacheSizeBytes()).toBe(0); + expect(getCacheStats().entryCount).toBe(0); + }); + + it('getCacheSizeBytes returns correct total after single prefetch', async () => { + global.fetch = makeFetchWithSize(5 * ONE_MB); + (Image.prefetch as jest.Mock).mockResolvedValue([true]); + + await ImageCache.prefetchImages(['https://example.com/test.jpg']); + + expect(ImageCache.getCacheSizeBytes()).toBe(5 * ONE_MB); + }); + + it('cache does not exceed 100 MB after many prefetches', async () => { + global.fetch = makeFetchWithSize(10 * ONE_MB); + (Image.prefetch as jest.Mock).mockResolvedValue([true]); + + const urls = Array.from({ length: 15 }, (_, i) => `https://example.com/big${i}.jpg`); + + await ImageCache.prefetchImages(urls); + + expect(ImageCache.getCacheSizeBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES); + }); +}); diff --git a/src/services/memoryPressureService.ts b/src/services/memoryPressureService.ts index 0f197e6a..f403774c 100644 --- a/src/services/memoryPressureService.ts +++ b/src/services/memoryPressureService.ts @@ -1,14 +1,14 @@ +import { preloadService } from './preloadService'; +import { syncService } from './syncService'; import { ImageCache } from '../utils/imageCache'; import { appLogger } from '../utils/logger'; import { - captureMemorySnapshot, - detectMemoryPressure, - MEMORY_PRESSURE_THRESHOLD, - type MemorySnapshot + captureMemorySnapshot, + detectMemoryPressure, + MEMORY_PRESSURE_THRESHOLD, + type MemorySnapshot, } from '../utils/memoryProfiler'; import { requestQueue } from './api/requestQueue'; -import { preloadService } from './preloadService'; -import { syncService } from './syncService'; const MEMORY_PRESSURE_CHECK_INTERVAL_MS = 10_000; @@ -65,12 +65,12 @@ export class MemoryPressureService { appLogger.warnSync('High memory pressure detected', { usedHeapBytes: snapshot.usedHeapBytes, heapSizeBytes: snapshot.heapSizeBytes, - utilization: `${(utilization * 100).toFixed(0)}%`, + utilization: `${(utilization * 100).toFixed(0)}%`, threshold: `${MEMORY_PRESSURE_THRESHOLD * 100}%`, }); await Promise.allSettled([ - ImageCache.clearCache(), + ImageCache.clearNonCritical(), (async () => { preloadService.pausePrefetch(); })(), @@ -88,7 +88,7 @@ export class MemoryPressureService { appLogger.infoSync('Memory pressure recovered', { usedHeapBytes: snapshot.usedHeapBytes, heapSizeBytes: snapshot.heapSizeBytes, - utilization: `${(utilization * 100).toFixed(0)}%`, + utilization: `${(utilization * 100).toFixed(0)}%`, }); preloadService.resumePrefetch(); diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index d677687e..de60bb52 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -1,11 +1,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Image } from 'expo-image'; -import { mobileAnalyticsService } from '../services/mobileAnalytics'; import { logger } from './logger'; +import { mobileAnalyticsService } from '../services/mobileAnalytics'; const CACHE_METADATA_KEY = '@image-cache-metadata'; const MAX_CACHE_SIZE_BYTES = 100 * 1024 * 1024; +/** Eviction fires when cache reaches 80% of max (80 MB) */ +const EVICTION_THRESHOLD_BYTES = MAX_CACHE_SIZE_BYTES * 0.8; const LOW_STORAGE_THRESHOLD_BYTES = 50 * 1024 * 1024; interface CacheEntry { @@ -47,16 +49,37 @@ async function saveMetadata(): Promise { } } -function evictLRU(): void { +/** + * Evict least-recently-used entries until totalSize is below targetBytes. + * Called automatically when cache reaches EVICTION_THRESHOLD_BYTES (80 MB). + * + * @param targetBytes - Reduce total size to this value. Defaults to 70% of max. + */ +function evictLRU(targetBytes: number = MAX_CACHE_SIZE_BYTES * 0.7): void { metadata.entries.sort((a, b) => a.lastAccessed - b.lastAccessed); - while (metadata.totalSize > MAX_CACHE_SIZE_BYTES && metadata.entries.length > 0) { + while (metadata.totalSize > targetBytes && metadata.entries.length > 0) { const evicted = metadata.entries.shift(); if (evicted) { metadata.totalSize -= evicted.size; + logger.debug(`[ImageCache] Evicted ${evicted.url} (${evicted.size} bytes)`); } } } +/** + * Check if eviction is needed (cache at or above 80% threshold) and run it. + * Called after every prefetch write. + */ +function maybeEvict(): void { + if (metadata.totalSize >= EVICTION_THRESHOLD_BYTES) { + logger.warn( + `[ImageCache] Cache at ${Math.round(metadata.totalSize / (1024 * 1024))}MB, ` + + `triggering LRU eviction (threshold: ${Math.round(EVICTION_THRESHOLD_BYTES / (1024 * 1024))}MB)` + ); + evictLRU(); + } +} + async function recordHit(url: string, entry: CacheEntry): Promise { entry.hitCount++; entry.lastAccessed = Date.now(); @@ -73,7 +96,13 @@ export function getCacheHitRate(): number { return total === 0 ? 0 : cacheHits / total; } -export function getCacheStats(): { hitRate: number; totalSize: number; entryCount: number; hits: number; misses: number } { +export function getCacheStats(): { + hitRate: number; + totalSize: number; + entryCount: number; + hits: number; + misses: number; +} { return { hitRate: getCacheHitRate(), totalSize: metadata.totalSize, @@ -108,7 +137,7 @@ export class ImageCache { hitCount: 0, }); metadata.totalSize += size; - evictLRU(); + maybeEvict(); await saveMetadata(); await recordMiss(url); } @@ -132,6 +161,35 @@ export class ImageCache { await AsyncStorage.removeItem(CACHE_METADATA_KEY); } + /** + * Clear all non-pinned cached images. Called on critical memory pressure. + * Preserves metadata structure but removes all entries and resets size. + */ + static async clearNonCritical(): Promise { + await loadMetadata(); + logger.warn('[ImageCache] clearNonCritical: removing all cached image entries'); + metadata.entries = []; + metadata.totalSize = 0; + await Image.clearMemoryCache(); + await Image.clearDiskCache(); + await saveMetadata(); + } + + /** + * Returns current total cache size in bytes. + * Used by memoryPressureService to expose cache size as a metric. + */ + static getCacheSizeBytes(): number { + return metadata.totalSize; + } + + /** + * Returns the 80% eviction threshold in bytes. + */ + static getEvictionThresholdBytes(): number { + return EVICTION_THRESHOLD_BYTES; + } + static async clearMemoryCache(): Promise { await Image.clearMemoryCache(); }