Skip to content

perf(cache): o cache de loader é por isolate e quase nunca serve (3-28% quente com 12 chaves em 10min) #536

Description

@JonasJesus42

Resumo

O cache de loader (createCachedLoader, sdk/cachedLoader.ts) é um Map de módulo,
ou seja por isolate. Num Worker com rotatividade alta de isolates ele quase nunca
serve: medido sob carga, com 12 chaves quentes martelizadas por 10 minutos, a
fração de respostas quentes ficou entre 3% e 28%.

Não é problema de TTL nem de cap de bytes. É a topologia: o cache morre com o isolate.

Evidência 1 — a vida do isolate

Produção (montecarlo, hora de pico, 3.808 isolates distintos numa hora), lookups de
cachedLoader por isolate:

lookups
p50 1
p90 12
p99 25
máximo 78

O isolate mediano faz um lookup e morre. Não há segunda chance de acertar.

Evidência 2 — teste de carga por camada (10 min)

Contra o worker de um site real, tráfego sustentado, 4.292 requisições, zero erro.
HTML para exercitar a borda e POST /deco/invoke/<loader> para exercitar o loader
isolado (o /deco/ faz bypass da borda por design, então é a única forma de ver
esta camada sem o edge mascarar).

camada resultado
HTML (borda, caches.default) hit 97,2%, p50 108ms
loader (/deco/invoke) p50 1739ms, p95 4468ms

A borda aquece em ~2 min e segura. O loader não aquece nunca.

Detalhe por chave — cada uma recebeu 150-190 chamadas idênticas em 10 minutos:

entrada quente p50 min
804 KB 6% 3377ms 95ms
707 KB 3% 3394ms 107ms
520 KB 4% 3352ms 793ms
367 KB 20% 2675ms 76ms
316 KB 22% 1823ms 88ms
122 KB 28% 812ms 71ms
15 KB 22% 575ms 58ms

O min mostra que o cache consegue servir em 60-110ms. Ele só quase nunca tem a
entrada.

O que já foi descartado

Proposta para discussão

Apoiar o cache de loader em algo compartilhado por colo em vez de por isolate —
caches.default é o candidato óbvio, já que a camada de borda usa e entrega 97%.

Pontos que precisam de decisão, e por isso isto é issue e não PR:

  1. Serialização. O Map guarda objetos vivos; caches.default guarda Response.
    Passa a haver custo de JSON por hit. Para uma entrada de 800 KB isso não é
    desprezível e pode comer parte do ganho — precisa medir antes de assumir.
  2. Invalidação. Hoje clearLoaderCache() limpa um Map local. Com cache
    compartilhado a limpeza vira purge por chave, e o /_cache/purge-loaders precisa
    acompanhar.
  3. SWR e single-flight. O stale-while-revalidate e a deduplicação por
    inflight são hoje in-process. Compartilhado, o single-flight vira problema
    distribuído — ou se aceita revalidação duplicada entre isolates.
  4. Híbrido? Manter o Map como L1 (grátis, mesma isolate) e caches.default
    como L2 seria o melhor dos dois, ao custo de duas camadas para raciocinar.

Segunda frente, independente

O tamanho das entradas (até 804 KB por PLP) merece atenção separada: as três
maiores foram as que menos aqueceram (3-6%) contra 20-28% das menores.

Ressalva: essa correlação é sugestiva, não causal provada — o tráfego HTML do
teste divide os mesmos isolates e também povoa o cache, então não dá para atribuir
tudo a evicção. Mas skills/knowledge/perf/edge-caching.md já diz "Entry SIZE is the
constraint, not hit rate", e estes números não contradizem.

Como reproduzir

npx -p '@decocms/parity@0.33.14' parity cache-gap \
  --url <worker-url> --dir <repo-do-site> \
  --pages 30 --loaders --loader-limit 30 --max-requests 1500

A fase 3 mede cada loader em dois modos, serial e fanned — a diferença entre os
dois é justamente o custo de fragmentação por isolate.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions