fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

This commit is contained in:
jubnl
2026-08-17 06:19:31 +02:00
parent 0962df2f22
commit 616897b60b
2 changed files with 42 additions and 3 deletions
@@ -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 */ });
}
}
}
}
@@ -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);
});
});