feat: P1 보안/성능 개선 및 마이그레이션 자동화
Security fixes: - migrate.ts: SQL/Command Injection 방지 (spawnSync 사용) - migrate.ts: Path Traversal 검증 추가 - api-tester.ts: API 키 마스킹 (4자만 노출) - api-tester.ts: 최소 16자 키 길이 검증 - cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한) Performance improvements: - cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩) - cache.ts: KV 등록 fire-and-forget (non-blocking) - cache.ts: 메모리 제한 (5000키) - cache.ts: 25초 실행 시간 가드 - cache.ts: 패턴 매칭 prefix 최적화 New features: - 마이그레이션 자동화 시스템 (scripts/migrate.ts) - KV 기반 캐시 인덱스 (invalidatePattern, clearAll) - 글로벌 CacheService 싱글톤 Other: - .env.example 추가, API 키 환경변수 처리 - CACHE_TTL.RECOMMENDATIONS (10분) 분리 - e2e-tester.ts JSON 파싱 에러 핸들링 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ export const CACHE_TTL = {
|
||||
HEALTH: 30,
|
||||
/** Cache TTL for pricing data (1 hour) */
|
||||
PRICING: 3600,
|
||||
/** Cache TTL for recommendation results (10 minutes) */
|
||||
RECOMMENDATIONS: 600,
|
||||
/** Default cache TTL (5 minutes) */
|
||||
DEFAULT: 300,
|
||||
} as const;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Env, InstanceQueryParams } from '../types';
|
||||
import { QueryService } from '../services/query';
|
||||
import { CacheService } from '../services/cache';
|
||||
import { getGlobalCacheService } from '../services/cache';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
SUPPORTED_PROVIDERS,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
* Note: Worker instances are recreated periodically, preventing memory leaks
|
||||
*/
|
||||
let cachedQueryService: QueryService | null = null;
|
||||
let cachedCacheService: CacheService | null = null;
|
||||
let cachedDb: D1Database | null = null;
|
||||
|
||||
/**
|
||||
@@ -49,18 +48,6 @@ function getQueryService(db: D1Database, env: Env): QueryService {
|
||||
return cachedQueryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create CacheService singleton
|
||||
* Lazy initialization on first request, then reused for subsequent requests
|
||||
*/
|
||||
function getCacheService(): CacheService {
|
||||
if (!cachedCacheService) {
|
||||
cachedCacheService = new CacheService(CACHE_TTL.INSTANCES);
|
||||
logger.debug('[Instances] CacheService singleton initialized');
|
||||
}
|
||||
return cachedCacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed and validated query parameters
|
||||
*/
|
||||
@@ -359,8 +346,8 @@ export async function handleInstances(
|
||||
const params = parseResult.params!;
|
||||
logger.info('[Instances] Query params validated', params as unknown as Record<string, unknown>);
|
||||
|
||||
// Get cache service singleton (reused across requests)
|
||||
const cacheService = getCacheService();
|
||||
// Get global cache service singleton (shared across all routes)
|
||||
const cacheService = getGlobalCacheService(CACHE_TTL.INSTANCES, env.RATE_LIMIT_KV);
|
||||
|
||||
// Generate cache key from query parameters
|
||||
const cacheKey = cacheService.generateKey(params as unknown as Record<string, unknown>);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { Env, ScaleType } from '../types';
|
||||
import { RecommendationService } from '../services/recommendation';
|
||||
import { validateStack, STACK_REQUIREMENTS } from '../services/stackConfig';
|
||||
import { CacheService } from '../services/cache';
|
||||
import { getGlobalCacheService } from '../services/cache';
|
||||
import { logger } from '../utils/logger';
|
||||
import { HTTP_STATUS, CACHE_TTL, REQUEST_LIMITS } from '../constants';
|
||||
import {
|
||||
@@ -33,23 +33,6 @@ interface RecommendRequestBody {
|
||||
*/
|
||||
const SUPPORTED_SCALES: readonly ScaleType[] = ['small', 'medium', 'large'] as const;
|
||||
|
||||
/**
|
||||
* Cached CacheService instance for singleton pattern
|
||||
*/
|
||||
let cachedCacheService: CacheService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create CacheService singleton
|
||||
*
|
||||
* @returns CacheService instance with INSTANCES TTL
|
||||
*/
|
||||
function getCacheService(): CacheService {
|
||||
if (!cachedCacheService) {
|
||||
cachedCacheService = new CacheService(CACHE_TTL.INSTANCES);
|
||||
}
|
||||
return cachedCacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /recommend endpoint
|
||||
*
|
||||
@@ -174,7 +157,7 @@ export async function handleRecommend(request: Request, env: Env): Promise<Respo
|
||||
// 7. Initialize cache service and generate cache key
|
||||
logger.info('[Recommend] Validation passed', { stack, scale, budgetMax });
|
||||
|
||||
const cacheService = getCacheService();
|
||||
const cacheService = getGlobalCacheService(CACHE_TTL.RECOMMENDATIONS, env.RATE_LIMIT_KV);
|
||||
|
||||
// Generate cache key from sorted stack, scale, and budget
|
||||
// Sort stack to ensure consistent cache keys regardless of order
|
||||
@@ -224,7 +207,7 @@ export async function handleRecommend(request: Request, env: Env): Promise<Respo
|
||||
{
|
||||
status: HTTP_STATUS.OK,
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -258,7 +241,7 @@ export async function handleRecommend(request: Request, env: Env): Promise<Respo
|
||||
|
||||
// 10. Store result in cache
|
||||
try {
|
||||
await cacheService.set(cacheKey, responseData, CACHE_TTL.INSTANCES);
|
||||
await cacheService.set(cacheKey, responseData, CACHE_TTL.RECOMMENDATIONS);
|
||||
} catch (error) {
|
||||
// Graceful degradation: log error but don't fail the request
|
||||
logger.error('[Recommend] Cache write failed',
|
||||
@@ -273,7 +256,7 @@ export async function handleRecommend(request: Request, env: Env): Promise<Respo
|
||||
{
|
||||
status: HTTP_STATUS.OK,
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
374
src/services/cache-kv.test.ts
Normal file
374
src/services/cache-kv.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* CacheService KV Index Tests
|
||||
*
|
||||
* Tests for KV-based cache index functionality:
|
||||
* - Cache key registration and unregistration
|
||||
* - Pattern-based cache invalidation
|
||||
* - clearAll() with KV enumeration
|
||||
* - Backward compatibility without KV
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CacheService } from './cache';
|
||||
|
||||
/**
|
||||
* Mock Cloudflare Cache API
|
||||
*/
|
||||
const createMockCache = () => {
|
||||
const store = new Map<string, Response>();
|
||||
|
||||
return {
|
||||
async match(key: string) {
|
||||
return store.get(key) || null;
|
||||
},
|
||||
|
||||
async put(key: string, response: Response) {
|
||||
store.set(key, response);
|
||||
},
|
||||
|
||||
async delete(key: string) {
|
||||
return store.delete(key);
|
||||
},
|
||||
|
||||
// For testing: expose internal store
|
||||
_getStore: () => store
|
||||
} as any as Cache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock KV namespace
|
||||
*/
|
||||
const createMockKV = () => {
|
||||
const store = new Map<string, { value: string; metadata?: any; expirationTtl?: number }>();
|
||||
|
||||
return {
|
||||
async get(key: string) {
|
||||
const entry = store.get(key);
|
||||
return entry ? entry.value : null;
|
||||
},
|
||||
|
||||
async put(key: string, value: string, options?: { expirationTtl?: number; metadata?: any }) {
|
||||
store.set(key, { value, metadata: options?.metadata, expirationTtl: options?.expirationTtl });
|
||||
},
|
||||
|
||||
async delete(key: string) {
|
||||
return store.delete(key);
|
||||
},
|
||||
|
||||
async list(options?: { prefix?: string; cursor?: string }) {
|
||||
const prefix = options?.prefix || '';
|
||||
const keys = Array.from(store.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(name => ({ name }));
|
||||
|
||||
return {
|
||||
keys,
|
||||
list_complete: true,
|
||||
cursor: undefined
|
||||
};
|
||||
},
|
||||
|
||||
// For testing: expose internal store
|
||||
_getStore: () => store
|
||||
} as any as KVNamespace;
|
||||
};
|
||||
|
||||
describe('CacheService - KV Index Integration', () => {
|
||||
let mockCache: ReturnType<typeof createMockCache>;
|
||||
let mockKV: ReturnType<typeof createMockKV>;
|
||||
let cache: CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock global caches
|
||||
mockCache = createMockCache();
|
||||
(global as any).caches = {
|
||||
default: mockCache
|
||||
};
|
||||
|
||||
mockKV = createMockKV();
|
||||
cache = new CacheService(300, mockKV);
|
||||
});
|
||||
|
||||
describe('Cache Key Registration', () => {
|
||||
it('should register cache keys in KV index when setting cache', async () => {
|
||||
const key = 'https://cache.internal/instances?provider=linode';
|
||||
await cache.set(key, { data: 'test' });
|
||||
|
||||
const kvStore = mockKV._getStore();
|
||||
const indexKey = `cache_index:${key}`;
|
||||
|
||||
expect(kvStore.has(indexKey)).toBe(true);
|
||||
const entry = kvStore.get(indexKey);
|
||||
expect(entry?.metadata.ttl).toBe(300);
|
||||
});
|
||||
|
||||
it('should unregister cache keys when deleting cache', async () => {
|
||||
const key = 'https://cache.internal/instances?provider=linode';
|
||||
await cache.set(key, { data: 'test' });
|
||||
await cache.delete(key);
|
||||
|
||||
const kvStore = mockKV._getStore();
|
||||
const indexKey = `cache_index:${key}`;
|
||||
|
||||
expect(kvStore.has(indexKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should use cache TTL for KV index expiration', async () => {
|
||||
const key = 'https://cache.internal/instances?provider=linode';
|
||||
const customTTL = 600;
|
||||
await cache.set(key, { data: 'test' }, customTTL);
|
||||
|
||||
const kvStore = mockKV._getStore();
|
||||
const indexKey = `cache_index:${key}`;
|
||||
const entry = kvStore.get(indexKey);
|
||||
|
||||
expect(entry?.expirationTtl).toBe(customTTL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern Invalidation', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup test cache entries
|
||||
await cache.set('https://cache.internal/instances?provider=linode', { data: 'linode-1' });
|
||||
await cache.set('https://cache.internal/instances?provider=vultr', { data: 'vultr-1' });
|
||||
await cache.set('https://cache.internal/pricing?provider=linode', { data: 'pricing-1' });
|
||||
await cache.set('https://cache.internal/pricing?provider=vultr', { data: 'pricing-2' });
|
||||
});
|
||||
|
||||
it('should invalidate cache entries matching wildcard pattern', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*instances*');
|
||||
|
||||
expect(invalidated).toBe(2);
|
||||
|
||||
// Verify only instances entries were deleted
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(2); // Only pricing entries remain
|
||||
});
|
||||
|
||||
it('should invalidate cache entries matching specific provider pattern', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*provider=linode*');
|
||||
|
||||
expect(invalidated).toBe(2); // Both instances and pricing for linode
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(2); // Only vultr entries remain
|
||||
});
|
||||
|
||||
it('should invalidate cache entries matching exact pattern', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*pricing?provider=vultr*');
|
||||
|
||||
expect(invalidated).toBe(1);
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(3);
|
||||
});
|
||||
|
||||
it('should return 0 when no entries match pattern', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*nonexistent*');
|
||||
|
||||
expect(invalidated).toBe(0);
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(4); // All entries remain
|
||||
});
|
||||
|
||||
it('should handle regex special characters in pattern', async () => {
|
||||
const keyWithSpecialChars = 'https://cache.internal/test?query=value+test';
|
||||
await cache.set(keyWithSpecialChars, { data: 'test' });
|
||||
|
||||
const invalidated = await cache.invalidatePattern('*query=value+test*');
|
||||
|
||||
expect(invalidated).toBe(1);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*INSTANCES*');
|
||||
|
||||
expect(invalidated).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAll() with KV Index', () => {
|
||||
beforeEach(async () => {
|
||||
await cache.set('https://cache.internal/instances?provider=linode', { data: 'test-1' });
|
||||
await cache.set('https://cache.internal/instances?provider=vultr', { data: 'test-2' });
|
||||
await cache.set('https://cache.internal/pricing?provider=linode', { data: 'test-3' });
|
||||
});
|
||||
|
||||
it('should clear all cache entries', async () => {
|
||||
const cleared = await cache.clearAll();
|
||||
|
||||
expect(cleared).toBe(3);
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear cache entries with prefix filter', async () => {
|
||||
const cleared = await cache.clearAll('https://cache.internal/instances');
|
||||
|
||||
expect(cleared).toBe(2);
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(1); // Only pricing entry remains
|
||||
});
|
||||
|
||||
it('should return 0 when no entries exist', async () => {
|
||||
await cache.clearAll();
|
||||
const cleared = await cache.clearAll();
|
||||
|
||||
expect(cleared).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Statistics', () => {
|
||||
it('should return indexed key count when KV is available', async () => {
|
||||
await cache.set('https://cache.internal/test1', { data: 'test' });
|
||||
await cache.set('https://cache.internal/test2', { data: 'test' });
|
||||
|
||||
const stats = await cache.getStats();
|
||||
|
||||
expect(stats.supported).toBe(true);
|
||||
expect(stats.indexed_keys).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 indexed keys when cache is empty', async () => {
|
||||
const stats = await cache.getStats();
|
||||
|
||||
expect(stats.supported).toBe(true);
|
||||
expect(stats.indexed_keys).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should gracefully handle KV registration failures', async () => {
|
||||
const mockKVWithError = {
|
||||
...mockKV,
|
||||
put: vi.fn().mockRejectedValue(new Error('KV write failed'))
|
||||
} as any;
|
||||
|
||||
const cacheWithError = new CacheService(300, mockKVWithError);
|
||||
|
||||
// Should not throw error, should gracefully degrade
|
||||
await expect(cacheWithError.set('test-key', { data: 'test' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should gracefully handle KV unregistration failures', async () => {
|
||||
await cache.set('test-key', { data: 'test' });
|
||||
|
||||
const mockKVWithError = {
|
||||
...mockKV,
|
||||
delete: vi.fn().mockRejectedValue(new Error('KV delete failed'))
|
||||
} as any;
|
||||
|
||||
// Replace KV namespace with error mock
|
||||
(cache as any).kvNamespace = mockKVWithError;
|
||||
|
||||
// Should not throw error, should gracefully degrade
|
||||
const deleted = await cache.delete('test-key');
|
||||
expect(deleted).toBe(true); // Cache delete succeeded
|
||||
});
|
||||
|
||||
it('should return empty array on KV list failures', async () => {
|
||||
await cache.set('test-key', { data: 'test' });
|
||||
|
||||
const mockKVWithError = {
|
||||
...mockKV,
|
||||
list: vi.fn().mockRejectedValue(new Error('KV list failed'))
|
||||
} as any;
|
||||
|
||||
// Replace KV namespace with error mock
|
||||
(cache as any).kvNamespace = mockKVWithError;
|
||||
|
||||
const cleared = await cache.clearAll();
|
||||
expect(cleared).toBe(0); // Graceful degradation
|
||||
});
|
||||
});
|
||||
|
||||
describe('KV Index Pagination', () => {
|
||||
it('should handle large numbers of cache keys', async () => {
|
||||
// Create 150 cache entries to test pagination
|
||||
const promises = [];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
promises.push(cache.set(`https://cache.internal/test${i}`, { data: `test-${i}` }));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const stats = await cache.getStats();
|
||||
expect(stats.indexed_keys).toBe(150);
|
||||
|
||||
const cleared = await cache.clearAll();
|
||||
expect(cleared).toBe(150);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CacheService - Backward Compatibility (No KV)', () => {
|
||||
let mockCache: ReturnType<typeof createMockCache>;
|
||||
let cache: CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCache = createMockCache();
|
||||
(global as any).caches = {
|
||||
default: mockCache
|
||||
};
|
||||
|
||||
// Initialize without KV namespace
|
||||
cache = new CacheService(300);
|
||||
});
|
||||
|
||||
describe('Pattern Invalidation without KV', () => {
|
||||
it('should return 0 and log warning when KV is not available', async () => {
|
||||
const invalidated = await cache.invalidatePattern('*instances*');
|
||||
|
||||
expect(invalidated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAll() without KV', () => {
|
||||
it('should return 0 and log info when KV is not available', async () => {
|
||||
const cleared = await cache.clearAll();
|
||||
|
||||
expect(cleared).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 with prefix filter when KV is not available', async () => {
|
||||
const cleared = await cache.clearAll('https://cache.internal/instances');
|
||||
|
||||
expect(cleared).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Statistics without KV', () => {
|
||||
it('should indicate not supported when KV is not available', async () => {
|
||||
const stats = await cache.getStats();
|
||||
|
||||
expect(stats.supported).toBe(false);
|
||||
expect(stats.indexed_keys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic Cache Operations (no KV impact)', () => {
|
||||
it('should set and get cache entries normally without KV', async () => {
|
||||
const key = 'https://cache.internal/test';
|
||||
const data = { value: 'test-data' };
|
||||
|
||||
await cache.set(key, data);
|
||||
const result = await cache.get<typeof data>(key);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.data).toEqual(data);
|
||||
});
|
||||
|
||||
it('should delete cache entries normally without KV', async () => {
|
||||
const key = 'https://cache.internal/test';
|
||||
await cache.set(key, { data: 'test' });
|
||||
|
||||
const deleted = await cache.delete(key);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const result = await cache.get(key);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,23 @@
|
||||
* - Cache key generation with sorted parameters
|
||||
* - Cache age tracking and metadata
|
||||
* - Graceful degradation on cache failures
|
||||
* - KV-based cache index for pattern invalidation and enumeration
|
||||
*
|
||||
* @example
|
||||
* // Basic usage (no KV index)
|
||||
* const cache = new CacheService(CACHE_TTL.INSTANCES);
|
||||
* await cache.set('key', data, CACHE_TTL.PRICING);
|
||||
* const result = await cache.get<MyType>('key');
|
||||
* if (result) {
|
||||
* console.log(result.cache_age_seconds);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // With KV index (enables pattern invalidation)
|
||||
* const cache = new CacheService(CACHE_TTL.INSTANCES, env.RATE_LIMIT_KV);
|
||||
* await cache.set('https://cache.internal/instances?provider=linode', data);
|
||||
* await cache.invalidatePattern('*instances*'); // Invalidate all instance caches
|
||||
* const count = await cache.clearAll(); // Clear all cache entries (returns actual count)
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -34,23 +43,57 @@ export interface CacheResult<T> {
|
||||
cached_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global CacheService singleton
|
||||
* Prevents race conditions from multiple route-level singletons
|
||||
*/
|
||||
let globalCacheService: CacheService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create global CacheService singleton
|
||||
* Thread-safe factory function that ensures only one CacheService instance exists
|
||||
*
|
||||
* @param ttl - TTL in seconds for cache entries
|
||||
* @param kv - KV namespace for cache index (enables pattern invalidation)
|
||||
* @returns Global CacheService singleton instance
|
||||
*
|
||||
* @example
|
||||
* const cache = getGlobalCacheService(CACHE_TTL.INSTANCES, env.RATE_LIMIT_KV);
|
||||
*/
|
||||
export function getGlobalCacheService(ttl: number, kv: KVNamespace | null): CacheService {
|
||||
if (!globalCacheService) {
|
||||
globalCacheService = new CacheService(ttl, kv);
|
||||
logger.debug('[CacheService] Global singleton initialized');
|
||||
}
|
||||
return globalCacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheService - Manages cache operations using Cloudflare Workers Cache API
|
||||
*/
|
||||
export class CacheService {
|
||||
private cache: Cache;
|
||||
private defaultTTL: number;
|
||||
private kvNamespace: KVNamespace | null;
|
||||
private readonly CACHE_INDEX_PREFIX = 'cache_index:';
|
||||
private readonly BATCH_SIZE = 50;
|
||||
private readonly MAX_KEYS_LIMIT = 5000;
|
||||
private readonly MAX_PATTERN_LENGTH = 200;
|
||||
private readonly MAX_WILDCARD_COUNT = 5;
|
||||
private readonly OPERATION_TIMEOUT_MS = 25000;
|
||||
|
||||
/**
|
||||
* Initialize cache service
|
||||
*
|
||||
* @param ttlSeconds - Default TTL in seconds (default: CACHE_TTL.DEFAULT)
|
||||
* @param kvNamespace - Optional KV namespace for cache index (enables pattern invalidation)
|
||||
*/
|
||||
constructor(ttlSeconds = CACHE_TTL.DEFAULT) {
|
||||
constructor(ttlSeconds: number = CACHE_TTL.DEFAULT, kvNamespace: KVNamespace | null = null) {
|
||||
// Use Cloudflare Workers global caches.default
|
||||
this.cache = caches.default;
|
||||
this.defaultTTL = ttlSeconds;
|
||||
logger.debug(`[CacheService] Initialized with default TTL: ${ttlSeconds}s`);
|
||||
this.kvNamespace = kvNamespace;
|
||||
logger.debug(`[CacheService] Initialized with default TTL: ${ttlSeconds}s, KV index: ${!!kvNamespace}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +166,17 @@ export class CacheService {
|
||||
|
||||
// Store in cache
|
||||
await this.cache.put(key, response);
|
||||
|
||||
// Register key in KV index if available (fire-and-forget)
|
||||
if (this.kvNamespace) {
|
||||
this._registerCacheKey(key, ttl).catch(error => {
|
||||
logger.error('[CacheService] Failed to register cache key (non-blocking)', {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`[CacheService] Cached: ${key} (TTL: ${ttl}s)`);
|
||||
|
||||
} catch (error) {
|
||||
@@ -143,6 +197,11 @@ export class CacheService {
|
||||
try {
|
||||
const deleted = await this.cache.delete(key);
|
||||
|
||||
// Unregister key from KV index if available
|
||||
if (this.kvNamespace && deleted) {
|
||||
await this._unregisterCacheKey(key);
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
logger.debug(`[CacheService] Deleted: ${key}`);
|
||||
} else {
|
||||
@@ -183,13 +242,11 @@ export class CacheService {
|
||||
/**
|
||||
* Clear all cache entries with optional prefix filter
|
||||
*
|
||||
* Note: The Cloudflare Workers Cache API doesn't support listing/enumerating keys,
|
||||
* so this method can only track operations via logging. Individual entries will
|
||||
* expire based on their TTL. For production use cases requiring enumeration,
|
||||
* consider using KV-backed cache index.
|
||||
* With KV index: Enumerates and deletes all matching cache entries
|
||||
* Without KV index: Logs operation only (entries expire based on TTL)
|
||||
*
|
||||
* @param prefix - Optional URL prefix to filter entries (e.g., 'https://cache.internal/instances')
|
||||
* @returns Number of entries cleared (0, as enumeration is not supported)
|
||||
* @returns Number of entries cleared
|
||||
*
|
||||
* @example
|
||||
* // Clear all cache entries
|
||||
@@ -202,18 +259,52 @@ export class CacheService {
|
||||
try {
|
||||
const targetPrefix = prefix ?? 'https://cache.internal/';
|
||||
|
||||
// The Cache API doesn't support listing keys directly
|
||||
// We log the clear operation for audit purposes
|
||||
// Individual entries will naturally expire based on TTL
|
||||
// If KV index is not available, log and return 0
|
||||
if (!this.kvNamespace) {
|
||||
logger.info('[CacheService] Cache clearAll requested (no KV index)', {
|
||||
prefix: targetPrefix,
|
||||
note: 'Individual entries will expire based on TTL. Enable KV namespace for enumeration.'
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.info('[CacheService] Cache clearAll requested', {
|
||||
// List all cache keys from KV index
|
||||
const cacheKeys = await this._listCacheKeys(targetPrefix);
|
||||
|
||||
if (cacheKeys.length === 0) {
|
||||
logger.info('[CacheService] No cache entries to clear', { prefix: targetPrefix });
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete cache entries in parallel batches with timeout
|
||||
const startTime = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (let i = 0; i < cacheKeys.length; i += this.BATCH_SIZE) {
|
||||
// Check timeout
|
||||
if (Date.now() - startTime > this.OPERATION_TIMEOUT_MS) {
|
||||
logger.warn('[CacheService] clearAll timeout reached', {
|
||||
deleted_count: deletedCount,
|
||||
total_keys: cacheKeys.length,
|
||||
timeout_ms: this.OPERATION_TIMEOUT_MS
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = cacheKeys.slice(i, i + this.BATCH_SIZE);
|
||||
const deletePromises = batch.map(key => this.delete(key));
|
||||
const results = await Promise.all(deletePromises);
|
||||
|
||||
deletedCount += results.filter(deleted => deleted).length;
|
||||
}
|
||||
|
||||
logger.info('[CacheService] Cache cleared', {
|
||||
prefix: targetPrefix,
|
||||
note: 'Individual entries will expire based on TTL. Consider using KV-backed cache index for enumeration.'
|
||||
total_keys: cacheKeys.length,
|
||||
deleted_count: deletedCount
|
||||
});
|
||||
|
||||
// Return 0 as we can't enumerate Cache API entries
|
||||
// In production, use KV-backed cache index for enumeration
|
||||
return 0;
|
||||
return deletedCount;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CacheService] Cache clearAll failed', {
|
||||
@@ -264,14 +355,98 @@ export class CacheService {
|
||||
|
||||
/**
|
||||
* Invalidate all cache entries matching a pattern
|
||||
* Note: Cloudflare Workers Cache API doesn't support pattern matching
|
||||
* This method is for future implementation with KV or custom cache index
|
||||
*
|
||||
* @param pattern - Pattern to match (e.g., 'instances:*')
|
||||
* Supports wildcards and pattern matching via KV index.
|
||||
* Without KV index, logs warning and returns 0.
|
||||
*
|
||||
* @param pattern - Pattern to match (supports * wildcard)
|
||||
* @returns Number of entries invalidated
|
||||
*
|
||||
* @example
|
||||
* // Invalidate all instance cache entries
|
||||
* await cache.invalidatePattern('*instances*');
|
||||
*
|
||||
* // Invalidate all cache entries for a specific provider
|
||||
* await cache.invalidatePattern('*provider=linode*');
|
||||
*
|
||||
* // Invalidate all pricing cache entries
|
||||
* await cache.invalidatePattern('*pricing*');
|
||||
*/
|
||||
async invalidatePattern(pattern: string): Promise<void> {
|
||||
logger.warn(`[CacheService] Pattern invalidation not supported: ${pattern}`);
|
||||
// TODO: Implement with KV-based cache index if needed
|
||||
async invalidatePattern(pattern: string): Promise<number> {
|
||||
try {
|
||||
// If KV index is not available, log warning
|
||||
if (!this.kvNamespace) {
|
||||
logger.warn('[CacheService] Pattern invalidation not available (no KV index)', { pattern });
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ReDoS prevention: validate pattern
|
||||
this._validatePattern(pattern);
|
||||
|
||||
// Extract prefix from pattern for KV-level filtering
|
||||
// e.g., "instances*" -> prefix "instances"
|
||||
// e.g., "*instances*" -> no prefix (full scan)
|
||||
const prefixMatch = pattern.match(/^([^*?]+)/);
|
||||
const kvPrefix = prefixMatch ? prefixMatch[1] : undefined;
|
||||
|
||||
if (kvPrefix) {
|
||||
logger.debug(`[CacheService] Using KV prefix filter: "${kvPrefix}" for pattern: "${pattern}"`);
|
||||
}
|
||||
|
||||
// List cache keys with KV-side prefix filtering
|
||||
const allKeys = await this._listCacheKeys(kvPrefix);
|
||||
|
||||
// Convert pattern to regex (escape special chars except *)
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
|
||||
// Filter keys matching pattern
|
||||
const matchingKeys = allKeys.filter(key => regex.test(key));
|
||||
|
||||
if (matchingKeys.length === 0) {
|
||||
logger.info('[CacheService] No cache entries match pattern', { pattern });
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete matching cache entries in parallel batches with timeout
|
||||
const startTime = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (let i = 0; i < matchingKeys.length; i += this.BATCH_SIZE) {
|
||||
// Check timeout
|
||||
if (Date.now() - startTime > this.OPERATION_TIMEOUT_MS) {
|
||||
logger.warn('[CacheService] invalidatePattern timeout reached', {
|
||||
deleted_count: deletedCount,
|
||||
total_matches: matchingKeys.length,
|
||||
timeout_ms: this.OPERATION_TIMEOUT_MS
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = matchingKeys.slice(i, i + this.BATCH_SIZE);
|
||||
const deletePromises = batch.map(key => this.delete(key));
|
||||
const results = await Promise.all(deletePromises);
|
||||
|
||||
deletedCount += results.filter(deleted => deleted).length;
|
||||
}
|
||||
|
||||
logger.info('[CacheService] Pattern invalidation complete', {
|
||||
pattern,
|
||||
total_matches: matchingKeys.length,
|
||||
deleted_count: deletedCount
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CacheService] Pattern invalidation failed', {
|
||||
pattern,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,8 +456,160 @@ export class CacheService {
|
||||
*
|
||||
* @returns Cache statistics (not available in Cloudflare Workers)
|
||||
*/
|
||||
async getStats(): Promise<{ supported: boolean }> {
|
||||
logger.warn('[CacheService] Cache statistics not available in Cloudflare Workers');
|
||||
return { supported: false };
|
||||
async getStats(): Promise<{ supported: boolean; indexed_keys?: number }> {
|
||||
if (!this.kvNamespace) {
|
||||
logger.warn('[CacheService] Cache statistics not available in Cloudflare Workers');
|
||||
return { supported: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const allKeys = await this._listCacheKeys();
|
||||
return {
|
||||
supported: true,
|
||||
indexed_keys: allKeys.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[CacheService] Failed to get cache stats', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return { supported: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cache key in KV index
|
||||
*
|
||||
* @param key - Cache key (URL format)
|
||||
* @param ttlSeconds - TTL in seconds
|
||||
*/
|
||||
private async _registerCacheKey(key: string, ttlSeconds: number): Promise<void> {
|
||||
if (!this.kvNamespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexKey = `${this.CACHE_INDEX_PREFIX}${key}`;
|
||||
const metadata = {
|
||||
cached_at: new Date().toISOString(),
|
||||
ttl: ttlSeconds
|
||||
};
|
||||
|
||||
// Store with same TTL as cache entry
|
||||
// KV will auto-delete when expired, keeping index clean
|
||||
await this.kvNamespace.put(indexKey, '1', {
|
||||
expirationTtl: ttlSeconds,
|
||||
metadata
|
||||
});
|
||||
|
||||
logger.debug(`[CacheService] Registered cache key in index: ${key}`);
|
||||
} catch (error) {
|
||||
// Graceful degradation: log error but don't fail cache operation
|
||||
logger.error('[CacheService] Failed to register cache key', {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister cache key from KV index
|
||||
*
|
||||
* @param key - Cache key (URL format)
|
||||
*/
|
||||
private async _unregisterCacheKey(key: string): Promise<void> {
|
||||
if (!this.kvNamespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexKey = `${this.CACHE_INDEX_PREFIX}${key}`;
|
||||
await this.kvNamespace.delete(indexKey);
|
||||
logger.debug(`[CacheService] Unregistered cache key from index: ${key}`);
|
||||
} catch (error) {
|
||||
// Graceful degradation: log error but don't fail delete operation
|
||||
logger.error('[CacheService] Failed to unregister cache key', {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all cache keys from KV index
|
||||
*
|
||||
* @param prefix - Optional prefix to filter keys (matches original cache key, not index key)
|
||||
* @param maxKeys - Maximum number of keys to return (default: 5000)
|
||||
* @returns Array of cache keys
|
||||
*/
|
||||
private async _listCacheKeys(prefix?: string, maxKeys = this.MAX_KEYS_LIMIT): Promise<string[]> {
|
||||
if (!this.kvNamespace) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
// List all keys with cache_index: prefix
|
||||
do {
|
||||
const result = await this.kvNamespace.list({
|
||||
prefix: this.CACHE_INDEX_PREFIX,
|
||||
cursor
|
||||
});
|
||||
|
||||
// Extract original cache keys (remove cache_index: prefix)
|
||||
const extractedKeys = result.keys
|
||||
.map(k => k.name.substring(this.CACHE_INDEX_PREFIX.length))
|
||||
.filter(k => !prefix || k.startsWith(prefix));
|
||||
|
||||
keys.push(...extractedKeys);
|
||||
|
||||
// Check if we've exceeded the max keys limit
|
||||
if (keys.length >= maxKeys) {
|
||||
logger.warn('[CacheService] Cache key limit reached', {
|
||||
max_keys: maxKeys,
|
||||
current_count: keys.length,
|
||||
note: 'Consider increasing MAX_KEYS_LIMIT or implementing pagination'
|
||||
});
|
||||
return keys.slice(0, maxKeys);
|
||||
}
|
||||
|
||||
cursor = result.list_complete ? undefined : result.cursor;
|
||||
} while (cursor);
|
||||
|
||||
logger.debug(`[CacheService] Listed ${keys.length} cache keys from index`, { prefix });
|
||||
return keys;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CacheService] Failed to list cache keys', {
|
||||
prefix,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate pattern for ReDoS prevention
|
||||
*
|
||||
* @param pattern - Pattern to validate
|
||||
* @throws Error if pattern is invalid
|
||||
*/
|
||||
private _validatePattern(pattern: string): void {
|
||||
// Check pattern length
|
||||
if (pattern.length > this.MAX_PATTERN_LENGTH) {
|
||||
throw new Error(`Pattern too long (max ${this.MAX_PATTERN_LENGTH} chars): ${pattern.length} chars`);
|
||||
}
|
||||
|
||||
// Check for consecutive wildcards (**) which can cause ReDoS
|
||||
if (pattern.includes('**')) {
|
||||
throw new Error('Consecutive wildcards (**) not allowed (ReDoS prevention)');
|
||||
}
|
||||
|
||||
// Count wildcards
|
||||
const wildcardCount = (pattern.match(/\*/g) || []).length;
|
||||
if (wildcardCount > this.MAX_WILDCARD_COUNT) {
|
||||
throw new Error(`Too many wildcards (max ${this.MAX_WILDCARD_COUNT}): ${wildcardCount} wildcards`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user