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
62 changes: 59 additions & 3 deletions src/backends/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Redis from 'ioredis';
import { RedisCacheBackend } from './redis';
import { CacheBackend } from '../types';
import { CacheBackend, CacheConnectionError } from '../types';

// Global Redis client to reuse connections
let globalRedisClient: Redis | null = null;
let connectionPromise: Promise<Redis> | null = null;

/**
* Create a default Redis backend, reusing a global client if available.
Expand All @@ -23,7 +24,7 @@
}

/**
* Get or create the global Redis client
* Get or create the global Redis client with proper error handling
* @returns Redis client instance
*/
function getGlobalRedisClient(): Redis {
Expand All @@ -35,9 +36,64 @@
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0', 10),
// Add connection timeout and retry settings
connectTimeout: 10000,
maxRetriesPerRequest: 3,
lazyConnect: true, // Don't connect immediately
});

// Set up proper error handling
globalRedisClient.on('error', (error) => {
// Only log connection errors, don't throw unhandled rejections
if (process.env.NODE_ENV !== 'test') {
console.warn('Redis connection error:', error.message);

Check warning on line 49 in src/backends/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
}

Check warning on line 50 in src/backends/index.ts

View check run for this annotation

Codecov / codecov/patch

src/backends/index.ts#L49-L50

Added lines #L49 - L50 were not covered by tests
});

globalRedisClient.on('connect', () => {
if (process.env.NODE_ENV !== 'test') {
console.log('Redis connected successfully');

Check warning on line 55 in src/backends/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
}

Check warning on line 56 in src/backends/index.ts

View check run for this annotation

Codecov / codecov/patch

src/backends/index.ts#L54-L56

Added lines #L54 - L56 were not covered by tests
});

// Handle connection promise
connectionPromise = globalRedisClient.connect().then(() => globalRedisClient!).catch((error) => {

Check failure on line 60 in src/backends/index.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
// Reset the promise on error so it can be retried
connectionPromise = null;
const connectionError = new CacheConnectionError(
`Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`
);

// In test environment, don't throw unhandled rejections
if (process.env.NODE_ENV === 'test') {
console.warn('Redis connection failed in test environment:', connectionError.message);

Check warning on line 69 in src/backends/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
return globalRedisClient!;

Check failure on line 70 in src/backends/index.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
}

throw connectionError;

Check warning on line 73 in src/backends/index.ts

View check run for this annotation

Codecov / codecov/patch

src/backends/index.ts#L73

Added line #L73 was not covered by tests
});
}
return globalRedisClient;
}

export { RedisCacheBackend } from './redis';
/**
* Get the connection promise for the global Redis client
* @returns Promise that resolves when Redis is connected
*/
export function getRedisConnectionPromise(): Promise<Redis> | null {
return connectionPromise;
}

Check warning on line 85 in src/backends/index.ts

View check run for this annotation

Codecov / codecov/patch

src/backends/index.ts#L84-L85

Added lines #L84 - L85 were not covered by tests

/**
* Close the global Redis client (useful for cleanup in tests)
*/
export function closeGlobalRedisClient(): void {
if (globalRedisClient) {
globalRedisClient.disconnect();
globalRedisClient = null;
connectionPromise = null;
}
}

export { RedisCacheBackend } from './redis';
export { MemoryCacheBackend } from './memory';
104 changes: 104 additions & 0 deletions src/backends/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { CacheBackend } from '../types';

/**
* In-memory cache backend for testing and development.
* This backend stores data in memory and is not suitable for production use.
*/
export class MemoryCacheBackend<T = unknown> implements CacheBackend<T> {
private store = new Map<string, { value: T; expiresAt?: number }>();
private locks = new Map<string, { expiresAt: number }>();

/**
* Get a value from memory cache.
* @param key - The cache key
*/
async get(key: string): Promise<T | undefined> {
const item = this.store.get(key);
if (!item) return undefined;

// Check if item has expired
if (item.expiresAt && Date.now() > item.expiresAt) {
this.store.delete(key);
return undefined;
}

return item.value;
}

/**
* Set a value in memory cache with optional TTL.
* @param key - The cache key
* @param value - The value to cache
* @param options - Optional TTL in seconds
*/
async set(key: string, value: T, options?: { ttl?: number }): Promise<void> {
const expiresAt = options?.ttl ? Date.now() + (options.ttl * 1000) : undefined;
this.store.set(key, { value, expiresAt });
}

/**
* Delete a value from memory cache.
* @param key - The cache key
*/
async del(key: string): Promise<void> {
this.store.delete(key);
}

/**
* Acquire a lock in memory (with TTL).
* @param key - The lock key
* @param ttl - Lock TTL in seconds
* @returns True if lock acquired, false otherwise
*/
async lock(key: string, ttl: number): Promise<boolean> {
const now = Date.now();
const expiresAt = now + (ttl * 1000);

// Check if lock exists and is still valid
const existingLock = this.locks.get(key);
if (existingLock && existingLock.expiresAt > now) {
return false;
}

// Acquire the lock
this.locks.set(key, { expiresAt });
return true;
}

/**
* Release a lock in memory.
* @param key - The lock key
*/
async unlock(key: string): Promise<void> {
this.locks.delete(key);
}

/**
* Clear all cache entries.
*/
async clear(): Promise<void> {
this.store.clear();
this.locks.clear();
}

/**
* Clean up expired entries (useful for memory management).
*/
cleanup(): void {
const now = Date.now();

// Clean up expired cache entries
for (const [key, item] of this.store.entries()) {
if (item.expiresAt && item.expiresAt <= now) {
this.store.delete(key);
}
}

// Clean up expired locks
for (const [key, lock] of this.locks.entries()) {
if (lock.expiresAt <= now) {
this.locks.delete(key);
}
}
}
}
34 changes: 28 additions & 6 deletions src/backends/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@
try {
const value = await this.client.get(fullKey);
if (value === null) return undefined;

// Fast path for simple values
if (value === 'null') return null as T;
if (value === 'undefined') return undefined;
if (value === 'true') return true as T;
if (value === 'false') return false as T;

// Try to parse as number first (common case)
const num = Number(value);
if (!isNaN(num) && value.trim() === num.toString()) {
return num as T;

Check warning on line 36 in src/backends/redis.ts

View check run for this annotation

Codecov / codecov/patch

src/backends/redis.ts#L36

Added line #L36 was not covered by tests
}

try {
return JSON.parse(value) as T;
} catch (error) {
Expand Down Expand Up @@ -50,12 +63,21 @@
async set(key: string, value: T, options?: { ttl?: number }): Promise<void> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
let str: string;
try {
str = JSON.stringify(value);
} catch (error) {
throw new CacheSerializationError(
`Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`
);

// Fast path for simple values
if (value === null) str = 'null';
else if (value === undefined) str = 'undefined';
else if (typeof value === 'boolean') str = value.toString();
else if (typeof value === 'number') str = value.toString();
else if (typeof value === 'string') str = JSON.stringify(value);
else {
try {
str = JSON.stringify(value);
} catch (error) {
throw new CacheSerializationError(
`Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`
);
}
}

try {
Expand Down
56 changes: 51 additions & 5 deletions src/cache/createCacheHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
version = '',
} = options;

// Simple in-memory cache for frequently accessed keys (L1 cache)
const l1Cache = new Map<string, { value: unknown; expiresAt: number }>();
const L1_CACHE_TTL = 1000; // 1 second TTL for L1 cache

/**
* Get the fully qualified key with prefix and version
*/
Expand All @@ -78,10 +82,22 @@
const fullKey = getFullKey(key);
const fetchOptions = { ...DEFAULT_FETCH_OPTIONS, ...options };

// Try to get from cache first
// Try to get from L1 cache first
const l1Item = l1Cache.get(fullKey);
if (l1Item && l1Item.expiresAt > Date.now()) {
logger.log({ type: 'HIT', key: fullKey });
return l1Item.value as R;
}

Check warning on line 90 in src/cache/createCacheHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/cache/createCacheHandler.ts#L88-L90

Added lines #L88 - L90 were not covered by tests

// Try to get from backend cache
try {
const cached = await backend.get(fullKey) as R | undefined;
if (cached !== undefined) {
// Store in L1 cache for future fast access
l1Cache.set(fullKey, {
value: cached,
expiresAt: Date.now() + L1_CACHE_TTL,
});
logger.log({ type: 'HIT', key: fullKey });
return cached;
}
Expand Down Expand Up @@ -118,6 +134,12 @@
ttl: fetchOptions.ttl,
});

// Also store in L1 cache
l1Cache.set(fullKey, {
value,
expiresAt: Date.now() + L1_CACHE_TTL,
});

// If staleTtl is set, store a stale copy with longer TTL
if (fallbackToStale && fetchOptions.staleTtl && fetchOptions.staleTtl > fetchOptions.ttl) {
const staleKey = `stale:${fullKey}`;
Expand Down Expand Up @@ -183,12 +205,14 @@
// Lock not acquired, wait for the value to be available
logger.log({ type: 'WAIT', key: lockKey });

// Simple polling implementation
// Exponential backoff polling implementation
const startTime = Date.now();
let pollInterval = 50; // Start with 50ms
const maxPollInterval = 500; // Max 500ms between polls

while (Date.now() - startTime < fetchOptions.lockTimeout) {
// Sleep for a short time
await new Promise((resolve) => setTimeout(resolve, 100));
// Sleep with exponential backoff
await new Promise((resolve) => setTimeout(resolve, pollInterval));

// Check if the value is now available
try {
Expand All @@ -198,8 +222,15 @@
}
} catch (error) {
// Continue polling even if get fails
continue;
logger.log({
type: 'ERROR',
key: fullKey,
error: error instanceof Error ? error : new Error(String(error)),
});
}

// Exponential backoff: double the interval, but cap it
pollInterval = Math.min(pollInterval * 1.5, maxPollInterval);
}

// Timeout waiting for the value
Expand All @@ -209,6 +240,21 @@
}
};

/**
* Clean up expired L1 cache entries
*/
const cleanupL1Cache = () => {
const now = Date.now();
for (const [key, item] of l1Cache.entries()) {
if (item.expiresAt <= now) {
l1Cache.delete(key);
}
}
};

Check warning on line 253 in src/cache/createCacheHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/cache/createCacheHandler.ts#L247-L253

Added lines #L247 - L253 were not covered by tests

// Clean up L1 cache periodically
setInterval(cleanupL1Cache, 5000); // Every 5 seconds

return {
fetch,
backend,
Expand Down
21 changes: 16 additions & 5 deletions src/cache/fetchWithCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,26 @@
import type { CacheFetchOptions, CacheHandler } from '../types';
import { createCacheHandler } from './createCacheHandler';
import { createDefaultBackend } from '../backends';
import { MemoryCacheBackend } from '../backends/memory';

// Lazily create the default cache handler when first accessed
let defaultHandler: CacheHandler<unknown> | undefined;
export function getDefaultHandler() {
if (!defaultHandler)
defaultHandler = createCacheHandler({
backend: createDefaultBackend(),
prefix: 'next-cachex',
});
if (!defaultHandler) {
try {
// Try to create a Redis backend first
defaultHandler = createCacheHandler({
backend: createDefaultBackend(),
prefix: 'next-cachex',
});
} catch (error) {
// Fallback to memory backend if Redis is not available (e.g., in tests)
defaultHandler = createCacheHandler({
backend: new MemoryCacheBackend(),
prefix: 'next-cachex',
});
}

Check warning on line 42 in src/cache/fetchWithCache.ts

View check run for this annotation

Codecov / codecov/patch

src/cache/fetchWithCache.ts#L38-L42

Added lines #L38 - L42 were not covered by tests
}
return defaultHandler;
}

Expand Down
3 changes: 3 additions & 0 deletions test/backends/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ vi.mock('ioredis', () => {
set: vi.fn(),
del: vi.fn(),
scan: vi.fn(),
on: vi.fn(),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
})),
};
});
Expand Down
Loading
Loading