Pular para o conteúdo principal

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-cache segments 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 os ttl ≤ 3600
  • front-ops/config/cache/defaults.yml — todos os ttlSeconds ≤ 3600 (brandConfig é 60s)
  • packages/platform-cache não tem clamp — nem em policy.ts nem em engine.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"):

AspectoValor
Namespacessr-auth:${BUILD_ID} (prefixado a-${CACHE_GENERATION}- na borda)
Chavehash do token de sessão + brand + buildId
KV TTLAUTH_KV_SSR_TTL = 600 (10 min)
Overrides por envAUTH_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

FontePapel
front-ops/config/cache/generation.ymlcurrent:Fonte operacional de verdade. Rotacionada pelo workflow bump-cache-generation.yml, injetada como env var no deploy
packages/platform-cache/src/generation.tsDEFAULT_CACHE_GENERATIONSó 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:* cobre games:list, games:detail, games:wins, etc)
  • Vocabulário compartilhado com service-api (mesmo tags: payments purga 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:

  1. Requer BFF ou service-api emitir header Cache-Tag em cada response
  2. Funciona só pra cache de zone (não para platform-cache memory tier nem KV snapshot)
  3. 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ávelOndeValorFunção
BUILD_IDbrand worker12-char SHANamespace de ssr:/ssr-auth:/assets: + tracing header + versão do Service Worker
CACHE_GENERATIONbrand worker + service-apitokenPrefixo a-<gen>- dos namespaces de borda + namespace do platform-cache. SoT: front-ops/config/cache/generation.yml
CACHE_NAMESPACEbrand workerworker_nameIsolation entre ambientes que compartilham ORIGIN_DOMAIN
ORIGIN_DOMAINbrand workerhostnameCache key domain
SSR_CACHE_HTML_TTLbrand workersegundosTTL do documento SSR público (s-maxage). Pode exceder 1h por design
SSR_CACHE_KV_TTLbrand workersegundosOverride do KV_SSR_TTL_DEFAULT (180s)
AUTH_SSR_CACHE_HTML_TTL / AUTH_SSR_CACHE_DATA_TTLbrand workersegundosOverrides do tier ssr-auth:
SHARED_PUBLIC_DOC_FOR_AUTHbrand worker"true"Kill-switch da Fase 2 (doc anônimo compartilhado pra logado)
PLATFORM_CACHE_POLICY_JSONbrand workerJSONPolicy do platform-cache
PLATFORM_CACHE_KVbrand workerKV bindingSnapshot tier do platform-cache
CACHE_PURGE_SECRETbrand worker + service-apistringAuth pro /api/cache/purge e /cache_purge
SERVICE_API_CACHE_POLICY_JSONservice-apiJSONPolicy do service-api proxy
API_CACHE_KVservice-apiKV bindingCache cross-DC do proxy

YAML fontes

ArquivoConsumerGeração
front-ops/config/cache/defaults.ymlbrand workerdeploy.yml resolve via yq merge → PLATFORM_CACHE_POLICY_JSON
front-ops/config/cache/service-api-defaults.ymlservice-apifront-service-api/scripts/build-cache-policy.mjs lê → JSON em --var no wrangler deploy
front-ops/config/cache/generation.ymlbrand worker + service-apicurrent: é a geração ativa; bumpada pelo bump-cache-generation.yml
front-ops/config/brands/<brand>/cache.ymlbrand workerOverride per-brand do defaults.yml
front-ops/config/brands/<brand>/environments/<env>/cache.ymlbrand workerOverride per-env

Workflows GH Actions

WorkflowTriggerFunção
deploy.ymlpush/dispatchDeploy brand worker + post-deploy purge + warmup
manual-cache-purge.ymldispatchPurge ondemand com tags/glob/segments + scope (platform-cache, service-api, zone, all)
cache-status.ymldispatchInspeciona estado live (TTLs restantes) via /api/cache/inspect
bump-cache-generation.ymldispatchRotaciona generation.ymlcurrent: + redeploy dos brand workers

Arquivos relevantes

Core (@cactus-agents/*)

  • packages/platform-cache/src/engine.ts — pipeline + purgeByTag/Glob
  • packages/platform-cache/src/policy.ts — parseCachePolicy, findResourcesByTag, listAllTags
  • packages/platform-cache/src/purge-handler.ts — createPurgeHandler aceita tags/glob/segments
  • packages/platform-cache/src/types.ts — ResourcePolicy.tags, CachePolicy
  • packages/platform-cache/src/stores/ — CacheApiStore, KvSnapshotStore, NoopSnapshotStore
  • packages/platform-cache/src/generation.tsDEFAULT_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 — caches ssr:, ssr-auth:, assets: (+ tombstones das camadas removidas)
  • workers/entry.ts — kill-switch SHARED_PUBLIC_DOC_FOR_AUTH
  • public/sw.js — Service Worker (cache de documento no browser) → Service Worker
  • app/utils/force-clear-cache.client.ts / app/utils/version-info.client.ts — escape hatches do SW
  • app/services/platform-cache.server.ts — singleton sharedPrimaryStore + factory
  • app/services/api.server.ts — createCactusServerClient com delegateCaching: true
  • app/services/brand.server.ts — engine.fetch("brandConfig")
  • app/services/games.cache.server.ts — engine.fetch p/ todos resources de games
  • app/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 JSON
  • package.json scripts: deploy:ct, deploy:bt (injeta SERVICE_API_CACHE_POLICY_JSON)

front-ops (front-ops)

  • config/cache/defaults.yml — platform-cache policy SoT
  • config/cache/service-api-defaults.yml — service-api policy SoT
  • config/cache/generation.ymlcurrent: é 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