Cache architecture (técnico)
Visão sistêmica das camadas de cache na plataforma, com fluxos ponta-a-ponta, decisões arquiteturais e arquivos relevantes. Esta é a referência canônica pra entender por que cada cache existe, o que cada um cobre e como interagem.
Para a visão "pra quem não é técnico", veja Cache strategy. Para playbooks de operação, veja Cache operations.
Sumário
Camadas
[Browser]
├── HTTP cache (Cache-Control)
├── Service Worker (public/sw.js) — cacheia DOCUMENTOS de navegação,
│ /api/games/*, .data, /assets/*, imagens. Isolamento per-user
│ pelas RESPOSTAS (no-store), não por cookie → ver ./service-worker
├── localStorage (api-client LocalStorageCache, TTL 60s)
└── sessionStorage (search, FTD flow, onboarding)
⇣ HTTPS
[Cloudflare Zone]
└── Cache Reserve (opcional, CT only) — não usado hoje
⇣
[Brand Worker — front-web-base]
├── Worker Middleware (workers/middleware.ts)
│ ├── SSR cache — namespace ssr:${BUILD_ID} (CF Cache API + KV)
│ ├── SSR autenticado — namespace ssr-auth:${BUILD_ID} (per-sessão, KV 600s)
│ └── Assets — namespace assets:${BUILD_ID} (+ R2 archive fallback)
│ (namespaces de borda prefixados `a-${CACHE_GENERATION}-`)
│
├── platform-cache engine (memory + CF Cache API + KV snapshot)
│ namespace: platform-cache-<CACHE_GENERATION>
│ policy: PLATFORM_CACHE_POLICY_JSON
│
└── api-client (delegateCaching: true → no-op server-side)
└── server: BYPASS — every call goes to service-api
└── browser: LocalStorageCache TTL 60s
⇣ Service Binding API_SERVICE (no network)
[service-api Worker — Frankfurt, Smart Placement]
└── Per-path cache (CF Cache API + KV)
namespace: ${BUILD_KEY}-${GLOBAL_VERSION}
policy: SERVICE_API_CACHE_POLICY_JSON
⇣
[BFF — api.bs2bet.com / bluetecconnect.com]
:::info Duas camadas foram REMOVIDAS em 2026-07
api:${BUILD_ID} e spa-shell:${BUILD_ID} não existem mais. O código carrega
comentários-tombstone nos dois pontos (workers/middleware.ts:775-789 e
:1056-1061). Ver Camadas removidas.
:::
Fluxo end-to-end
Brand config (caso canônico)
User → CF Zone → Brand Worker SSR cache (cold)
↓
platform-cache.engine.fetch("brandConfig", ...)
↓ tier 1: memory (per-isolate Map)
↓ tier 2: CF Cache API "platform-cache-<CACHE_GENERATION>"
↓ tier 3: KV snapshot PLATFORM_CACHE_KV
↓ origin:
ApiClient.get("/bff/features") + parallel calls
↓ (delegateCaching: true → no api-client cache)
↓ env.API_SERVICE.fetch(...)
↓
service-api proxy (Frankfurt)
↓ CF Cache API + KV API_CACHE_KV
↓ origin:
BFF /v2/bff/features
↓ mtls (BT) ou cf-worker-key-proxy (CT)
BFF response
⇡
service-api writes to its KV/CF
⇡
platform-cache writes to memory + CF + KV snapshot
⇡
SSR renders → Brand Worker SSR cache writes
⇡
CF Zone caches HTML (if anon)
⇡
User receives ~200ms total (cold)
~5ms next visit (everything warm)
Invalidação após mudança no backoffice
Caso "mudei a ordem de payment-providers no backoffice":
1. Backoffice → BFF DB write (instant)
2. TTL natural: 5min no service-api (payment-providers rule)
3. Visitor após 5min do TTL: cache miss → re-fetch → fresh data
ou força explícita:
1. DevOps roda manual-cache-purge.yml
tags: payments
scope: all
2. Workflow → POST https://<brand>/api/cache/purge body:{ tags: ["payments"] }
3. Brand worker:
a. engine.purgeByTag("payments") — afeta resources locais com tag "payments"
(atualmente: nenhum no platform-cache; payments mora no service-api)
b. Cascade: env.API_SERVICE.fetch("/__cache_purge__", { originDomain, tags })
4. service-api:
a. Tag purge é brand-wide nesta camada — não há índice tag→key.
Tags são logadas pra observabilidade mas o delete é por originDomain (scope: all entries daquela brand).
A resposta retorna scope: "brand-wide (originDomain; tags/glob ignored)" + intendedPathsIgnored com as paths resolvidas, pra deixar claro que o delete foi mais amplo que o pedido.
(Um índice tag→key real é follow-up futuro; tag-only sem originDomain faz fallback pra purgeAll.)
b. KV delete + CF Cache API best-effort pra todas as entries do originDomain
5. Próxima visita: cache miss → BFF → data fresca
Invalidação em deploy
push na trunk → deploy.yml dispara
→ wrangler deploy --keep-vars --var BUILD_ID:<sha[0:12]>
→ BUILD_ID muda → namespaces ssr:/ssr-auth:/assets: ficam órfãos
→ KV entries antigas expiram naturalmente
(SSR público: KV_SSR_TTL_DEFAULT 180s, override por env;
SSR autenticado: AUTH_KV_SSR_TTL 600s; assets arquivados: 7d)
→ o box do Service Worker no browser também rotaciona (chave por BUILD_ID)
→ POST /api/cache/purge?segments=<csv> — platform-cache purge
→ POST /zones/<id>/purge_cache {purge_everything: true} — CF zone
→ curl -m 8 em 10 paths (warm-up paralelo)
Invariantes arquiteturais
1. Per-user data NEVER entra em cache compartilhado
platform-cache.engine.fetch() rejeita resource names que casem com FORBIDDEN_RESOURCE_PATTERNS (wallet, balance, profile, kyc, auth, rewards, gamification, bets, history, referral, deposit, withdraw, income, bonus, user, etc).
Para per-user, use engine.fetchWithKey(resource, userId, ...) com suffix obrigatório. Ver services/favorites.cache.server.ts como canônico.
Postmortem 2026-04-29: balance leak entre usuários por causa de cache user-scoped sem suffix.
2. api-client.serverCache está em modo delegateCaching no brand worker
app/services/api.server.ts:88 seta delegateCaching: true. O api-client passa direto pro service-api sem consultar GlobalCache nem KV. Isso elimina a duplicação de TTLs entre api-client e platform-cache que causou staleness em /payment-providers e /appearance.
Resources hoje cacheados pelo brand worker passam pelo platform-cache engine ONLY.
3. Tags são vocabulário compartilhado entre brand worker e service-api
platform-cache declara tags: [...] por resource em front-ops/config/cache/defaults.yml.
service-api declara tags: [...] por path em front-ops/config/cache/service-api-defaults.yml.
A intersecção (tags como brand, catalog, games:list, payments) permite que um único manual-cache-purge.yml tags: brand ataque ambas as camadas com a mesma semântica.
4. BUILD_ID invalida edge cache, mas NÃO platform-cache
Por design. O platform-cache é stateful entre deploys — repopular o KV snapshot a cada deploy custaria muito ($KV + tempo). Em vez disso:
- Deploy bump BUILD_ID →
ssr:/ssr-auth:/assets:viram cold - Post-deploy purge explícito é responsável por invalidar
platform-cachesegments mutáveis (homeRows, brandConfig, etc)
O catch: novo resource adicionado precisa de PR no front-ops pra ser registrado, senão engine.fetch() cai em BYPASS silencioso (com warning console uma vez por isolate).
5. KV snapshot é cross-DC warm tier, NÃO disaster recovery
KvSnapshotStore no platform-cache engine é lido antes da origin quando primary tier (CF Cache API per-DC) é miss. Sem isso, cada datacenter pagaria o custo BFF na 1ª request.
Não confundir com o último-recurso KV snapshot que serve durante origin outage (step 6 do pipeline).
6. CACHE_NAMESPACE separa stage/prod que compartilham ORIGIN_DOMAIN
Cache key format: https://${CACHE_NAMESPACE ?? ORIGIN_DOMAIN}-cache/{keyPrefix}[:suffix]/v1
CACHE_NAMESPACE é injetado pelo front-ops/deploy.yml como worker_name.
Garante isolamento quando vários ambientes compartilham ORIGIN_DOMAIN — caso
real hoje: cl.bet7k.com é o origin_domain de quatro ambientes
(bet7k-cl na trunk main-7k, cl-bet7k-com na main-cl-bet7k-com, o stage em
stage-spa-v2 e o sports-only em sports). Sem CACHE_NAMESPACE os quatro
workers colidiriam na mesma chave. Ver Trunks de marca.
7. O ceiling de 1h vale pros TTLs de endpoint do front-service-api
O único clamp de runtime que existe hoje está no front-service-api:
MAX_CACHE_TTL = 3600 + dois Math.min (src/index.ts). Um TTL de endpoint
acima de 1h editado por acidente no YAML é cortado ali.
Nas outras camadas o teto é convenção no YAML, não código:
front-ops/config/cache/service-api-defaults.yml— todos osttl≤ 3600front-ops/config/cache/defaults.yml— todos osttlSeconds≤ 3600 (brandConfigé 60s)packages/platform-cachenão tem clamp — nem empolicy.tsnem emengine.ts
E o clamp não se aplica a TTL de documento SSR, que é env-driven e
deliberadamente pode passar de 1h — o experimento stage-spa roda com
SSR_CACHE_HTML_TTL=7d. Não leia este invariante como "nada nunca passa de 1h";
leia como "TTL de endpoint no service-api é clampado".
:::note Pendente de decisão do owner
O front-web-base API_CACHE_RULES citado aqui antes não existe mais (a
camada api: foi removida — ver abaixo). Se o teto de 1h deve ou não voltar a
ser aplicado programaticamente nas outras camadas é decisão de quem é dono da
política de cache; a doc aqui descreve só o que o código faz hoje.
:::
staleWhileRevalidateSeconds e staleIfErrorSeconds são janelas de
resiliência, não TTLs de endpoint — não estão sujeitos a este ceiling.
staleIfErrorSeconds é 6h (21600) pra maioria dos resources em defaults.yml,
com exceções (wins/stats usam 1800; gameDetail/allGames/gameCategory/
gameProvider usam 7200).
8. KV_SSR_TTL — o teto real da foto global do documento
O cache de HTML SSR anônimo em KV é o teto real da foto global: o CF Cache
API ignora stale-while-revalidate (hard-MISS pós-s-maxage) e é
por-PoP/evictável; é o KV que repopula um PoP frio com a mesma foto.
// workers/middleware.ts
const KV_SSR_TTL_DEFAULT = 180; // 3 minutos
export function resolveKvSsrTtl(env?: Env): number {
const raw = Number(env?.SSR_CACHE_KV_TTL);
return Number.isFinite(raw) && raw > 0 ? raw : KV_SSR_TTL_DEFAULT;
}
180s por default, sobrescrevível por ambiente via SSR_CACHE_KV_TTL. O
override existe porque o KV precisa acompanhar a janela do documento: com
SSR_CACHE_HTML_TTL=7d (experimento stage-spa), um KV de 180s serviria uma
foto diferente da do edge.
Banner sub-TTL sem deploy/purge requer excluir a rota home do SSR-HTML caching
ou renderizar o banner client-side a partir do brandConfig (60s). Essa mudança
de design fica para spec separado.
8b. Tier de SSR autenticado — ssr-auth:${BUILD_ID}
Existe uma camada separada pra documento de usuário logado, keyed pelo hash
do token de sessão (composeSsrCacheKey com mode: "authenticated"):
| Aspecto | Valor |
|---|---|
| Namespace | ssr-auth:${BUILD_ID} (prefixado a-${CACHE_GENERATION}- na borda) |
| Chave | hash do token de sessão + brand + buildId |
| KV TTL | AUTH_KV_SSR_TTL = 600 (10 min) |
| Overrides por env | AUTH_SSR_CACHE_HTML_TTL, AUTH_SSR_CACHE_DATA_TTL |
O segmento auth (g/u) da chave de SSR é um invariante duro de segurança
(isolamento guest/user — postmortem de balance-leak 2026-04-29) e nunca pode ser
removido.
A evolução planejada é a Fase 2, atrás do kill-switch
SHARED_PUBLIC_DOC_FOR_AUTH (workers/entry.ts, middleware.ts): servir o
documento anônimo compartilhado também pro logado, com reidratação de auth no
client. Isso já é possível porque o SSR é auth-agnóstico — ver
Fluxo de autenticação.
Camadas removidas (2026-07)
Duas camadas do middleware foram deletadas e o motivo está registrado em comentário no próprio código. Vale conhecer o porquê, porque é o mesmo raciocínio da Invariante 2:
api:${BUILD_ID} (workers/middleware.ts:775-789) cacheava a resposta CRUA
do BFF pra /api/* (API_CACHE_RULES + getApiCacheRule + handleApiCache).
Removida porque todo /api/* proxia pro BFF via service binding API_SERVICE e
o front-service-api já é a única fonte de cache raw-BFF. Cachear aqui era
redundante e causava staleness por conflito de TTL — este layer fixava 3600s
enquanto o service-api usa TTLs menores por endpoint. O único path que não
passava pelo binding (/api/cactus-sportbook/*) hoje também passa.
spa-shell:${BUILD_ID} (workers/middleware.ts:1056-1061) bufferizava o
stream inteiro pra montar um shell que nunca montava nada. O caminho pra logado
em rota pública é o SSR streamado real.
Consequência prática: não existe whitelist de cache de /api/* no
front-web-base. Se você está debugando uma resposta /api/* stale, o TTL
está em front-ops/config/cache/service-api-defaults.yml.
9. CACHE_GENERATION tem DUAS fontes — saiba qual vence
| Fonte | Papel |
|---|---|
front-ops/config/cache/generation.yml → current: | Fonte operacional de verdade. Rotacionada pelo workflow bump-cache-generation.yml, injetada como env var no deploy |
packages/platform-cache/src/generation.ts → DEFAULT_CACHE_GENERATION | Só fallback in-source, usado quando nenhuma env var é injetada |
As duas divergem por design — a de código não é bumpada a cada rotação. Não
pine o valor numa doc nem num teste: leia
front-ops/config/cache/generation.yml. Bumps são operação rotineira, não
rara (o arquivo acumulou mais de 20 gerações em ~2 meses), então qualquer número
escrito aqui estaria errado antes de você ler.
Um bump rotaciona o cacheName e o hash de chave em todas as camadas, tornando
as entries KV antigas órfãs (não deletadas explicitamente). Elas TTL-evictam
na janela do próprio TTL — que pra documento SSR pode ser bem maior que 1h em
ambientes com SSR_CACHE_HTML_TTL alto (ver Invariante 8). O histórico de
generation.yml registra pelo menos um caso de documento preso num cache longo
que exigiu bump justamente por isso. Para wipe instantâneo, um POST
{ all: true } adicional ao service-api seria necessário — documentado como
follow-up (D3 Option B), não implementado.
Brand-worker redeploy é obrigatório em um generation bump. O workflow
bump-cache-generation.yml faz redeploy do brand worker por padrão
(brand_workers: all) e FALHA se brand_workers: none for passado, pra prevenir
que um bump deixe workers em gerações diferentes.
:::note O Service Worker não acompanha
public/sw.js carrega o token de geração como literal (um SW roda no browser
sem acesso ao env do wrangler). A invalidação dele é por BUILD_ID, não por
CACHE_GENERATION. Ver Service Worker.
:::
Decisões de design
Por que duas camadas (platform-cache + service-api), não uma?
- Localidade: brand worker roda no DC mais próximo do user. service-api roda em Frankfurt (perto do BFF). 1 chamada user → brand worker = ~10ms. 1 chamada brand worker → BFF direto = ~300ms (transatlântico). Com service-api: brand worker → Frankfurt (~80ms via Smart Placement) → BFF (~5ms intra-region).
- Granularidade: platform-cache cacheia payload transformado (homeRows = dezenas de chamadas BFF agregadas + transformadas). service-api cacheia resposta HTTP crua por path. Os dois servem propósitos distintos e coexistem sem duplicação semântica.
Por que api-client não cacheia mais no server?
Resposta longa em Camadas > api-client e na "Invariante 2" acima. Em curto: dois sistemas com TTLs independentes cobrindo as mesmas rotas → drift inevitável → postmortems.
Por que cache tags em vez de só segments?
Tags permitem:
- Agrupamento lógico sem enumerar nomes (
tags: catalog= todos resources de catálogo) - Globs pra famílias (
games:*cobregames:list,games:detail,games:wins, etc) - Vocabulário compartilhado com service-api (mesmo
tags: paymentspurga ambos)
Segments continuam aceitos por backward-compat (deploy.yml legado, automações antigas).
Por que não usar Cache-Tag nativo do Cloudflare (Enterprise feature)?
CT tem Enterprise plan, então tecnicamente disponível. Foi adiado porque:
- Requer BFF ou service-api emitir header
Cache-Tagem cada response - Funciona só pra cache de zone (não para platform-cache memory tier nem KV snapshot)
- App-level tags cobrem 95% do uso e funcionam idênticas em CT e BT
Pode ser uma Fase 6 opcional no futuro pra otimizar SSR HTML caching especificamente.
Por que TTL do brandConfig é tão baixo (60s)?
Trade-off explícito:
- Antes: TTL 3600s + SWR 300s → 1 hit BFF a cada 1h, mas mudanças no backoffice levavam 1h pra aparecer
- Agora: TTL 60s + SWR 300s → 1 hit BFF a cada 5min (graças ao SWR), mudanças aparecem em ≤1min
O hit extra no BFF é absorvido pelo single-flight coalescing do platform-cache (única request por isolate por chave). Estimativa: ~20 chamadas BFF/min global (vs 1/h antes) — desprezível pro BFF.
Por que delegateCaching no api-client em vez de remover o cache server-side?
Backward compatibility. Forks e legacy code paths ainda usam api-client direto sem platform-cache acima. A flag permite cutover gradual:
- Brand worker novo:
delegateCaching: true(passa pra platform-cache) - Forks/legacy: continuam usando o cache do api-client com TTLs reduzidos (60s)
Eliminação total fica pra um major version bump do api-client.
Configuração end-to-end
Variáveis de ambiente
| Variável | Onde | Valor | Função |
|---|---|---|---|
BUILD_ID | brand worker | 12-char SHA | Namespace de ssr:/ssr-auth:/assets: + tracing header + versão do Service Worker |
CACHE_GENERATION | brand worker + service-api | token | Prefixo a-<gen>- dos namespaces de borda + namespace do platform-cache. SoT: front-ops/config/cache/generation.yml |
CACHE_NAMESPACE | brand worker | worker_name | Isolation entre ambientes que compartilham ORIGIN_DOMAIN |
ORIGIN_DOMAIN | brand worker | hostname | Cache key domain |
SSR_CACHE_HTML_TTL | brand worker | segundos | TTL do documento SSR público (s-maxage). Pode exceder 1h por design |
SSR_CACHE_KV_TTL | brand worker | segundos | Override do KV_SSR_TTL_DEFAULT (180s) |
AUTH_SSR_CACHE_HTML_TTL / AUTH_SSR_CACHE_DATA_TTL | brand worker | segundos | Overrides do tier ssr-auth: |
SHARED_PUBLIC_DOC_FOR_AUTH | brand worker | "true" | Kill-switch da Fase 2 (doc anônimo compartilhado pra logado) |
PLATFORM_CACHE_POLICY_JSON | brand worker | JSON | Policy do platform-cache |
PLATFORM_CACHE_KV | brand worker | KV binding | Snapshot tier do platform-cache |
CACHE_PURGE_SECRET | brand worker + service-api | string | Auth pro /api/cache/purge e /cache_purge |
SERVICE_API_CACHE_POLICY_JSON | service-api | JSON | Policy do service-api proxy |
API_CACHE_KV | service-api | KV binding | Cache cross-DC do proxy |
YAML fontes
| Arquivo | Consumer | Geração |
|---|---|---|
front-ops/config/cache/defaults.yml | brand worker | deploy.yml resolve via yq merge → PLATFORM_CACHE_POLICY_JSON |
front-ops/config/cache/service-api-defaults.yml | service-api | front-service-api/scripts/build-cache-policy.mjs lê → JSON em --var no wrangler deploy |
front-ops/config/cache/generation.yml | brand worker + service-api | current: é a geração ativa; bumpada pelo bump-cache-generation.yml |
front-ops/config/brands/<brand>/cache.yml | brand worker | Override per-brand do defaults.yml |
front-ops/config/brands/<brand>/environments/<env>/cache.yml | brand worker | Override per-env |
Workflows GH Actions
| Workflow | Trigger | Função |
|---|---|---|
deploy.yml | push/dispatch | Deploy brand worker + post-deploy purge + warmup |
manual-cache-purge.yml | dispatch | Purge ondemand com tags/glob/segments + scope (platform-cache, service-api, zone, all) |
cache-status.yml | dispatch | Inspeciona estado live (TTLs restantes) via /api/cache/inspect |
bump-cache-generation.yml | dispatch | Rotaciona generation.yml → current: + redeploy dos brand workers |
Arquivos relevantes
Core (@cactus-agents/*)
packages/platform-cache/src/engine.ts— pipeline + purgeByTag/Globpackages/platform-cache/src/policy.ts— parseCachePolicy, findResourcesByTag, listAllTagspackages/platform-cache/src/purge-handler.ts— createPurgeHandler aceita tags/glob/segmentspackages/platform-cache/src/types.ts— ResourcePolicy.tags, CachePolicypackages/platform-cache/src/stores/— CacheApiStore, KvSnapshotStore, NoopSnapshotStorepackages/platform-cache/src/generation.ts—DEFAULT_CACHE_GENERATION(só fallback)packages/api-client/src/cache.ts— ServerCacheConfig.delegateCaching, DEFAULT_API_CACHE_RULES (60s)packages/api-client/src/client.ts— getCacheInfo respeita delegateCaching
Base (front-web-base)
workers/middleware.ts— cachesssr:,ssr-auth:,assets:(+ tombstones das camadas removidas)workers/entry.ts— kill-switchSHARED_PUBLIC_DOC_FOR_AUTHpublic/sw.js— Service Worker (cache de documento no browser) → Service Workerapp/utils/force-clear-cache.client.ts/app/utils/version-info.client.ts— escape hatches do SWapp/services/platform-cache.server.ts— singleton sharedPrimaryStore + factoryapp/services/api.server.ts— createCactusServerClient com delegateCaching: trueapp/services/brand.server.ts— engine.fetch("brandConfig")app/services/games.cache.server.ts— engine.fetch p/ todos resources de gamesapp/routes/api/cache/purge.ts— endpoint /api/cache/purge (tags/glob/segments + cascade)app/routes/api/cache/inspect.ts— endpoint /api/cache/inspect (live state)app/routes/api/dev/cache-policy.ts— endpoint /api/dev/cache-policy (declared)
service-api (front-service-api)
src/index.ts— proxy logic, loadCacheRules, handleCachePurge (com tags forward-compat)scripts/build-cache-policy.mjs— lê YAML do front-ops, emite JSONpackage.jsonscripts:deploy:ct,deploy:bt(injeta SERVICE_API_CACHE_POLICY_JSON)
front-ops (front-ops)
config/cache/defaults.yml— platform-cache policy SoTconfig/cache/service-api-defaults.yml— service-api policy SoTconfig/cache/generation.yml—current:é a geração ativa (SoT operacional).github/workflows/deploy.yml— deploy brand worker + post-deploy purge + warmup.github/workflows/manual-cache-purge.yml— purge ondemand.github/workflows/cache-status.yml— inspect ondemand.github/workflows/bump-cache-generation.yml— rotação de geração
Docs relacionadas
- Cache strategy (não-técnico)
- Service Worker — a camada de cache dentro do browser
- Cache operations — playbooks
- Trunks de marca — por que ambientes compartilham domínio