Documentation
Feedback
Guides
VTEX IO Apps

VTEX IO Apps
Proposta: cache compartilhado de bindings em VBase
vtex.rewriter
Version: 1.70.1
Latest version: 1.70.1

Nota de investigação (rascunho). Objetivo: reduzir os 429 MaxRequestsByInstance do 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 manda VtexIdclientAutCookie: 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:


_18
const 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)
_37
import { staleFromVBaseWhileRevalidate } from '../../compatibility/vbase'
_37
_37
const BINDINGS_CACHE_BUCKET = 'bindings-cache'
_37
const BINDINGS_CACHE_KEY = 'store-bindings' // chave FIXA (account/workspace são implícitos no VBase)
_37
const BINDINGS_CACHE_TTL_MIN = 30
_37
_37
// corpo atual, intacto — as chamadas reais ao LM + Catalog + toBinding
_37
const 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
_37
const 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)


_10
request
_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 staleFromVBaseWhileRevalidate deriva o arquivo de normalizedJSONFile(filePath) (md5 do filePath). Basta passar um filePath constante (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: isCacheable para o staleFromVBaseWhileRevalidate. Em cold/stale, o valor recém-buscado é devolvido à request atual, mas só é gravado no VBase se tiver bindings. Efeito colateral bom: se uma revalidação stale retornar vazio (blip), o último bom valor no VBase não é sobrescrito — seguimos servindo o stale.
  • LRU: o bindingsLRU.set só roda quando isCacheable(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. Cada miss = 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 o memoryCache interno do client nesse caminho — a LRU externa + VBase já fazem o cache. Aí "revalidate chamou o client" ≡ "egress real", e rewriter_dependency_requests_total{dependency="license-manager"} passa a contar o que sai de fato. O singleFlight/memoizable (dedup intra-request) pode ficar ligado — ele só colapsa chamadas concorrentes idênticas, o que é desejável.
  • Geral (se quiser manter o memoryCache interno): injetar um middleware custom no client (via opts.middlewares) que, depois do next(), lê ctx.cacheHit/ctx.memoizedHit/ctx.inflightHit e emite um atributo cache_status (hit = servido em processo, sem egress; revalidated = 304, ida condicional; miss = egress completo). Caveat: middlewares não está tipado no InstanceOptions pú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 com rewriter_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)

SinalFórmulaLeitura
LRU hit ratelru.hit / (lru.hit + lru.miss)quão bem a LRU segura o hot path
Idas ao VBaselru.miss (= leituras no VBase)volume no VBase
VBase fresh ratevbase.fresh / (fresh + stale + cold)quão bem o VBase evita o LM
LM por bindingsvbase.stale + vbase.cold (≈ dependency LM)deve ser baixo e ~plano
Rollout saudávelpico em lru.miss sem pico correlato no LMdesign funcionando

O que confirmar em produção

  • Steady-state: lru.hit domina; 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/stale sem 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-revalidate ajuda (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 revalidate propaga 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.

See also
Vtex.rewriter
VTEX IO Apps
VTEX App Store
VTEX IO Apps
Was this helpful?