diff --git a/src/config/limits.ts b/src/config/limits.ts index d6dc4db..d1ae40b 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -107,6 +107,12 @@ // In-memory /api/sync response cache TTL (milliseconds). // /api/sync 内存缓存有效期(毫秒)。 syncResponseTtlMs: 30 * 1000, + // Max size of a single cached /api/sync body in bytes. + // 单个 /api/sync 缓存响应允许的最大字节数。 + syncResponseMaxBodyBytes: 512 * 1024, + // Max total in-memory bytes used by /api/sync cache per isolate. + // 每个 isolate 中 /api/sync 缓存允许占用的最大总字节数。 + syncResponseMaxTotalBytes: 2 * 1024 * 1024, // Max in-memory /api/sync cache entries per isolate. // 每个 isolate 的 /api/sync 最大缓存条目数。 syncResponseMaxEntries: 64, diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index b9b7cca..353772a 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -11,11 +11,16 @@ import { } from '../utils/user-decryption'; interface SyncCacheEntry { + userId: string; + revisionDate: string; body: string; expiresAt: number; + bytes: number; } const syncResponseCache = new Map(); +let syncResponseCacheTotalBytes = 0; +const textEncoder = new TextEncoder(); function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string { return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`; @@ -25,21 +30,67 @@ function readSyncCache(key: string): string | null { const hit = syncResponseCache.get(key); if (!hit) return null; if (hit.expiresAt <= Date.now()) { - syncResponseCache.delete(key); + deleteSyncCacheEntry(key, hit); return null; } return hit.body; } -function writeSyncCache(key: string, body: string): void { - if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) { - const oldestKey = syncResponseCache.keys().next().value as string | undefined; - if (oldestKey) syncResponseCache.delete(oldestKey); +function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void { + const existing = entry ?? syncResponseCache.get(key); + if (!existing) return; + syncResponseCache.delete(key); + syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes); +} + +function pruneExpiredSyncCache(nowMs: number = Date.now()): void { + for (const [key, entry] of syncResponseCache.entries()) { + if (entry.expiresAt <= nowMs) { + deleteSyncCacheEntry(key, entry); + } } +} + +function pruneStaleUserSyncCache(userId: string, revisionDate: string): void { + for (const [key, entry] of syncResponseCache.entries()) { + if (entry.userId === userId && entry.revisionDate !== revisionDate) { + deleteSyncCacheEntry(key, entry); + } + } +} + +function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void { + const nowMs = Date.now(); + pruneExpiredSyncCache(nowMs); + pruneStaleUserSyncCache(userId, revisionDate); + + const bodyBytes = textEncoder.encode(body).byteLength; + if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) { + return; + } + + const existing = syncResponseCache.get(key); + if (existing) { + deleteSyncCacheEntry(key, existing); + } + + while ( + syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries || + syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes + ) { + const oldestKey = syncResponseCache.keys().next().value as string | undefined; + if (!oldestKey) break; + deleteSyncCacheEntry(oldestKey); + } + syncResponseCache.set(key, { + userId, + revisionDate, body, - expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs, + expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs, + bytes: bodyBytes, }); + syncResponseCacheTotalBytes += bodyBytes; } // GET /api/sync @@ -137,7 +188,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }; const body = JSON.stringify(syncResponse); - writeSyncCache(cacheKey, body); + writeSyncCache(userId, revisionDate, cacheKey, body); return new Response(body, { status: 200,