Da Sanity al browser: invalidazione cache event-driven con SQS FIFO
Come ho costruito il pipeline di invalidazione tra Sanity e la cache GraphQL: webhook con firma HMAC, coda SQS FIFO con delay, revalidazione a cascata via tabella DynamoDB, e purge selettivo su Stellate + ISR Next.js.
L’articolo su Stellate descriveva il ciclo di invalidazione in modo sintetico. In realtà quella parte del sistema è la più delicata: un errore significa o dati stale in produzione o una Lambda che gira a vuoto su messaggi già processati. Vale la pena descriverla in dettaglio.
Il contesto: un BFF GraphQL su Lambda davanti a Sanity e Shopify, con Stellate come CDN e Next.js come frontend. Ogni pubblicazione in Sanity deve propagarsi in meno di un minuto attraverso tre layer: purge su Stellate, aggiornamento della routing table su DynamoDB, revalidazione ISR su Next.js.
Il problema del burst da CMS
Un editor che aggiorna la descrizione di un prodotto salva la bozza quattro volte prima di pubblicare. Senza throttling, ogni salvataggio trigghera un webhook → una Lambda → una chiamata a Stellate. Cinque purge per un singolo cambio.
La soluzione è mettere una coda tra il webhook e il consumer. Ma la scelta del tipo di coda non è banale.
Perché SQS FIFO
Una coda SQS standard non darebbe garanzie sull’ordine di consegna e potrebbe consegnare lo stesso messaggio più volte. In un sistema di invalidazione, processare lo stesso documento due volte è innocuo ma costoso — ogni processing richiede query a DynamoDB, chiamate a Stellate, e una richiesta al frontend. Con una coda FIFO:
- I messaggi all’interno dello stesso
MessageGroupIdvengono consegnati in ordine - Un messaggio non viene consegnato finché il precedente nello stesso gruppo non è stato processato o è scaduto
- La
contentBasedDeduplicationelimina messaggi identici ricevuti entro 5 minuti
Il MessageGroupId è impostato al _type del documento Sanity — product, page, category. Questo significa che i prodotti si accodano tra loro senza bloccare le pagine, e viceversa.
La configurazione della coda
const revalidateQueue = new Queue(stack, 'revalidate-queue', {
cdk: {
queue: {
fifo: true,
contentBasedDeduplication: true,
queueName: `${app.stage}-revalidate.fifo`,
visibilityTimeout: toCdkDuration('6 minutes'),
deliveryDelay: toCdkDuration('45 seconds'),
deadLetterQueue: {
queue: revalidateDlq.cdk.queue,
maxReceiveCount: 3,
},
},
},
});
Quattro decisioni qui.
deliveryDelay: 45 seconds — i messaggi non vengono consegnati al consumer per 45 secondi dall’invio. Se l’editor salva il prodotto dieci volte in rapida successione, tutti i messaggi arrivano al consumer insieme come batch. Invece di dieci purge separate, ne viene eseguita una sola.
contentBasedDeduplication: true — SQS calcola un hash del corpo del messaggio. Se due messaggi con lo stesso MessageGroupId hanno lo stesso hash, il secondo viene scartato silenziosamente. Il risultato: salvataggi multipli dello stesso documento collassano in un unico messaggio.
visibilityTimeout: 6 minutes — mentre il consumer Lambda sta elaborando un messaggio, quel messaggio diventa invisibile agli altri consumer. Il timeout deve essere maggiore del timeout della Lambda (2 minuti). Se la Lambda impiega più del visibilityTimeout a finire, il messaggio torna visibile e viene riprocessato — potenzialmente in parallelo.
maxReceiveCount: 3 — se il consumer fallisce tre volte sullo stesso messaggio, il messaggio viene spostato nella DLQ per ispezione manuale. Senza questo, un messaggio corrotto potrebbe ciclare indefinitamente.
Il problema di contentBasedDeduplication
contentBasedDeduplication usa JSON.stringify del corpo del messaggio per calcolare l’hash. Due oggetti con lo stesso contenuto ma proprietà in ordine diverso producono JSON diversi — e quindi hash diversi, e quindi nessuna deduplicazione.
Sanity aggiunge un campo _rev a ogni documento. _rev cambia a ogni salvataggio, anche se il contenuto non è cambiato. Senza stripparlo, due salvataggi consecutivi dello stesso documento producono messaggi con _rev diverso → hash diversi → nessuna deduplicazione.
Ho risolto con una funzione toMessageBody che:
- Rimuove
_revdal documento prima di serializzare - Ordina ricorsivamente le chiavi dell’oggetto prima di
JSON.stringify
export const toMessageBody = <T extends Record<string, unknown>>(obj: T) =>
JSON.stringify(deepSortObjectByKeys(omit(obj, ['_rev'])));
// deepSortObjectByKeys: ordina le chiavi di ogni oggetto e gli elementi degli array
// in modo che JSON.stringify sia deterministico
Con questo, due salvataggi dello stesso documento con contenuto identico producono lo stesso hash → SQS deduplicato → il consumer vede un solo messaggio.
Il webhook Lambda
Il webhook riceve il POST da Sanity, valida la firma HMAC, e mette il documento in coda:
export const revalidateHandler = () =>
Handler('api', async ({ requestContext }) => {
const { error, success } = useResponse();
if (requestContext.http.method !== 'POST') {
return error({ message: 'Method not allowed', statusCode: 405 });
}
const { isValidSignature } = useSanityWebhook({
secret: process.env.SANITY_REVALIDATE_WEBHOOK_SECRET || '',
});
if (!isValidSignature()) {
return error({ message: 'Invalid signature', statusCode: 401 });
}
const { jsonBody, sendMessage } = useQueue();
const { MessageId, $metadata } = await sendMessage({
queueUrl: process.env.REVALIDATE_QUEUE_URL || '',
groupId: jsonBody?._type,
});
if ($metadata.httpStatusCode !== 200) {
return error({ message: 'Unable to send message to queue' });
}
return success({ message: `Document "${jsonBody?._id}" sent to queue` });
});
La firma HMAC usa @sanity/webhook:
export const useSanityWebhook = ({ secret }: { secret: string }) => ({
isValidSignature: () => {
const body = useBody();
if (!body) return false;
const signature = useHeader(SIGNATURE_HEADER_NAME) || '';
return isValidSignature(body, signature, secret);
},
});
SIGNATURE_HEADER_NAME è sanity-webhook-signature — Sanity firma il body con HMAC-SHA256 e mette la firma nell’header. La validazione fallisce se il body è stato alterato o se il segreto non corrisponde.
La Lambda risponde a Sanity immediatamente dopo aver enqueued il messaggio. Non aspetta il processing — quello avviene in modo asincrono nel consumer.
Il consumer Lambda
Il consumer riceve un batch di messaggi (fino a 10) e li processa insieme:
export const revalidateConsumerHandler = ({ routingQuery, routingConfig }) =>
Handler('sqs', async ({ Records }) => {
const documentIds: string[] = [];
for (const record of Records) {
const document = parseJsonToObject<SanityDocumentLike>(record.body);
if (isSanityDocument(document) && !documentIds.includes(document._id)) {
documentIds.push(document._id);
}
}
if (!documentIds.length) return;
const routingData = await selectiveRevalidate({
routingQuery,
routingConfig,
documentIds,
});
const slugs = uniqueArray(routingData.map(({ slug }) => slug));
const routingConfigGroupedByType = groupBy(routingData, ({ typename }) => typename);
const cdnPurgePromises = [
...Object.entries(routingConfigGroupedByType).map(([type, docs]) =>
cdnPurgeType({
type,
keyFields: docs.reduce<KeyFieldInput[]>(
(acc, { documentId }) =>
acc.find(({ value }) => value === documentId)
? acc
: [...acc, { name: '_id', value: documentId }],
[]
),
})
),
cdnPurgeType({
type: 'Routing',
keyFields: slugs.map(value => ({ name: 'slug', value })),
}),
];
await Promise.all(cdnPurgePromises);
await storefrontRevalidate({ slugs });
});
Il consumer deduplicato i documentId estratti dal batch (FIFO garantisce ordine ma il consumer può comunque ricevere messaggi duplicati in certi edge case). Poi delega tutto a selectiveRevalidate.
selectiveRevalidate e le dipendenze in cascata
Questa è la parte più interessante. Cambiare un documento Sanity può invalidare pagine che non contengono direttamente quel documento.
Esempio: un “componente globale” (un banner, un menu, una sezione di footer) è referenziato da 50 pagine. Quando il banner cambia, tutte le 50 pagine devono essere revalidate. Se la tabella di routing memorizzasse solo la relazione diretta documentId → slug, questo non funzionerebbe.
La soluzione è un campo dependencies in ogni record della routing table DynamoDB. Ogni route ha la lista degli _id Sanity che contribuiscono al suo contenuto — il documento principale più tutti i documenti referenziati.
export const selectiveRevalidate = async ({ routingQuery, routingConfig, documentIds }) => {
// Cerca tutti i record dove documentId corrisponde OPPURE
// il documento è nelle dipendenze della route
const routingDataToRevalidate = await fetchAllDocumentToRevalidateFromTable(documentIds);
const documentIdsToRevalidate = uniqueArray([
...documentIds,
...routingDataToRevalidate.map(({ documentId }) => documentId),
...routingDataToRevalidate.flatMap(({ dependencies }) => dependencies),
]);
// Ricostruisce il routing per tutti gli ID coinvolti
const routingTable = await buildRoutingTable({
routingConfig,
documentIds: documentIdsToRevalidate,
routingQuery,
});
await saveRoutingTable(routingTable);
return routingTable;
};
La query DynamoDB usa un FilterExpression con OR contains(dependencies, :dep):
const filterExpression = documentIds.reduce(
({ filterExpression, filterExpressionAttrValues }, documentId, index) => {
const attributeName = `:dep${index}`;
const containClause = `documentId = ${attributeName} OR contains (dependencies, ${attributeName})`;
return {
filterExpression: [...filterExpression, containClause],
filterExpressionAttrValues: {
...filterExpressionAttrValues,
[attributeName]: documentId,
},
};
},
{ filterExpression: [], filterExpressionAttrValues: {} }
);
contains su un attributo list in DynamoDB cerca un elemento esatto — in questo caso un _id Sanity nella lista di dipendenze del record.
La routing table come hash idempotente
La saveRoutingTable usa una ConditionExpression per scrivere solo se il contenuto è cambiato:
const itemHash = hash(docJson);
return new PutCommand({
Item: { ...doc, itemHash },
ConditionExpression:
'attribute_not_exists(itemHash) OR (itemHash <> :itemHash)',
ExpressionAttributeValues: {
':itemHash': itemHash,
},
TableName: process.env.ROUTING_TABLE_NAME,
});
Se il record esiste già con lo stesso hash, la PutCommand lancia un ConditionalCheckFailedException — gestito silenziosamente. Questo rende la scrittura idempotente: re-eseguire saveRoutingTable sugli stessi dati non produce scritte inutili.
Il purge Stellate e la revalidazione ISR
Dopo selectiveRevalidate, il consumer esegue tre operazioni in parallelo:
Purge per typename: raggruppa i documenti per tipo GraphQL (SpfProduct, Page, ecc.) e fa un _purgeType per ciascuno con i _id come keyFields. Stellate invalida solo le voci di cache che corrispondono a quei documenti.
Purge del tipo Routing: invalida le route nella cache Stellate per slug. Necessario perché il resolver allRouting è cachato separatamente.
ISR Next.js: chiama l’endpoint di revalidazione del frontend con la lista degli slug:
export const storefrontRevalidate = ({ slugs, baseUrl, secret }) =>
redaxios.post(`${baseUrl}/api/revalidate`, { slugs }, { params: { secret } });
Next.js on-demand ISR ricostruisce le pagine indicate sullo sfondo — gli utenti che visitano la pagina durante la ricostruzione ricevono ancora la versione stale, la prossima visita ottiene quella aggiornata.
La DLQ
La Dead Letter Queue è una seconda coda FIFO. Quando il consumer fallisce tre volte sullo stesso messaggio, SQS lo sposta là automaticamente. Senza ispezione manuale, quel documento rimane stale in produzione.
Il maxReceiveCount: 3 è un equilibrio: troppo basso e messaggi temporaneamente non processabili (Stellate momentaneamente irraggiungibile) finiscono in DLQ; troppo alto e un documento che causa un errore sistematico blocca il MessageGroupId per troppo tempo.
Un pattern comune è avere un’altra Lambda che legge dalla DLQ periodicamente e invia un alert — per ora l’ispezione era manuale via console AWS.
Cosa ho imparato
Il deliveryDelay risolve il burst senza richiedere logica applicativa. L’alternativa sarebbe un aggregatore stateful — più complessa da gestire e da testare.
contentBasedDeduplication richiede serializzazione deterministica. Non è documentato in modo prominente ma è il requisito critico: stessa chiave, stesso ordine, nessun campo volatile. _rev di Sanity è l’esempio tipico.
Le dipendenze in cascata sono necessarie su qualsiasi CMS strutturato. Un documento Sanity che è un componente globale può toccare decine di URL. Senza il campo dependencies, quelle pagine rimarrebbero stale anche dopo il purge.
Il visibilityTimeout deve essere coordinato con il timeout Lambda. Se il timeout della Lambda è 2 minuti, il visibilityTimeout deve essere almeno 3-4 minuti. Altrimenti SQS ri-consegna il messaggio mentre la Lambda sta ancora lavorando — e il documento viene processato due volte.