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:
- 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.
- Invalidação. Hoje
clearLoaderCache() limpa um Map local. Com cache
compartilhado a limpeza vira purge por chave, e o /_cache/purge-loaders precisa
acompanhar.
- 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.
- 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.
Resumo
O cache de loader (
createCachedLoader,sdk/cachedLoader.ts) é umMapde 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
cachedLoaderpor isolate: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 loaderisolado (o
/deco/faz bypass da borda por design, então é a única forma de veresta camada sem o edge mascarar).
caches.default)/deco/invoke)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:
O
minmostra que o cache consegue servir em 60-110ms. Ele só quase nunca tem aentrada.
O que já foi descartado
32 MB que nunca é alcançado (o p99 dos isolates segura ~12 MB). Já subimos para
64 MB num site como folga, sem expectativa de mover hit rate — e não moveu.
era o problema; esta camada continua igual.
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:
Mapguarda objetos vivos;caches.defaultguardaResponse.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.
clearLoaderCache()limpa umMaplocal. Com cachecompartilhado a limpeza vira purge por chave, e o
/_cache/purge-loadersprecisaacompanhar.
stale-while-revalidatee a deduplicação porinflightsão hoje in-process. Compartilhado, o single-flight vira problemadistribuído — ou se aceita revalidação duplicada entre isolates.
Mapcomo L1 (grátis, mesma isolate) ecaches.defaultcomo 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.mdjá diz "Entry SIZE is theconstraint, not hit rate", e estes números não contradizem.
Como reproduzir
A fase 3 mede cada loader em dois modos,
serialefanned— a diferença entre osdois é justamente o custo de fragmentação por isolate.