Merged in feat/redis-fix (pull request #3207)

Feat/redis fix

* feat(redis): delete multiple keys in one partition scan

* fix(BOOK-603): make it possible to do multiple deletes in redis at once using one partition scan


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-11-24 10:17:35 +00:00
parent 29e81d9995
commit 8ed16a0119
11 changed files with 260 additions and 61 deletions
+54 -18
View File
@@ -1,15 +1,20 @@
import * as Sentry from "@sentry/bun";
import { Elysia, t } from "elysia";
import { ModelValidationError } from "@/errors/ModelValidationError";
import { redis } from "@/services/redis";
import { queueDelete } from "@/services/redis/queueDelete";
import { queueDelete, queueDeleteMultiple } from "@/services/redis/queueDelete";
import { loggerModule } from "@/utils/logger";
import { truncate } from "@/utils/truncate";
import { validateKey } from "@/utils/validateKey";
const MIN_LENGTH = 1;
const QUERY_TYPE = t.Object({ key: t.String({ minLength: MIN_LENGTH }) });
const DELETEMULTIPLE_BODY_TYPE = t.Object({
keys: t.Array(t.String({ minLength: MIN_LENGTH })),
fuzzy: t.Optional(t.Boolean({ default: false })),
});
const cacheRouteLogger = loggerModule("cacheRoute");
export const cacheRoutes = new Elysia({ prefix: "/cache" })
.get(
@@ -68,6 +73,53 @@ export const cacheRoutes = new Elysia({ prefix: "/cache" })
response: { 204: t.Undefined(), 400: t.String() },
},
)
.delete(
"/multiple",
async ({ body: { keys, fuzzy = false } }) => {
const validatedKeys = keys.map(validateKey);
cacheRouteLogger.debug(
`DELETE /multiple keys=${validatedKeys.join(",")} ${fuzzy ? "(fuzzy)" : ""}`,
);
// 1. Fuzzy deletes → Single SCAN pass
if (fuzzy) {
const patterns = validatedKeys.map((k) => `*${k}*`);
await queueDeleteMultiple({ patterns });
return { status: "queued" };
}
// 2. Exact deletes → Batch unlink
const now = performance.now();
// Use UNLINK for async deletes
const deletedKeys = await redis.unlink(...validatedKeys);
const elapsed = performance.now() - now;
cacheRouteLogger.info(
`Deleted ${deletedKeys} keys in ${elapsed}ms`,
{
deletedKeys,
keys: validatedKeys,
elapsed,
},
);
return { deletedKeys };
},
{
body: DELETEMULTIPLE_BODY_TYPE,
response: {
200: t.Union([
t.Object({ deletedKeys: t.Number() }),
t.Object({ status: t.Literal("queued") }),
]),
400: t.String(),
},
},
)
.delete(
"/",
async ({ query: { key, fuzzy } }) => {
@@ -105,19 +157,3 @@ export const cacheRoutes = new Elysia({ prefix: "/cache" })
},
},
);
function validateKey(key: string) {
const parsedKey = decodeURIComponent(key);
if (parsedKey.length < MIN_LENGTH) {
throw new ModelValidationError(
"Key has to be at least 1 character long",
);
}
if (parsedKey.includes("*")) {
throw new ModelValidationError("Key cannot contain wildcards");
}
return parsedKey;
}