fix(settings): make Clear Caches actually work, and scope it safely

The Clear Caches button never did anything: the handler used the node-redis
v4 scan signature (options object + { cursor, keys } result) against our
ioredis client, whose scan takes positional args and returns [cursor, keys].
Every call threw and cleared nothing -- which is why clearing the cache did
not dislodge the wedged podcast jobs.

Even had it run, "delete every key except sess:" would have wiped live
BullMQ queue state (bull:*, 200+ keys) and the enrichment/audio/clap control
plane. Replace that with an allowlist of genuine rebuildable caches
(MusicBrainz, cover art, Last.fm, Wikidata, Deezer, iTunes, hero images) and
delete in chunks. Verified read-only against production: clears ~5130 cache
keys, preserves all bull:/audio:/enrichment:/clap:/sess: keys.
This commit is contained in:
chevron7
2026-06-14 23:48:30 -05:00
parent 34dc43977b
commit ebb488aa85
+43 -19
View File
@@ -847,35 +847,59 @@ router.post("/queue-cleaner/stop", (req, res) => {
});
});
// Clear all Redis caches
// Cache key prefixes that are safe to drop -- external-API and image-derivation
// caches that rebuild on demand. Deliberately an allowlist: it must never touch
// BullMQ queue state ("bull:"), the enrichment/analysis control plane
// ("enrichment", "audio:", "clap:"), or sessions ("sess:"). The previous
// "delete everything except sess:" approach would have wiped live job state --
// and it never ran anyway because it used the node-redis scan signature against
// our ioredis client (positional args + array return), so it threw every time.
const CLEARABLE_CACHE_PREFIXES = [
"mb:",
"album-cover:",
"hero:",
"cover-art:",
"caa:",
"lastfm:",
"wikidata:",
"deezer:",
"itunes:",
];
// Clear external-API / image caches (not queue or control-plane state)
router.post("/clear-caches", async (req, res) => {
try {
const { redisClient } = require("../utils/redis");
const { notificationService } =
await import("../services/notificationService");
// Collect all keys using SCAN (non-blocking) and exclude session keys
const allKeys: string[] = [];
let cursor = 0;
// SCAN with the ioredis signature: positional MATCH/COUNT args, and an
// [nextCursor, keys] array result; the cursor is a string, "0" terminates.
const keysToDelete: string[] = [];
let cursor = "0";
do {
const result = await redisClient.scan(cursor, { MATCH: "*", COUNT: 100 });
cursor = result.cursor;
allKeys.push(...result.keys);
} while (cursor !== 0);
const keysToDelete = allKeys.filter(
(key: string) => !key.startsWith("sess:"),
);
const [next, keys] = await redisClient.scan(
cursor,
"MATCH",
"*",
"COUNT",
500,
);
cursor = next;
for (const key of keys) {
if (CLEARABLE_CACHE_PREFIXES.some((p) => key.startsWith(p))) {
keysToDelete.push(key);
}
}
} while (cursor !== "0");
if (keysToDelete.length > 0) {
// Delete in chunks so a large cache doesn't build one oversized command.
for (let i = 0; i < keysToDelete.length; i += 500) {
await redisClient.del(...keysToDelete.slice(i, i + 500));
}
logger.debug(
`[CACHE] Clearing ${keysToDelete.length} cache entries (excluding ${
allKeys.length - keysToDelete.length
} session keys)...`,
);
await redisClient.del(keysToDelete);
logger.debug(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries`,
`[CACHE] Cleared ${keysToDelete.length} cache entries`,
);
// Send notification to user