Cache e invalidacion en Neges Storefront: del precio pegado al edge sano
Post-mortem tecnico de la estrategia de cache del storefront: edge cache en Vercel, invalidacion por tags desde Strapi, precios pegados, self-calls SSR y el interceptor in-process que cerro la causa raiz.
El storefront publico de Neges necesita ser rapido, barato y fresco al mismo tiempo. La solucion combina cache en el edge de Vercel, invalidacion por tags desde Strapi y un interceptor SSR que evita que la app se pida datos a si misma pasando por el edge.
Resumen ejecutivo
Neges Storefront es multi-tenant, usa Angular SSR en Vercel y toma sus datos desde Strapi en Railway. Para que las tiendas respondan rapido se introdujo cache en el edge de Vercel con invalidacion por tags ante ediciones de contenido.
Durante la puesta en marcha aparecio un sintoma peligroso: un operador cambiaba un precio en el backoffice y la tienda seguia mostrando el valor anterior, incluso despues de esperar y refrescar. La API de Strapi devolvia el valor correcto, pero el HTML cacheado mantenia un precio viejo horneado en la hidratacion.
El problema no era una sola cosa. Habia tres capas apiladas: invalidacion inerte por variables desalineadas, cache en memoria del SSR con TTL demasiado largo y, finalmente, un loop de envenenamiento donde el SSR hacia self-calls hacia su host publico y recibia datos stale desde el edge.
El resultado final: una edicion de precio se propaga sola en segundos. La primera visita tras una edicion puede ver el valor viejo una unica vez por stale-while-revalidate, y desde la revalidacion siguiente el edge queda con el valor nuevo.
El incidente: precios pegados
El sintoma reportado fue directo: cambio un precio en backoffice y en la tienda no se actualiza. En un caso concreto, la ficha de una churrera manual, codigo EVP3-H, mostraba $162.082 en el home y en el detalle, cuando el precio correcto de lista base era $162.080.
- Strapi, la fuente de verdad, devolvia siempre el valor correcto: $162.080.
- Un render fresco con parametro anti-cache tambien salia correcto.
- Las copias servidas desde el edge, creadas pocos minutos antes y con el bundle JS actual, tenian el precio viejo horneado en el HTML.
- Refrescar podia incluso regenerar el problema porque la revalidacion volvia a armar HTML fresco con datos stale.
Modelo mental: actores y capas
Para entender la arquitectura y el bug hay que separar tres capas: el edge que sirve copias, la funcion SSR que fabrica HTML y Strapi como fuente de verdad.
El edge existe porque armar una pagina puede tardar entre 1 y 5 segundos, mientras entregar una copia puede rondar decenas de milisegundos. Esa copia tiene TTL y tags. Cuando cambia contenido, Strapi marca como stale las copias con tenant:<slug>.
Tres causas apiladas
| Causa | Sintoma | Resolucion |
|---|---|---|
| Invalidacion inerte | Strapi no estaba purgando el edge porque habia nombres de variables de entorno desalineados. | Se reutilizo la configuracion de Vercel que Strapi ya tenia para automatizacion de dominios. |
| Cache in-memory del SSR con TTL 60 s | Una revalidacion del edge podia tomar datos viejos desde la cache interna de la funcion. | El TTL default bajo a 5 s, suficiente para deduplicar el doble-resolve e insuficiente para esconder ediciones. |
| Loop de envenenamiento por SSR self-call | El SSR fabricaba HTML nuevo, pero pedia datos a su propio host publico y el edge le devolvia JSON stale. | Se agrego un interceptor server-only que resuelve catalogo y producto in-process, directo a Strapi. |
Causa raiz: SSR self-call por el edge
La fabrica SSR necesitaba datos del producto o catalogo. En vez de pedirlos directo a Strapi, construia una URL con el host publico y hacia fetch a /storefront-api. Esa llamada salia de la funcion, entraba de nuevo por Vercel Edge y quedaba sometida al mismo stale-while-revalidate que las visitas reales.
Por eso esperar y refrescar no bastaba. Cada refresh podia disparar una revalidacion que volvia a contaminar el HTML. No era un vencimiento que se curaba con tiempo; era una fuente que se re-contaminaba a si misma.
La solucion en cinco piezas
La arquitectura final mezcla velocidad, frescura y aislamiento multi-tenant. Las tres primeras piezas hacen que la tienda sea rapida; las dos ultimas cierran los agujeros de staleness.
| Pieza | Repo | Rol |
|---|---|---|
| Edge cache de HTML y APIs publicas | neges-store | Cachea respuestas anonimas con Vercel-CDN-Cache-Control y Vercel-Cache-Tag. |
| Resolucion de precios en lote | neges-strapi | Elimina N+1 del catalogo y mantiene paridad con la resolucion individual. |
| Invalidacion por tags desde Strapi | neges-strapi | Detecta mutaciones por document-service y purga tenant:<slug> en Vercel. |
| TTL corto de cache SSR in-memory | neges-store | Deduplica el doble-resolve sin esconder ediciones. |
| Interceptor SSR in-process | neges-store | Evita self-calls al host publico durante SSR y lee directo desde Strapi. |
Pieza 1: edge cache de HTML y APIs publicas
En neges-store/src/server-app.ts, las respuestas publicas anonimas setean headers separados para navegador y edge. El navegador siempre revalida; el edge puede guardar copias por TTL y asociarlas a tags para purga selectiva.
Cache-Control: public, max-age=0, must-revalidate
Vercel-CDN-Cache-Control: public, max-age=<ttl>, stale-while-revalidate=<swr>
Vercel-Cache-Tag: tenant:<slug>, tenant-domain:<host>| Superficie | TTL edge | SWR |
|---|---|---|
| Home, categoria, colecciones, busqueda y legales HTML | 300 s | 3600 s |
| Ficha de producto HTML y /storefront-api/products/:slug | 900 s | 86400 s |
| /storefront-api/catalog | 300 s | 3600 s |
- Siempre no-store: checkout, ordenes, pagos, customer-access, preview de Storefront Studio y respuestas no-200.
- El edge indexa por host, path y query, asi que los tenants no comparten entradas.
- tenant:<slug> es el tag primario de purga; tenant-domain:<host> queda como respaldo por dominio.
Pieza 2: precios en lote
El catalogo resolvia precios producto por producto, con alrededor de 352 consultas por render. Se agrego resolveProductPricesForList en neges-strapi/src/api/product-price/services/pricing.ts para resolver la lista completa con una consulta product_prices filtrada por product.documentId.
- Reusa toProductPriceSnapshot.
- Mantiene filtros de vigencia y canal.
- Mantiene compareResolvedPrices.
- Mantiene calculatePriceBreakdown.
- Incluye test de paridad contra la resolucion individual.
- El TTFB del catalogo bajo de aproximadamente 3.4-4.5 s a aproximadamente 0.7 s.
Pieza 3: invalidacion por tags desde Strapi
neges-strapi/src/utils/vercel-cache-invalidation.ts registra un middleware del document-service. Ante mutaciones de product, product-price, product-category, catalog-collection, storefront-studio-config, tenant-domain o tenant, resuelve el slug del tenant y llama a la REST API de Vercel invalidate-by-tags.
POST https://api.vercel.com/v1/edge-cache/invalidate-by-tags
?projectIdOrName=<VERCEL_STOREFRONT_PROJECT_ID>&teamId=<VERCEL_TEAM_ID>
Authorization: Bearer <VERCEL_TOKEN>
Content-Type: application/json
{
"tags": ["tenant:importadora-roble"],
"target": "production"
}- Se usa invalidate, no delete: marca stale y deja que SWR revalide en background.
- Un solo tag tenant:<slug> cubre dominio custom y subdominio .neges.cl.
- Como engancha document-service, purga ediciones desde admin panel, REST y neges-mcp.
- No hace falta una tool explicita de revalidar para ChatGPT.
- Reusa VERCEL_TOKEN, VERCEL_STOREFRONT_PROJECT_ID y VERCEL_TEAM_ID desde config/domainAutomation.ts.
- Si falta token o project id, el feature queda como no-op. Si Vercel responde 403, se loguea como warn y no rompe la edicion.
Pieza 4: TTL corto de cache SSR in-memory
El SSR mantiene una cache en memoria por lambda en storefront.server-api.ts, controlada por CATALOG_CACHE_TTL_MS y PRODUCT_CACHE_TTL_MS. Su razon de ser no es frescura de negocio, sino deduplicar el doble-resolve de un mismo render.
Ese doble-resolve ocurre en menos de un segundo: una resolucion para detectar redirect o 404 y otra durante el render Angular. Con TTL 60 s, el edge podia revalidar y tomar datos viejos desde la memoria de la funcion. Con 5 s, se mantiene el dedup sin enmascarar ediciones.
Pieza 5: interceptor SSR in-process
El fix de causa raiz vive en neges-store/src/app/core/storefront/storefront-ssr-data.interceptor.ts y se registra solo en app.config.server.ts. En servidor, los GET a /storefront-api/catalog y /storefront-api/products/:slug se resuelven llamando directamente a getStorefrontCatalogData y getStorefrontProductData.
- Ya no hay fetch que salga y vuelva a entrar por el edge durante SSR.
- Se usa el mismo codigo de lectura que server-app.ts.
- Se elimina un viaje edge + funcion por pagina SSR.
- Se reduce aproximadamente a la mitad la cantidad de invocaciones relacionadas con ese self-call.
- La cache JSON del edge queda para navegaciones del navegador, donde un unico stale post-purga es aceptable.
Flujo correcto de una edicion de precio
Referencia de componentes y archivos
| Pieza | Repo / archivo | Que hace |
|---|---|---|
| Headers de edge cache + tags | neges-store · src/server-app.ts (setPublicEdgeCacheHeaders, isPrivateStorefrontPath) | Setea Vercel-CDN-Cache-Control y Vercel-Cache-Tag en HTML catch-all y endpoints JSON. |
| Interceptor SSR in-process | neges-store · src/app/core/storefront/storefront-ssr-data.interceptor.ts y src/app/app.config.server.ts | Resuelve catalog/product en server sin pasar por el edge. |
| Cache in-memory SSR | neges-store · src/app/core/storefront/storefront.server-api.ts | Dedup del doble-resolve con TTL 5 s. |
| URL de datos del SSR | neges-store · src/app/core/storefront/storefront-api.service.ts | buildApiUrl y getOrigin explican por que existia el self-call. |
| Precios en lote | neges-strapi · src/api/product-price/services/pricing.ts | resolveProductPricesForList, una consulta por lista y paridad con resolucion individual. |
| Payload storefront | neges-strapi · src/api/product/services/storefront-product.ts | Arma catalogo y detalle usando el batch de precios. |
| Invalidacion por tags | neges-strapi · src/utils/vercel-cache-invalidation.ts y src/index.ts | Middleware document-service hacia REST invalidate-by-tags de Vercel. |
| Config Vercel reusada | neges-strapi · config/domainAutomation.ts | Token, project id y team id compartidos con automatizacion de dominios. |
Configuracion
| Variable Strapi en Railway | Uso |
|---|---|
| VERCEL_TOKEN | Token de Vercel con permiso de cache purging, compartido con automatizacion de dominios. |
| VERCEL_STOREFRONT_PROJECT_ID | ID del proyecto neges-store en Vercel. |
| VERCEL_TEAM_ID | ID del team de Vercel. |
| Variable Store en Vercel | Default | Uso |
|---|---|---|
| STOREFRONT_CATALOG_CACHE_TTL_MS | 5000 | TTL de la cache in-memory del catalogo durante SSR. |
| STOREFRONT_PRODUCT_CACHE_TTL_MS | = catalog | TTL de la cache in-memory de la ficha de producto durante SSR. |
La taxonomia actual emite y purga a nivel tenant: tenant:<slug> como tag primario y tenant-domain:<host> como respaldo. En Vercel, el documento base considera limites de 256 caracteres por tag, 16 tags por llamada REST y coma como separador reservado.
Decisiones y tradeoffs
| Decision | Por que | Alternativa descartada |
|---|---|---|
| Interceptor in-process en vez de arreglar la URL del self-call | Elimina el round-trip, usa el mismo codigo que server-app.ts y mejora dedup/invocaciones. | Apuntar el self-call a un origin interno seguia dejando una capa de red fragil y riesgo de re-cache. |
| invalidate en vez de delete | Sirve stale al instante y revalida en background, sin latencia ni stampede. | Delete da frescura en el primer request, pero puede golpear el origen en masa. Queda como posible modo urgente. |
| Reusar VERCEL_TOKEN existente | Cero variables nuevas y menos friccion operativa. | Token dedicado de purga: mas higienico, pero mas operacion. |
| Tag grueso por tenant | Un tag cubre todos los dominios del tenant y es simple. | Tags finos por producto/categoria: menos re-render, mas complejidad y mapping por documentId. |
| Bruto derivado del neto en Strapi | El precio con IVA se calcula y redondea una vez en backend. | Recalcular en frontend invita a divergencias de redondeo. |
| No crear modulo runtime de TTLs | Env vars alcanzan y evitan una consulta o cache extra por pagina. | Config por tenant tenia mas costo que beneficio. |
| No crear boton forzar refresh | Con interceptor y purga automatica el sistema se autocura en segundos. | Endpoint admin con delete sigue siendo viable para emergencias futuras. |
Runbook operativo
Cuando alguien reporta que una pagina no se actualiza, el diagnostico debe separar fuente de verdad, render fresco y copia del edge. Asi se evita culpar al backend cuando el problema es staleness, o culpar al cache cuando el dato base sigue mal.
# 1) Ver estado de copia del edge y antiguedad
curl -sI "https://importadoraroble.cl/products/<slug>" | grep -i "x-vercel-cache\|^age:"
# 2) Comparar copia normal vs render fresco con parametro anti-cache
curl -s --compressed "https://importadoraroble.cl/products/<slug>" -o /tmp/cacheado.html
curl -s --compressed "https://importadoraroble.cl/products/<slug>?cb=$(date +%s)" -o /tmp/fresco.html
# 3) Fuente de verdad: Strapi sin cache del edge
curl -s --compressed -H "X-Store-Host: importadoraroble.cl" \
"https://api.neges.cl/api/storefront/products/<slug>"- Si Strapi todavia devuelve el valor viejo, el problema es de datos o edicion, no de cache.
- Si el render fresco es correcto y la copia normal no, es cache del edge: esperar el proximo request o forzar purga.
- Si el render fresco tambien esta mal, revisar codigo y ensamblado del payload en Strapi.
- Revisar logs de Strapi por [cache-invalidation] purged Vercel edge cache tags.
- Revisar logs por Vercel purge failed 403 si el token no tuviera permiso de purga.
- Para forzar purga sin editar, llamar el endpoint invalidate-by-tags con tenant:<slug>.
Como evolucionar sin reabrir el bug
- Agregar tags finos por producto/categoria: tenant:<slug>:product:<documentId>, :category:<id> y :catalog. Usar documentId, no slug, porque el slug cambia.
- Mantener home y catalogo invalidandose cuando una edicion afecta listados.
- Agregar modo urgente con dangerously-delete-by-tag solo para bajas criticas que no toleran ni un stale.
- No reintroducir self-calls por el edge. Toda lectura que el SSR necesite debe ser in-process o directa a Strapi.
- No recalcular bruto con IVA en el frontend. Usar pricing.unitGrossAmount del payload.
- Mantener cache in-memory SSR con TTL menor o igual a 5 s.
- Recordar el contrato con neges-mcp: sus ediciones pasan por document-service y purgan igual; si cambia la forma consumida por MCP, actualizar neges-mcp segun neges-strapi/MCP-CONTRACT.md.
Changelog relevante
| Repo | Commit | Contenido |
|---|---|---|
| neges-store | 75558df | Tune static asset cache-control headers, immutable en JS/CSS hasheados. |
| neges-store | c976dbf | Add Vercel edge cache + featured badge to product card. |
| neges-store | 6bdadf0 | Doc: clarify cache invalidation reuses existing Vercel config. |
| neges-store | 568737d | Reduce SSR catalog memory cache TTL to 5s. |
| neges-store | 5ba2ab3 | Bypass edge self-calls in SSR storefront: interceptor y fix de causa raiz. |
| neges-strapi | b2ba4de | Optimize storefront product queries con shape liviano de catalogo. |
| neges-strapi | b149b98 | Add batch pricing and Vercel cache invalidation. |
| neges-strapi | 02cc433 | Use domain config for Vercel cache invalidation. |
| neges-strapi | f83e92a | Clean up branding media and log cache purges. |
Glosario
| Termino | Definicion |
|---|---|
| SSR | Server-Side Rendering: el HTML se arma en Vercel Function antes de llegar al navegador. |
| Edge / CDN | Red de servidores cercanos al usuario que guardan copias. Para Chile, el PoP relevante del documento es Sao Paulo, gru1. |
| TTL | Time To Live: cuanto vive una copia antes de considerarse vencida. |
| SWR | Stale-while-revalidate: servir copia vieja al instante mientras se arma una nueva en background. |
| Cache tag | Etiqueta de una copia cacheada para invalidarla selectivamente, por ejemplo tenant:importadora-roble. |
| Invalidate vs Delete | Invalidate marca stale y revalida despues; Delete borra y obliga al proximo request a esperar origen. |
| Self-call | Cuando el servidor hace una peticion HTTP a su propio host publico en vez de resolver en memoria. |
| x-vercel-cache | Header de Vercel que puede mostrar HIT, MISS o STALE segun el estado de la copia. |
Recursos relacionados
¿Te fue útil este contenido?
Tu feedback nos ayuda a mejorar la documentación de Neges.
¿No encuentras lo que buscas?
Conversa con nuestro equipo de soporte en español. Respondemos por WhatsApp, correo o ticket en menos de 24 horas hábiles.