Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/__tests__/utils/imageCacheLRU.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 9 additions & 9 deletions src/services/memoryPressureService.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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();
})(),
Expand All @@ -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();
Expand Down
68 changes: 63 additions & 5 deletions src/utils/imageCache.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -47,16 +49,37 @@ async function saveMetadata(): Promise<void> {
}
}

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<void> {
entry.hitCount++;
entry.lastAccessed = Date.now();
Expand All @@ -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,
Expand Down Expand Up @@ -108,7 +137,7 @@ export class ImageCache {
hitCount: 0,
});
metadata.totalSize += size;
evictLRU();
maybeEvict();
await saveMetadata();
await recordMiss(url);
}
Expand All @@ -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<void> {
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<void> {
await Image.clearMemoryCache();
}
Expand Down
Loading