Discovery cache with per-kind TTL and size cap (BL-0051)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 08:45:47 +01:00
1 parent 12c54e5ffb
commit aad2761dc9
2 files changed
+163

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const DAY = 86400000;
const KINDS = ['title', 'recs', 'creator', 'tagTop', 'fresh'];
const TTL_MS = { title: 30 * DAY, recs: 30 * DAY, creator: 30 * DAY, tagTop: 7 * DAY, fresh: 7 * DAY };
function checkKind(kind) {
if (!KINDS.includes(kind)) throw new TypeError(`Unknown discovery cache kind: ${kind}`);
}
class DiscoveryCache {
constructor(dir, { maxFiles = 5000, now = Date.now } = {}) {
this.dir = path.resolve(dir);
this.maxFiles = maxFiles;
this.now = now;
}
// base64url keeps any key (slashes, dots, spaces) inside the kind folder.
fileFor(kind, key) {
checkKind(kind);
return path.join(this.dir, kind, `${Buffer.from(String(key)).toString('base64url')}.json`);
}
readFile(file, kind) {
let entry;
try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) {
if (error.code === 'ENOENT') return null;
entry = null;
}
const valid = entry && entry.schema === 1 && entry.kind === kind && typeof entry.fetchedAt === 'string'
&& Number.isFinite(Date.parse(entry.fetchedAt)) && 'data' in entry;
if (valid) return entry;
// A bad file would otherwise be re-read on every lookup; drop it so the caller refetches.
try { fs.rmSync(file, { force: true }); } catch {}
return null;
}
get(kind, key) { return this.readFile(this.fileFor(kind, key), kind); }
isFresh(kind, key) {
const entry = this.get(kind, key);
return !!entry && this.now() - Date.parse(entry.fetchedAt) < TTL_MS[kind];
}
put(kind, key, data) {
const file = this.fileFor(kind, key);
const entry = { schema: 1, kind, key, fetchedAt: new Date(this.now()).toISOString(), data };
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
try {
fs.writeFileSync(temp, JSON.stringify(entry), { mode: 0o600 });
fs.renameSync(temp, file);
} finally { try { fs.rmSync(temp, { force: true }); } catch {} }
this.prune();
return entry;
}
files(kind) {
try {
return fs.readdirSync(path.join(this.dir, kind)).filter((name) => name.endsWith('.json'))
.map((name) => path.join(this.dir, kind, name));
} catch { return []; }
}
list(kind) {
checkKind(kind);
return this.files(kind).map((file) => this.readFile(file, kind)).filter(Boolean);
}
stats() {
let files = 0, bytes = 0;
for (const kind of KINDS) for (const file of this.files(kind)) {
try { bytes += fs.statSync(file).size; files++; } catch {}
}
return { files, bytes };
}
// Oldest entries go first so the cache never grows past maxFiles.
prune() {
const all = [];
for (const kind of KINDS) for (const file of this.files(kind)) {
const entry = this.readFile(file, kind);
if (entry) all.push({ file, at: Date.parse(entry.fetchedAt) });
}
if (all.length <= this.maxFiles) return;
all.sort((a, b) => a.at - b.at);
for (const { file } of all.slice(0, all.length - this.maxFiles)) {
try { fs.rmSync(file, { force: true }); } catch {}
}
}
}
module.exports = { KINDS, TTL_MS, DiscoveryCache };
+74
View File
@@ -0,0 +1,74 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DiscoveryCache } = require('../discovery/cache');
const DAY = 86400000;
function setup(t, options = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'circus-dcache-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const clock = { t: Date.UTC(2026, 0, 1) };
const cache = new DiscoveryCache(dir, { ...options, now: () => clock.t });
return { dir, clock, cache };
}
test('cache round-trips an entry', (t) => {
const { clock, cache } = setup(t);
cache.put('title', 42, { a: 1 });
const entry = cache.get('title', 42);
assert.deepEqual(entry.data, { a: 1 });
assert.equal(entry.fetchedAt, new Date(clock.t).toISOString());
assert.equal(entry.schema, 1);
assert.equal(entry.kind, 'title');
});
test('freshness follows TTL per kind', (t) => {
const { clock, cache } = setup(t);
cache.put('fresh', 'global', []);
cache.put('title', 1, {});
clock.t += 8 * DAY;
assert.equal(cache.isFresh('fresh', 'global'), false);
assert.equal(cache.isFresh('title', 1), true);
clock.t += 23 * DAY;
assert.equal(cache.isFresh('title', 1), false);
assert.notEqual(cache.get('title', 1), null);
assert.equal(cache.list('title').length, 1);
});
test('prune keeps the newest maxFiles', (t) => {
const { clock, cache } = setup(t, { maxFiles: 3 });
for (let i = 1; i <= 5; i++) { clock.t += 1000; cache.put('title', i, { i }); }
assert.equal(cache.stats().files, 3);
assert.equal(cache.get('title', 1), null);
assert.equal(cache.get('title', 2), null);
assert.notEqual(cache.get('title', 5), null);
});
test('corrupt entry is deleted', (t) => {
const { dir, cache } = setup(t);
cache.put('recs', 7, {});
const file = path.join(dir, 'recs', fs.readdirSync(path.join(dir, 'recs'))[0]);
fs.writeFileSync(file, '{broken');
assert.equal(cache.get('recs', 7), null);
assert.equal(fs.existsSync(file), false);
});
test('odd keys are file-safe', (t) => {
const { dir, cache } = setup(t);
cache.put('tagTop', 'genre:Slice of Life/../x', [1]);
const files = fs.readdirSync(path.join(dir, 'tagTop'));
assert.equal(files.length, 1);
assert.deepEqual(fs.readdirSync(dir), ['tagTop']);
assert.deepEqual(cache.get('tagTop', 'genre:Slice of Life/../x').data, [1]);
});
test('unknown kind throws', (t) => {
const { cache } = setup(t);
assert.throws(() => cache.put('nope', 1, {}), TypeError);
assert.throws(() => cache.get('nope', 1), TypeError);
});