From 616897b60bd5924dab9dae0a451eda0d5c5fa7d4 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 17 Aug 2026 06:19:31 +0200 Subject: [PATCH] fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep --- .../nest/memories/trek-photo-cache.service.ts | 14 +++++++++ .../nest/trek-photo-cache.service.test.ts | 31 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/server/src/nest/memories/trek-photo-cache.service.ts b/server/src/nest/memories/trek-photo-cache.service.ts index 535982274..291d403e3 100644 --- a/server/src/nest/memories/trek-photo-cache.service.ts +++ b/server/src/nest/memories/trek-photo-cache.service.ts @@ -104,5 +104,19 @@ export class TrekPhotoCacheService { this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key); await this.storage.delete('photos-trek', objectName(row.cache_key)); } + + // Pass 2 (fix #4, spec rev 3.2): getFresh's expiry path deletes the meta + // row but never the object — reclaim row-less .bin objects past the same + // cutoff. The mtime guard spares an in-flight put (object lands before its + // row); nested keys are skipped because the old readdir-free sweep could + // never touch them either. + for await (const stat of this.storage.list('photos-trek')) { + if (stat.key.includes('/') || !stat.key.endsWith('.bin') || stat.mtimeMs >= cutoff) continue; + const key = stat.key.slice(0, -'.bin'.length); + const row = this.db.get('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?', key); + if (!row) { + await this.storage.delete('photos-trek', stat.key).catch(() => { /* race */ }); + } + } } } diff --git a/server/tests/unit/nest/trek-photo-cache.service.test.ts b/server/tests/unit/nest/trek-photo-cache.service.test.ts index 32ea48aa4..7c7608f59 100644 --- a/server/tests/unit/nest/trek-photo-cache.service.test.ts +++ b/server/tests/unit/nest/trek-photo-cache.service.test.ts @@ -201,16 +201,41 @@ describe('sweepExpired', () => { expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); }); - it('CACHE-014: a row-less .bin object survives the DB-driven sweep (parity pin — fix #4 reclaims it)', async () => { + it('CACHE-014: reclaims a row-less .bin object past the cutoff (fix #4, spec rev 3.2)', async () => { + // getFresh's expiry path deletes the meta row but never the object — the + // list-driven pass 2 is what reclaims those leaks. const key = freshKey('rowless'); await svc.put(key, Buffer.from('leaked'), 'image/jpeg'); testDb.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); - // Old enough that the list-driven fix-#4 sweep WILL reclaim it — today's - // DB-driven sweep never visits it (the getFresh-expiry leak, spec rev 3.2). const old = (Date.now() - CACHE_TTL * 3) / 1000; fs.utimesSync(binPath(key), old, old); + await svc.sweepExpired(); + expect(fs.existsSync(binPath(key))).toBe(false); + }); + + it('CACHE-015: a row-less .bin younger than the cutoff survives (in-flight put guard)', async () => { + const key = freshKey('rowless-fresh'); + await svc.put(key, Buffer.from('in-flight'), 'image/jpeg'); + testDb.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + await svc.sweepExpired(); expect(fs.existsSync(binPath(key))).toBe(true); }); + + it('CACHE-016: nested and non-.bin entries are never touched by the sweep', async () => { + const nestedDir = path.join(CACHE_DIR, 'sub'); + fs.mkdirSync(nestedDir, { recursive: true }); + const nested = path.join(nestedDir, 'nested.bin'); + fs.writeFileSync(nested, 'nested'); + const note = path.join(CACHE_DIR, 'note.txt'); + fs.writeFileSync(note, 'not a cache object'); + const old = (Date.now() - CACHE_TTL * 3) / 1000; + fs.utimesSync(nested, old, old); + fs.utimesSync(note, old, old); + + await svc.sweepExpired(); + expect(fs.existsSync(nested)).toBe(true); + expect(fs.existsSync(note)).toBe(true); + }); });