Nota de investigação (rascunho). Objetivo: reduzir os
429 MaxRequestsByInstancedo License Manager durante rollouts, recriando no rewriter o "cache compartilhado entre pods" que a Tenant API tinha de graça via ApiCache.
Contexto (resumo)
- O rewriter chama
LicenseManager.listBindings(/api/license-manager/binding/site/{account}) direto pelo endpoint público. - O LM responde
Vary: ...VtexIdclientAutCookie, Authorization, e o node-vtex-api mandaVtexIdclientAutCookie: ctx.vtex.authToken, que rotaciona a cada request. - Resultado: o ApiCache do Janus fragmenta por token → não compartilha entre pods → no rollout (muitos pods frios juntos) todo mundo bate na origem do LM →
429. - A LRU em processo (por pod, chaveada por URL) segura o steady-state, mas não compartilha entre pods. O eixo "entre pods" é justamente o que falta.
VBase é storage do VTEX IO compartilhado entre todos os pods da conta+workspace, e não depende do LM. É a camada ideal pro eixo "entre pods".
O que salvaríamos
O resultado já mapeado da binding-discovery — o mesmo que fetchBindings devolve hoje:
_10{_10 "bindings": [ /* Binding[] já passado por toBinding */ ],_10 "fallbackLocale": "pt-BR"_10}
O helper staleFromVBaseWhileRevalidate já envelopa isso com { data, ttl }, então não precisamos gerenciar TTL na mão.
Onde mudaríamos (bem cirúrgico)
Um único arquivo: node/utils/bindings/bindings.ts.
Hoje fetchBindings faz as chamadas ao LM + Catalog e mapeia:
_18const fetchBindings = async (_18 ctx: Context,_18 cacheTTL?: number_18): Promise<{ bindings: Binding[]; fallbackLocale: string }> => {_18 const deps = new BindingDependencies(ctx)_18_18 const [salesChannel, lmBindings] = await Promise.all([_18 deps.getSalesChannel(cacheTTL),_18 deps.listBindings(cacheTTL),_18 ])_18_18 const fallbackLocale = salesChannel.CultureInfo_18 const bindings = lmBindings.map((lmBinding: APIBindingRes) =>_18 toBinding(lmBinding, fallbackLocale)_18 )_18_18 return { bindings, fallbackLocale }_18}
A ideia: extrair esse corpo para fetchBindingsFromLM (o "revalidate function") e fazer fetchBindings só envolver com o helper de VBase que já existe (node/compatibility/vbase.ts).
_37// node/utils/bindings/bindings.ts (esboço)_37import { staleFromVBaseWhileRevalidate } from '../../compatibility/vbase'_37_37const BINDINGS_CACHE_BUCKET = 'bindings-cache'_37const BINDINGS_CACHE_KEY = 'store-bindings' // chave FIXA (account/workspace são implícitos no VBase)_37const BINDINGS_CACHE_TTL_MIN = 30_37_37// corpo atual, intacto — as chamadas reais ao LM + Catalog + toBinding_37const fetchBindingsFromLM = async (_37 ctx: Context,_37 cacheTTL?: number_37): Promise<{ bindings: Binding[]; fallbackLocale: string }> => {_37 const deps = new BindingDependencies(ctx)_37 const [salesChannel, lmBindings] = await Promise.all([_37 deps.getSalesChannel(cacheTTL),_37 deps.listBindings(cacheTTL),_37 ])_37 const fallbackLocale = salesChannel.CultureInfo_37 const bindings = lmBindings.map((lmBinding: APIBindingRes) =>_37 toBinding(lmBinding, fallbackLocale)_37 )_37 return { bindings, fallbackLocale }_37}_37_37// nova camada: VBase (entre pods) por fora, LM (com LRU por pod) por dentro_37const fetchBindings = (_37 ctx: Context,_37 cacheTTL?: number_37): Promise<{ bindings: Binding[]; fallbackLocale: string }> =>_37 staleFromVBaseWhileRevalidate(_37 ctx.clients.vbase,_37 BINDINGS_CACHE_BUCKET,_37 BINDINGS_CACHE_KEY,_37 () => fetchBindingsFromLM(ctx, cacheTTL),_37 undefined,_37 { expirationInMinutes: BINDINGS_CACHE_TTL_MIN }_37 )
Quem chama fetchBindings não muda (getDefaultStoreBinding, BindingResolver.discover).
Fluxo final (3 camadas de cache)
_10request_10 └─ fetchBindings_10 ├─ VBase (compartilhado entre pods) ← só precisa de account/workspace, NÃO toca o LM_10 │ ├─ fresco → devolve, fim ✅_10 │ └─ stale → devolve o stale AGORA + revalida em background_10 └─ fetchBindingsFromLM (na revalidação / miss)_10 └─ LM + Catalog (com LRU em processo por pod)
Efeito: o LM passa a ser tocado ~1x por conta a cada TTL (na revalidação), em vez de 1x por pod por hora. Some a carga contínua e a maior parte da rajada.
Isso pode encher o VBase?
Não, se a chave for fixa. O ponto crítico é a cardinalidade da chave:
- Gravamos 1 blob pequeno (alguns KB) numa chave fixa (
store-bindings) por bucket. - O VBase já é escopado por conta + workspace, então isso vira 1 entrada por conta/workspace — bounded pelo nº de contas, não pelo tráfego.
- É sobrescrito (mesma chave) a cada revalidação → não cresce/acumula.
- Workspaces de dev/link têm seu próprio escopo de VBase (isolado e minúsculo), sem impacto no de produção.
Onde daria pra "encher" (a evitar):
- NÃO chavear por algo de alta cardinalidade (por URL, por token, por usuário, por request) — aí sim multiplicaria entradas. Mantemos chave fixa por conta.
- Atenção: o
staleFromVBaseWhileRevalidatederiva o arquivo denormalizedJSONFile(filePath)(md5 dofilePath). Basta passar umfilePathconstante (ex.:'store-bindings') → 1 arquivo só.
Ou seja: o footprint é 1 entrada de poucos KB por conta/workspace, sobrescrita — desprezível. O rewriter já usa VBase pesado pra rotas; isso é ordens de grandeza menor.
Nunca cachear resultado vazio (loja recém-configurada)
Uma loja recém-criada — ou um blip transitório do LM/Catalog — pode devolver uma lista de bindings vazia. Se cacheássemos isso, a loja ficaria "presa" em "sem bindings" por um TTL inteiro (até 1h na LRU, 2h no VBase), mesmo depois de já estar configurada corretamente.
Guard implementado nas duas camadas de escrita:
- Um resultado só é cacheável quando
bindings.length > 0(isCacheable). - VBase: passamos
shouldPersist: isCacheablepara ostaleFromVBaseWhileRevalidate. Emcold/stale, o valor recém-buscado é devolvido à request atual, mas só é gravado no VBase se tiver bindings. Efeito colateral bom: se uma revalidaçãostaleretornar vazio (blip), o último bom valor no VBase não é sobrescrito — seguimos servindo o stale. - LRU: o
bindingsLRU.setsó roda quandoisCacheable(data). Vazio nunca é fixado em processo → a próxima request re-busca na origem.
Assim, assim que o LM passar a devolver os bindings de verdade, a próxima
request já pega o valor correto, sem esperar TTL. O shouldPersist é um
parâmetro opcional e aditivo no helper compartilhado — os demais callers
(newURLs, categoryTreeSegmentFinder) não mudam.
Métricas de observabilidade
Objetivo: enxergar as duas camadas (LRU em processo e VBase) e confirmar que o LM
deixou de ser tocado no hot path e no rollout. Hoje as caches em VBase da camada
compatibility (que já usam este mesmo helper) não emitem nenhuma métrica, então
não há como medir hit/miss/stale nem o volume de idas ao VBase — esta seção fecha
essa lacuna.
Seguimos a convenção de node/observability/: recordCounter, nomes fixos
rewriter_*_total, diferenciação por atributos de baixa cardinalidade (nunca
account/workspace/url/token como atributo).
Contadores novos
rewriter_bindings_cache_total{layer="lru", result="hit"|"miss"}hit: resolvido na LRU em processo (0 rede).miss: LRU fria/expirada → caiu pro VBase. Cadamiss= 1 ida ao VBase.
rewriter_bindings_cache_total{layer="vbase", result="fresh"|"stale"|"cold"}fresh: VBase dentro do TTL → serviu sem tocar o LM.stale: serviu o stale + disparou revalidação em background (1 chamada ao LM).cold: VBase vazio (1ª população) → chamada síncrona ao LM.
Separar "bateu no cache" de "saiu de fato pro LM/Catalog"
Problema atual: rewriter_dependency_requests_total super-conta. O
instrumentDependency envolve a chamada do client inteira e sempre emite
outcome=success/status=200, mas quando a LRU do http-client (o memoryCache do
node-vtex-api) serve um hit não sai HTTP nenhum — mesmo assim conta como request
ao LM/Catalog. Ou seja, hoje a métrica mistura hit de cache com egress real.
Restrição técnica (verificada no @vtex/api): não dá pra "ler uma flag" no
retorno. O HttpClient.request() devolve só context.response; o status de cache
(cacheHit/memoizedHit/inflightHit) vive no MiddlewareContext e não vem no
getRaw. Então a separação tem que ser feita instrumentando nos pontos certos, não
inspecionando a resposta.
Duas formas (não exclusivas):
- Recomendada (encaixa no redesenho): parar de contar egress envolvendo o client.
Como, com a LRU nova + VBase, o LM/Catalog só é chamado dentro do
fetchBindingsFromLM(na revalidação/cold), instrumentamos o egress ali e (opcional) desligamos omemoryCacheinterno do client nesse caminho — a LRU externa + VBase já fazem o cache. Aí "revalidate chamou o client" ≡ "egress real", erewriter_dependency_requests_total{dependency="license-manager"}passa a contar só o que sai de fato. OsingleFlight/memoizable(dedup intra-request) pode ficar ligado — ele só colapsa chamadas concorrentes idênticas, o que é desejável. - Geral (se quiser manter o
memoryCacheinterno): injetar um middleware custom no client (viaopts.middlewares) que, depois donext(), lêctx.cacheHit/ctx.memoizedHit/ctx.inflightHite emite um atributocache_status(hit= servido em processo, sem egress;revalidated= 304, ida condicional;miss= egress completo). Caveat:middlewaresnão está tipado noInstanceOptionspúblico (existe no runtime) → precisa de cast.
Taxonomia final (o que responde "cache vs LM"):
-
Bateu no cache (sem tocar o LM):
bindings_cache{layer="lru",result="hit"}+bindings_cache{layer="vbase",result="fresh"}. -
Saiu de fato pro LM/Catalog:
bindings_cache{layer="vbase",result="stale"|"cold"}, que deve bater comrewriter_dependency_requests_total{dependency="license-manager"}(agora limpo). Os dois se cruzam como sanity-check. -
Instrumentar a leitura do VBase como dependência (
instrumentDependency({ dependency: "vbase", operation: "bindings-get" })) dá latência + contagem da ida ao VBase de graça (mede o "peso" por request) — e isso não é egress ao LM, é ida ao VBase (categoria própria).
Sinais derivados (dashboard)
| Sinal | Fórmula | Leitura |
|---|---|---|
| LRU hit rate | lru.hit / (lru.hit + lru.miss) | quão bem a LRU segura o hot path |
| Idas ao VBase | lru.miss (= leituras no VBase) | volume no VBase |
| VBase fresh rate | vbase.fresh / (fresh + stale + cold) | quão bem o VBase evita o LM |
| LM por bindings | vbase.stale + vbase.cold (≈ dependency LM) | deve ser baixo e ~plano |
| Rollout saudável | pico em lru.miss sem pico correlato no LM | design funcionando |
O que confirmar em produção
- Steady-state:
lru.hitdomina;vbase.*e LM perto de zero. - Rollout: pico em
lru.miss+vbase.fresh(pods frios lendo o VBase), com LM ~plano. Se o LM subir junto, évbase.cold/stalesem jitter — sinal pra ligar o jitter/single-flight na revalidação.
Cuidados / trade-offs
- Thundering herd no instante do rollout: se muitos pods acham o VBase frio ao mesmo tempo (antes da 1ª escrita), ainda há uma janela onde vários chamam o LM. O
stale-while-revalidateajuda (serve stale e revalida em background), mas na 1ª população (cache vazio) não tem stale pra servir. Refinamentos opcionais: jitter antes da 1ª chamada, ou lock leve. - Freshness: bindings mudam raramente; TTL de 30 min (ajustável) é seguro. Comparável à staleness que já temos com a LRU de 1h.
- Consistência de dado: guardamos o shape final (
{ bindings, fallbackLocale }), então nenhum pod re-mapeia; e é o mesmo contrato que o resto do app já consome. - Erro no LM durante revalidação: hoje o
revalidatepropaga o erro na 1ª população (cache vazio). Numa versão final vale decidir se, em erro, servimos stale (se houver) para não quebrar a request.
Alternativa (fora do rewriter)
Se o time de LM tirar o VtexIdclientAutCookie/Authorization do Vary no binding/site (o dado é por-conta, não por-usuário) ou expuser um caminho público/cacheável como a Tenant tinha, o ApiCache volta a compartilhar entre pods e o problema morre sem mudança no rewriter. As duas abordagens não são exclusivas.