feat: add Redis cache invalidation and stats retrieval functionality

This commit is contained in:
Marco Pedone 2026-03-04 17:34:15 +01:00
parent a36fd52af2
commit 6c9524e54b
4 changed files with 154 additions and 95 deletions

View file

@ -1,14 +1,9 @@
import Head from "next/head";
import { useState } from "react";
import toast from "react-hot-toast";
import Input from "~/components/custom_ui/input";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import type { NextPageWithLayout } from "~/pages/_app";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
const Impostazioni: NextPageWithLayout = () => {
@ -25,7 +20,7 @@ const Impostazioni: NextPageWithLayout = () => {
</div>
</div>
<StripeSection />
<SendEmailSection />
<RedisCacheSection />
</div>
</>
);
@ -77,90 +72,44 @@ const StripeSection = () => {
);
};
const SendEmailSection = () => {
const { data: codici, isLoading: loading } =
api.annunci.getOnline_AnnuncioIdCodice.useQuery();
const [selectedAnnunci, setSelectedAnnunci] = useState<
{ value: AnnunciId; label: string }[]
>([]);
const [email, setEmail] = useState<string | undefined>(undefined);
const { mutate } = api.comunicazioni.sendAnnunciEmail.useMutation({
onSuccess: () => {
// setSelectedAnnunci([]);
// setEmail("");
toast.success("Email inviata con successo");
const RedisCacheSection = () => {
const { data, isLoading } = api.revalidation.getRedisStats.useQuery();
const utils = api.useUtils();
const { mutate } = api.revalidation.invalidateRedisCache.useMutation({
onMutate() {
toast.loading("Invalidating cache...", {
id: "invalidate-cache",
});
},
onError: (error) => {
console.error(error);
toast.error(`Errore durante l'invio dell'email: ${error.message}`);
onSuccess: async () => {
toast.success("Cache invalidated successfully!", {
id: "invalidate-cache",
});
await utils.revalidation.getRedisStats.invalidate();
},
onError() {
toast.error("Failed to invalidate cache.", {
id: "invalidate-cache",
});
},
});
if (isLoading) {
return <LoadingPage />;
}
return (
<div className="space-y-4 rounded-lg border p-4">
<h4 className="font-semibold text-lg">
Invia email con annunci selezionati
</h4>
<h4 className="font-semibold text-lg">Invalidate Redis Cache</h4>
<p>Current Cache Stats:</p>
{data?.success ? (
<ul className="list-inside list-disc">
<li>Pdf Schede Annuncio: {data?.schedaCount}</li>
<li>Pdf Condizioni: {data?.condizioniCount}</li>
</ul>
) : (
<div className="text-red-500">Error fetching cache stats</div>
)}
<div className="flex max-w-xl flex-col gap-2">
<Label className="font-medium" htmlFor="email-to">
Indirizzo email destinatario
</Label>
<Input
id="email-to"
onChange={(e) => setEmail(e.target.value)}
placeholder="Inserisci l'indirizzo email"
type="email"
value={email || ""}
/>
</div>
<div className="flex max-w-xl flex-col gap-2">
{loading ? (
<LoadingPage />
) : (
<div className="flex max-w-xl flex-col">
<Label className="font-medium" htmlFor="select-annuncio">
Seleziona gli annunci da inviare
</Label>
<MultiSelect
defaultValue={undefined}
elementId="select-annuncio"
elementName="annuncio"
isMulti={true}
onValueChange={(value) => {
setSelectedAnnunci(
value.map((v) => ({
label: v.label,
value: parseInt(v.value) as AnnunciId,
})),
);
}}
options={
codici?.map((cod) => ({
label: cod.codice,
value: cod.id.toString(),
})) || []
}
placeholder=""
/>
</div>
)}
</div>
<Button
aria-label="Cancel Add Annuncio"
disabled={!selectedAnnunci.length || !email}
onClick={() => {
if (!selectedAnnunci.length || !email) return;
mutate({
annunci: selectedAnnunci.map((annuncio) => annuncio.value),
to: email,
});
}}
type="button"
>
Invia
</Button>
<Button onClick={() => mutate()}>Invalidate Cache</Button>
</div>
);
};

View file

@ -26,9 +26,12 @@ import {
} from "~/server/controllers/annunci.controller";
import { db } from "~/server/db";
import { NewMail } from "~/server/services/mailer";
import { genPdfSchedaAnnuncio } from "~/server/services/puppeteer.service";
import {
genPdfSchedaAnnuncio,
invalidateCache,
SCHEDA_ANNUNCIO_CACHE_PREFIX,
} from "~/server/services/puppeteer.service";
import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types";
import { getKeydbClient } from "~/utils/keydb";
export const annunciRouter = createTRPCRouter({
editAnnuncio: adminProcedure
@ -36,10 +39,10 @@ export const annunciRouter = createTRPCRouter({
z.object({ annuncioId: zAnnuncioId, data: z.custom<AnnunciUpdate>() }),
)
.mutation(async ({ input }) => {
const keybd = getKeydbClient();
if (keybd) {
await keybd.del(`scheda-annuncio-${input.annuncioId}`);
}
await invalidateCache(
`${SCHEDA_ANNUNCIO_CACHE_PREFIX}${input.annuncioId}`,
);
return await editAnnuncioHandler({
...input,
});

View file

@ -3,6 +3,10 @@ import { z } from "zod/v4";
import { env } from "~/env";
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controller";
import {
getCacheStats,
invalidateAllCaches,
} from "~/server/services/puppeteer.service";
import {
revalidate,
revalidateMultiple,
@ -130,4 +134,23 @@ export const revalidationRouter = createTRPCRouter({
});
}
}),
getRedisStats: adminProcedure.query(async () => {
return await getCacheStats();
}),
invalidateRedisCache: adminProcedure.mutation(async () => {
try {
await invalidateAllCaches();
return {
status: "success",
};
} catch (err) {
console.error("Invalidate Redis cache error:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
err instanceof Error ? err.message : "Invalidate Redis cache failed",
cause: err,
});
}
}),
});

View file

@ -1,10 +1,72 @@
import { TRPCError } from "@trpc/server";
import type Redis from "ioredis";
import puppeteer from "puppeteer-core";
import { env } from "~/env";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { getKeydbClient } from "~/utils/keydb";
import { TOKEN_CONFIG } from "../auth/configs";
export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-";
export const CONDIZIONI_CACHE_PREFIX = "condizioni-";
export const invalidateCache = async (cacheKey: string) => {
const keybd = getKeydbClient();
if (keybd) {
await keybd.del(cacheKey);
}
};
async function deleteByPrefix(redis: Redis, prefix: string) {
// Create a stream to find keys matching the pattern
const stream = redis.scanStream({
match: `${prefix}*`,
count: 100, // Process 100 keys per iteration
});
stream.on("data", async (keys: string[]) => {
if (keys.length > 0) {
// Create a pipeline to delete keys in a single network round-trip
const pipeline = redis.pipeline();
// Use UNLINK instead of DEL for better performance on large keys
keys.forEach((key) => pipeline.unlink(key));
await pipeline.exec();
console.log(`Deleted ${keys.length} keys`);
}
});
stream.on("error", (err) => {
console.error(
`Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`,
);
});
}
export const invalidateAllCaches = async () => {
const keybd = getKeydbClient();
if (keybd) {
await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX);
await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX);
}
};
export const getCacheStats = async () => {
const keybd = getKeydbClient();
if (keybd) {
const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`);
const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`);
return {
success: true,
schedaCount: schedaKeys.length,
condizioniCount: condizioniKeys.length,
};
}
return {
success: false,
schedaCount: 0,
condizioniCount: 0,
};
};
export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => {
const pdf = await genPdfSchedaAnnuncio(annuncioId);
const pdfBase64 = Buffer.from(pdf).toString("base64");
@ -19,13 +81,14 @@ export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => {
export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => {
const keybd = getKeydbClient();
const url = `/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`;
if (keybd) {
const cacheKey = `scheda-annuncio-${annuncioId}`;
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`;
const cached = await keybd.get(cacheKey);
if (cached) {
return new Uint8Array(Buffer.from(cached, "base64"));
}
const scheda = await puppeteerSchedaAnnuncio(annuncioId);
const scheda = await puppeteerGenPdf(url);
await keybd.set(
cacheKey,
Buffer.from(scheda).toString("base64"),
@ -34,10 +97,31 @@ export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => {
);
return scheda;
}
return await puppeteerSchedaAnnuncio(annuncioId);
return await puppeteerGenPdf(url);
};
const puppeteerSchedaAnnuncio = async (annuncioId: AnnunciId) => {
export const genPdfCondizioni = async (condizioniId: AnnunciId) => {
const keybd = getKeydbClient();
const url = `/servizio/condizioni/${condizioniId}?raw=true`;
if (keybd) {
const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${condizioniId}`;
const cached = await keybd.get(cacheKey);
if (cached) {
return new Uint8Array(Buffer.from(cached, "base64"));
}
const scheda = await puppeteerGenPdf(url);
await keybd.set(
cacheKey,
Buffer.from(scheda).toString("base64"),
"EX",
3600 * 24 * 5,
);
return scheda;
}
return await puppeteerGenPdf(url);
};
const puppeteerGenPdf = async (url: string) => {
let browser = null;
try {
@ -66,7 +150,7 @@ const puppeteerSchedaAnnuncio = async (annuncioId: AnnunciId) => {
? env.INTERNAL_BASE_URL
: `http://host.docker.internal:3000`;
const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`;
const targetUrl = `${baseUrl}${url}`;
await page.goto(targetUrl, { waitUntil: "networkidle0" });
const pdf = await page.pdf({