Caching
@basaltkit/cache é um cache scoped por tenant com tags, TTL e proteção contra stampede, sobre um driver plugável — em memória para dev, Redis para produção, ou um driver multinível que coloca um near cache à frente do Redis.
Setup
cachePlugin regista um Cache sob o token CACHE. Começa no driver em memória (sem servidor necessário):
import { createApp } from '@basaltkit/core'
import { cachePlugin, CACHE } from '@basaltkit/cache'
const app = await createApp({
plugins: [cachePlugin({ driver: 'memory' })], // 'memory' é a predefinição
}).boot()
const cache = app.container.get(CACHE)
await cache.put('greeting', 'hello world', '10m')
console.log(await cache.get('greeting')) // 'hello world'Cada chave é prefixada com o tenant de ctx().tenant, por isso os tenants nunca veem as entradas uns dos outros. cache.flush() limpa apenas as chaves deste tenant — nunca faz um FLUSHALL.
Fail-closed fora do contexto de tenant (apps multi-tenant)
Quando o tenancyPlugin está registado, uma operação de cache sem scope de tenant resolúvel — um job em background ou uma tarefa de boot fora do contexto do pedido — lança MissingCacheScopeError em vez de ler e escrever silenciosamente num namespace partilhado por todos os tenants. Apps single-tenant (sem plugin de tenancy) não são afetadas. Para correr um job para um tenant específico, envolve-o em runWithContext({ tenant }); se a partilha entre tenants for mesmo intencional, volta a optar com cachePlugin({ onMissingScope: 'global' }).
get / put
await cache.put('config', { theme: 'dark' }) // sem TTL: fica até ser apagado
await cache.put('session', 'abc123', '30s') // durações em string: 500ms 30s 5m 2h 7d
await cache.put('token', 'xyz', 60_000) // …ou milissegundos
await cache.get('config') // { theme: 'dark' }
await cache.get('missing') // undefined
await cache.get('missing', 'default-value') // 'default-value' (fallback em miss)remember
O cavalo de batalha — retorna o valor em cache ou computa, guarda em cache e retorna, com proteção contra stampede por processo (misses concorrentes partilham uma computação):
const plans = await cache.remember('plans', '1h', () => db.plans.findMany())Dica
A deduplicação de stampede é por processo — usa um mapa em memória de promessas em curso. Dois servidores podem ainda executar a factory ao mesmo tempo, mas dentro de um processo ela corre exatamente uma vez, mesmo sob 100 misses concorrentes.
Stale-while-revalidate (SWR)
Passa { ttl, staleFor } em vez de um TTL simples e o remember troca uma quantidade limitada de desatualização por nunca bloquear uma leitura quente na factory:
// Fresh for 1 minute; for the next 10 minutes a stale copy is served
// instantly while ONE background refresh runs.
const stats = await cache.remember(
'dashboard-stats',
{ ttl: '1m', staleFor: '10m' },
() => computeExpensiveStats(),
)Uma leitura cai numa de três janelas:
| Janela | Idade da entrada | Comportamento |
|---|---|---|
| Fresca | < ttl | Servida do cache, a factory nunca corre |
| Stale | ttl … ttl + staleFor | Servida imediatamente, uma revalidação em background atualiza a entrada |
| Expirada | > ttl + staleFor | Miss a sério — a leitura bloqueia na factory como num remember simples |
A revalidação em background reutiliza a mesma deduplicação por processo da proteção contra stampede — no máximo um refresh por chave em curso, e um refresh falhado continua a servir o valor stale (nunca aparece como unhandled rejection). As entradas SWR são guardadas num pequeno envelope com as janelas de frescura; o TTL ao nível do driver é ttl + staleFor, por isso uma entrada expirada desaparece mesmo. Um valor cru escrito por put() ou por um remember simples é servido como fresco — mudar uma chave para SWR não precisa de migração.
forget / flush
await cache.forget('plans') // apaga uma chave; retorna true se existia
await cache.flush() // apaga todas as chaves no scope deste tenantTags
Agrupa chaves e invalida-as em conjunto:
await cache.tags('plans').put('pro', plan, '1h')
await cache.tags('plans').remember('enterprise', '1h', () => fetchPlan('enterprise'))
await cache.tags('plans').flush() // remove todas as chaves com a tag 'plans'Drivers
driver é 'memory', 'redis' (com url), ou uma instância CacheDriver personalizada.
O driver de memória é limitado
O MemoryCacheDriver é o driver por omissão, por isso não pode crescer sem limite: as chaves de cache costumam embeber ids, slugs ou fingerprints de queries, e crescimento ilimitado sobre chaves influenciadas pelo utilizador é um OOM à espera de acontecer. Guarda no máximo 10 000 entradas, despejando primeiro as já expiradas e depois as menos recentemente usadas (um get conta como uso, por isso as chaves quentes sobrevivem).
import { MemoryCacheDriver, cachePlugin } from '@basaltkit/cache'
cachePlugin({ driver: new MemoryCacheDriver({ maxEntries: 50_000 }) })
cachePlugin({ driver: new MemoryCacheDriver({ maxEntries: Infinity }) }) // sem limite| Opção | Tipo | Omissão | Propósito |
|---|---|---|---|
maxEntries | number | 10_000 | Limite de entradas vivas antes do despejo; Infinity desliga-o |
Trocar memory → Redis
Produção é uma mudança de uma linha — aponta para um servidor Redis:
pnpm add @basaltkit/cache-redis ioredisimport { redisCache } from '@basaltkit/cache-redis'cachePlugin({ driver: redisCache(process.env.REDIS_URL!) }) // ex.: redis://localhost:6379Aviso
O Redis serializa valores com JSON.stringify/JSON.parse — guarda dados simples. Instâncias de classe, Map e Date não sobrevivem à ida e volta (um Date volta como string).
Para reutilizar uma conexão ioredis existente em vez de um URL, passa uma instância RedisCacheDriver:
import { Redis } from 'ioredis'
import { cachePlugin } from '@basaltkit/cache'
import { RedisCacheDriver } from '@basaltkit/cache-redis'
const redis = new Redis(process.env.REDIS_URL!)
cachePlugin({ driver: new RedisCacheDriver(redis) })
// ou a partir de um URL: redisCache(process.env.REDIS_URL!)Multinível (tiered)
@basaltkit/cache-tiered coloca um near cache in-process à frente de um far cache partilhado (Redis) — as chaves quentes são servidas a partir da memória, cortando as idas e voltas à rede, enquanto o Redis continua a ser a fonte da verdade entre instâncias:
import { cachePlugin, MemoryCacheDriver } from '@basaltkit/cache'
import { redisCache } from '@basaltkit/cache-redis'
import { TieredCacheDriver } from '@basaltkit/cache-tiered'
cachePlugin({
driver: new TieredCacheDriver({
layers: [new MemoryCacheDriver(), redisCache(process.env.REDIS_URL!)],
backfillTtlMs: 30_000, // quanto tempo o near cache mantém um valor lido do Redis (predefinição 60000)
}),
})Uma leitura verifica cada camada da mais rápida→mais lenta, faz curto-circuito no primeiro hit e reabastece as camadas mais rápidas; escritas e invalidações espalham-se por todas as camadas. Não tem lacunas de capacidade — o que quer que as camadas suportem (tags, prefix flush), o driver tiered suporta por delegação.
A desatualização entre instâncias é limitada por backfillTtlMs
Não há um bus de invalidação entre instâncias — em vez disso, cada escrita numa camada near é limitada a backfillTtlMs (tanto os backfills de leitura como os set() diretos; a última camada, partilhada, mantém o TTL completo). Depois de outra réplica atualizar ou apagar uma chave, nenhuma instância serve a sua cópia local por mais tempo do que este limite. Mantém-no curto para dados que mudam frequentemente; backfillTtlMs: null remove o limite por completo e só é seguro com uma única réplica.
Escrever um driver
Implementa o contrato CacheDriver e passa a instância como driver:
import type { CacheDriver } from '@basaltkit/cache'
class MyCacheDriver implements CacheDriver {
async get(key: string): Promise<unknown> { /* undefined em miss/expiração */ return undefined }
async set(key: string, value: unknown, ttlMs?: number, tags?: string[]): Promise<void> { /* … */ }
async delete(key: string): Promise<boolean> { /* … */ return false }
async flushPrefix(prefix: string): Promise<void> { /* apaga chaves sob o prefixo */ }
async flushTags(tags: string[]): Promise<void> { /* apaga chaves com qualquer tag */ }
async disconnect(): Promise<void> { /* liberta conexões */ }
}TieredCacheDriver é uma pequena implementação de referência — é composição pura sobre outros drivers.
Referência de opções
cachePlugin(options) — tudo é opcional:
| Opção | Tipo | Omissão | Propósito |
|---|---|---|---|
driver | 'memory' | 'redis' | CacheDriver | 'memory' | Backend de armazenamento; passa uma instância para drivers tiered/personalizados |
prefix | string | 'basalt' | Prefixo raiz de todas as chaves |
scope | (() => string | undefined) | null | lê ctx().tenant.id → tenant:<id> (o id codificado como URL, para que um : nele nunca alcance as chaves de outro tenant) | Segmento dinâmico do prefixo, resolvido em cada operação — o isolamento por tenant. null = cache global deliberado (sem scoping, sem fail-closed) |
onMissingScope | 'global' | 'error' | vê abaixo | O que uma leitura/escrita faz quando a função de scope não resolve nada: 'global' partilha um namespace, 'error' lança MissingCacheScopeError. O flush() falha sempre fechado, independentemente |
now | () => number | Date.now | Relógio injetável para as janelas de frescura SWR (testes) |
Como a omissão de onMissingScope é escolhida. Quando o tenancyPlugin está registado, publica um marcador de metadata 'tenancy:active'; o cachePlugin lê-o e passa a omissão para 'error' — uma app multi-tenant falha fechada em vez de partilhar silenciosamente um namespace entre tenants. Sem o marcador (app single-tenant) a omissão continua 'global'. Um onMissingScope explícito ou uma função scope personalizada ganham sempre a esta deteção.
Modos de falha e troubleshooting
| Erro | Código | Quando |
|---|---|---|
MissingCacheScopeError | CACHE_SCOPE_MISSING | Um cache scoped por tenant não resolveu tenant nenhum: qualquer leitura/escrita com onMissingScope: 'error' (a omissão sob tenancyPlugin), e todos os flush() com scope por resolver |
CACHE_SCOPE_MISSINGnum job em background, seed script ou tarefa de boot — o código corre fora de um pedido, por isso nenhum tenant foi resolvido. Corre-o dentro de um contexto de tenant (tenancy.run(tenantId, …)/runWithContext({ tenant })), ou — só se os dados forem genuinamente partilhados — opta por sair comonMissingScope: 'global'ou um cache dedicado comscope: null.- O
flush()lança mesmo comonMissingScope: 'global'— intencional: sem scope resolvido, um flush limparia o namespace inteiro, ou seja, as chaves de todos os tenants de uma vez, por isso falha sempre fechado. Sóscope: null(um cache deliberadamente global) faz flush sem tenant. - Valores voltam subtilmente errados no Redis — ida e volta JSON: um
Datevolta como string,Map/instâncias de classe não sobrevivem. Guarda dados simples (vê o aviso em Drivers). - A factory corre mais vezes do que o esperado numa frota — a deduplicação de stampede e a revalidação SWR são por processo; duas réplicas podem computar em simultâneo. Dentro de um processo, cada chave computa exatamente uma vez.
- Entradas desaparecem antes do TTL no driver de memória — passaste o
maxEntries(10 000 por omissão) e o LRU está a despejar. Sobe o limite, ou passa para Redis. - Uma factory que devolve
undefinedcorria em cada chamada — já não: oundefinedé guardado atrás de um marcador interno, por isso é um hit real de cache. Ocache.get(key, fallback)continua a devolver o teu fallback para ele, porque através dogetumundefinedem cache e um miss são indistinguíveis. - Os flushes por tag no Redis deixaram de encontrar chaves após um upgrade — os índices de tags passaram de sets simples (
__tags__:) para sorted sets pontuados pela expiração (__tagz__:), para que os membros expirados sejam podados em vez de se acumularem para sempre. As chaves antigas são órfãs inertes; limpa-as uma vez comDEL __tags__:*.
Pedidos condicionais (ETags)
Distinto do cache acima — uma otimização ao nível HTTP que evita reenviar uma resposta inalterada. Marca uma rota com meta: { etag: true }: a framework faz o hash da resposta GET/HEAD num ETag forte e, quando o cliente envia um If-None-Match coincidente, responde 304 Not Modified sem corpo. Adapter-agnostic (fastify/express/hono), sem mudanças no handler.
route({
method: 'GET',
url: '/projects/:id',
meta: { etag: true }, // ← ETag + tratamento de 304
params: z.object({ id: z.string() }),
async handler({ params }) { return db.projects.find(params.id) },
})O cliente faz cache por ETag e revalida de forma barata — poupas serialização e largura de banda em leituras inalteradas. O computeEtag(body) e o ifNoneMatchSatisfied(header, etag) são exportados para fluxos personalizados.