Skip to content

Caché, archivos y colas ​

Los tres recursos siguen el mismo patrón (ADR 0008): un driver crudo que no sabe nada de tenants (CacheStore, StorageDriver, QueueDriver) y un envoltorio que aísla por tenant. Tú usas siempre el envoltorio: tenancy.cache(), tenancy.storage() y tenancy.jobs.

RecursoAislamientoPor defectoEn producción
CachéPrefijo tenant:{id}: (central: central:)MemoryCacheStoreredis() o memcached()
ArchivosPrefijo {id}/ (central: central/)LocalStorage en ./storages3()
ColastenantId dentro de cada trabajoMemoryQueuebullmq()

Caché ​

tenancy.cache() devuelve una TenantCache para el contexto actual. Dentro de un tenant, todas las llaves llevan su prefijo; fuera de uno, el prefijo central:.

ts
await tenancy.run('bolivar', async () => {
  const cache = tenancy.cache(); // llaves con prefijo tenant:bolivar:

  await cache.set('config', { moneda: 'BOB' }, 300); // TTL en segundos
  const config = await cache.get<{ moneda: string }>('config'); // o undefined
  await cache.delete('config');

  // Devuelve lo guardado o ejecuta la función y guarda el resultado 60 s
  const productos = await cache.remember('productos', 60, cargarProductos);

  await cache.flush(); // borra toda la caché de bolivar
});

// Fuera de un tenant, la caché usa el prefijo central:
await tenancy.cache().set('planes', ['free', 'pro']);
MétodoQué hace
get<T>(key)Valor guardado o undefined
set(key, value, ttlSeconds?)Guarda. Sin TTL (o 0), no vence. set(key, undefined) borra la llave
delete(key)Borra una llave
remember(key, ttlSeconds, fn)Lee o calcula y guarda
flush()Borra todas las llaves del tenant. En el contexto central lanza TenantNotIdentifiedError
prefix, tenantIdPrefijo en uso y tenant (null = central)

Cuando se borra un tenant (tenant.deleted), su caché se vacía sola.

Límites de remember

remember no tiene bloqueo: si dos peticiones fallan la lectura a la vez, las dos ejecutan la función. Y como un valor undefined se trata como "no guardado", una función que devuelve undefined se ejecuta siempre. Devuelve null si quieres guardar "no hay nada".

Stores ​

ts
import { createTenancy, MemoryCacheStore } from '@tenancy-node/core';
import { redis, redisInvalidation } from '@tenancy-node/cache-redis';
import { memcached } from '@tenancy-node/cache-memcached';

// Por defecto: memoria del proceso (LRU de 10 000 llaves)
createTenancy({ cache: new MemoryCacheStore({ maxEntries: 50_000 }) });

// Redis o Valkey
createTenancy({
  cache: redis({ url: process.env.REDIS_URL, keyPrefix: 'miapp:' }),
  invalidation: redisInvalidation({ url: process.env.REDIS_URL }), // caché de búsqueda entre réplicas
});

// Memcached
createTenancy({
  cache: memcached({ servers: process.env.MEMCACHED_SERVERS, versionCacheMs: 1000 }),
});

MemoryCacheStore guarda los valores por referencia (si modificas el objeto que te devuelve get, cambias el guardado) y solo vive en el proceso: cada réplica tiene la suya. Úsalo en desarrollo y tests.

redis() guarda los valores como JSON: las fechas vuelven como texto y las clases como objetos planos. flush() recorre las llaves con SCAN y las borra con UNLINK, sin bloquear el servidor.

Opción de redis()Por defectoQué hace
urlredis://127.0.0.1:6379Se ignora si pasas client
client—Tu cliente ioredis. No se cierra en tenancy.close()
redisOptions—Opciones de ioredis
keyPrefixtenancy:Prefijo global. Usa uno por app si comparten Redis
scanCount500Llaves por iteración de SCAN al vaciar un tenant

memcached() también guarda JSON. Memcached no puede borrar por prefijo, así que cada tenant tiene un número de versión dentro de sus llaves (tenancy:bolivar:v3:productos). flush() incrementa la versión: lo anterior queda inalcanzable y vence solo por su TTL o por la memoria de Memcached (ADR 0013).

Opción de memcached()Por defectoQué hace
servers127.0.0.1:11211host:puerto separados por coma
username, password—Autenticación SASL
keyPrefixtenancy:Prefijo global
versionCacheMs1000Cuánto recuerda el proceso la versión de un tenant

Dos consecuencias de las versiones:

  • Tras un flush() en otra réplica, esta sigue leyendo la versión anterior hasta versionCacheMs.
  • Las llaves de más de 200 bytes, o con espacios o caracteres de control, se guardan como hash SHA-1 (tenancy:h:<hash>). Funcionan igual; solo no las reconoces a simple vista.

Caché de búsqueda de tenants ​

Aparte de tenancy.cache(), el núcleo guarda en memoria los tenants y dominios que busca en cada petición (LRU con TTL, también las búsquedas que no encuentran nada). Se configura con lookupCache:

ts
createTenancy({ lookupCache: { max: 10_000, ttlMs: 60_000 } }); // valores por defecto
createTenancy({ lookupCache: false }); // cada petición consulta el repositorio

Los cambios hechos con tenancy.tenants y tenancy.domains la invalidan en la instancia que los hizo. Con varias réplicas, las demás esperan hasta el TTL salvo que configures invalidation: redisInvalidation(...). Cómo montarlo en Producción.

Archivos ​

tenancy.storage() devuelve un TenantStorage: cada ruta que pasas es relativa a la carpeta del tenant.

ts
await tenancy.run('bolivar', async () => {
  const storage = tenancy.storage(); // prefijo bolivar/

  await storage.put('logos/logo.png', png, { contentType: 'image/png' });
  await storage.put('notas/bienvenida.txt', 'Hola');

  await storage.get('logos/logo.png'); // Uint8Array | undefined
  await storage.getText('notas/bienvenida.txt'); // 'Hola'
  await storage.exists('logos/logo.png'); // true
  await storage.list('logos'); // [{ path: 'logos/logo.png', size, lastModified }]
  await storage.url('logos/logo.png'); // '/tenancy/assets/logos/logo.png' con el driver local
  storage.key('logos/logo.png'); // 'bolivar/logos/logo.png' (llave real en el driver)

  await storage.delete('notas/bienvenida.txt');
  await storage.deleteAll(); // todo lo de bolivar
});

put acepta Uint8Array o string y lee todo en memoria: no hay streams. Para archivos grandes, sube directo a S3 con tu propio cliente usando storage.key(ruta) como llave.

Cuando se borra un tenant (tenant.deleted), también se borran sus archivos (todo lo que está bajo {id}/), igual que su caché. Pasa en segundo plano: tenancy.tenants.delete() no espera al driver y, si el borrado falla, el tenant queda borrado igual y el error queda en el log como storage.delete_tenant, con su tenantId. Un tenant con id central compartiría la carpeta central/ del contexto central: en ese caso no se borra nada.

Rutas seguras ​

Toda ruta pasa por normalizeStoragePath, que rechaza con InvalidStoragePathError (TENANCY_INVALID_STORAGE_PATH):

  • .. en cualquier parte (a/../../b)
  • rutas absolutas (/etc/passwd)
  • \ y bytes nulos
  • rutas vacías, . o de más de 1024 caracteres

Las barras repetidas y los . intermedios se limpian (a//./b → a/b). Además, LocalStorage verifica que la ruta final quede dentro de su raíz aunque la llave llegue manipulada por otro camino.

Local y memoria ​

ts
import { createTenancy, LocalStorage, MemoryStorage } from '@tenancy-node/core';

// Disco local (por defecto en ./storage). Sirve los archivos con la ruta opcional /tenancy/assets/*
createTenancy({
  storage: new LocalStorage({ root: '/var/lib/miapp/storage' }),
  http: { assets: true },
});

// Memoria (tests)
createTenancy({ storage: new MemoryStorage() });

Con LocalStorage y MemoryStorage, url() devuelve /tenancy/assets/<ruta> (se puede cambiar con la opción url de LocalStorage). Esa URL solo responde si activas http: { assets: true }: el adaptador sirve el archivo del tenant del host con su content-type, ETag y x-content-type-options: nosniff. Los archivos locales viven en el disco de una sola máquina: con varias réplicas, usa S3.

S3 y compatibles ​

@tenancy-node/storage-s3 funciona con AWS S3 y servicios compatibles (MinIO, R2, DigitalOcean Spaces, SeaweedFS).

ts
import { createTenancy } from '@tenancy-node/core';
import { s3 } from '@tenancy-node/storage-s3';

// URLs públicas: bucket público o CDN
createTenancy({
  storage: s3({ bucket: 'mi-app', region: 'us-east-1', publicUrl: 'https://cdn.tuapp.com' }),
});

// URLs firmadas (sin publicUrl) contra un servicio compatible
createTenancy({
  storage: s3({
    bucket: 'mi-app',
    endpoint: 'http://localhost:8333',
    forcePathStyle: true,
    credentials: {
      accessKeyId: process.env.S3_KEY!,
      secretAccessKey: process.env.S3_SECRET!,
    },
    keyPrefix: 'uploads/',
    signedUrlExpiresInSeconds: 900,
  }),
});
OpciónPor defectoQué hace
bucketobligatorio
regionus-east-1
endpoint—URL del servicio compatible
forcePathStyle—true para la mayoría de los servicios que no son AWS
credentials—Si falta, el SDK usa su cadena normal (variables de entorno, rol IAM)
keyPrefix''Prefijo dentro del bucket: uploads/bolivar/logo.png
publicUrl—Base de las URLs públicas. Si falta, url() firma
signedUrlExpiresInSeconds3600Vigencia de las URLs firmadas

Configurar desde .env ​

storageFromEnv() elige el driver y sus opciones con variables de entorno. El mismo código sirve para desarrollo (disco local) y producción (S3, Cloudflare R2, MinIO, Spaces...): solo cambia el .env.

ts
import { createTenancy } from '@tenancy-node/core';
import { storageFromEnv } from '@tenancy-node/storage-s3';

createTenancy({ storage: storageFromEnv(), http: { assets: true } });
bash
# Desarrollo: disco local (también es lo que pasa si no hay ninguna variable)
STORAGE_DRIVER=local
STORAGE_LOCAL_ROOT=./storage

# Cloudflare R2
STORAGE_DRIVER=r2
R2_ACCOUNT_ID=0123456789abcdef0123456789abcdef
S3_BUCKET=mi-app
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_URL=https://archivos.tuapp.com   # dominio público del bucket; sin esto, URLs firmadas

# AWS S3
STORAGE_DRIVER=s3
S3_BUCKET=mi-app
S3_REGION=sa-east-1
# sin S3_ACCESS_KEY_ID: el SDK usa AWS_ACCESS_KEY_ID o el rol IAM

# MinIO, SeaweedFS u otro compatible
STORAGE_DRIVER=s3
S3_BUCKET=mi-app
S3_ENDPOINT=http://localhost:9000
S3_FORCE_PATH_STYLE=true
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
VariablePor defectoQué hace
STORAGE_DRIVERlocallocal, s3 o r2
STORAGE_LOCAL_ROOT./storageCarpeta del driver local
S3_BUCKETobligatorio con s3 y r2Bucket
S3_REGIONus-east-1 (auto en r2)Región
S3_ENDPOINT—Servicio compatible. Con r2 se arma solo desde R2_ACCOUNT_ID
R2_ACCOUNT_ID—Id de la cuenta de Cloudflare (32 caracteres)
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY—Las dos o ninguna. Sin ellas, la cadena normal del SDK
S3_FORCE_PATH_STYLE—true para MinIO y la mayoría de los compatibles
S3_PUBLIC_URL—Base de las URLs públicas (CDN o dominio del bucket)
S3_PREFIX''Prefijo de las llaves dentro del bucket
S3_SIGNED_URL_EXPIRES3600Vigencia de las URLs firmadas, en segundos

Si falta una variable obligatoria o un valor no es válido, storageFromEnv() lanza InvalidConfigError al arrancar, con el nombre de la variable, y no en la primera subida. Recibe un objeto opcional en lugar de process.env (útil en tests). tenancy.health() verifica el bucket con HeadBucket.

Las imágenes y archivos van al driver; en la base solo se guarda su ruta (por ejemplo theme.logo). Cambiar de driver no mueve los archivos ya subidos: cópialos al nuevo destino con la misma llave (<tenant>/<ruta>), por ejemplo con rclone. | client, clientConfig | — | Tu S3Client (no se cierra al terminar) o su configuración |

Con publicUrl, url() arma publicUrl + llave sin consultar S3 y sin comprobar que el archivo exista. Sin publicUrl, firma un GetObject; puedes cambiar la vigencia por llamada:

ts
await tenancy.run('bolivar', async () => {
  // URL firmada de 5 minutos (con S3 sin publicUrl)
  const url = await tenancy.storage().url('facturas/2026-09.pdf', { expiresInSeconds: 300 });
});

/tenancy/assets/* no sirve archivos de S3 (responde 404): las URLs apuntan directo al bucket o al CDN. Si una ruta ya es una URL absoluta (https://...), url() la devuelve igual; eso permite guardar en el tema un logo alojado afuera.

Colas ​

Definir y encolar trabajos ​

ts
import { createTenancy } from '@tenancy-node/core';
import { bullmq } from '@tenancy-node/queue-bullmq';

const tenancy = createTenancy({
  queue: bullmq({ url: process.env.REDIS_URL, queueName: 'miapp' }),
});

// En la app y en el worker: el mismo registro
tenancy.jobs.define<{ pedidoId: number }>(
  'factura',
  async (data, { tenant, job }) => {
    // corre en el contexto del tenant: tenancy.cache(), tenancy.storage()... ya son de él
    console.log(tenant?.id.value, data.pedidoId, `intento ${job.attempt}/${job.maxAttempts}`);
  },
  { attempts: 5, backoff: { type: 'exponential', delayMs: 2000 } }, // opciones por defecto
);

// En la petición: se encola con el tenant del contexto
await tenancy.run('bolivar', async () => {
  const id = await tenancy.jobs.dispatch('factura', { pedidoId: 123 });
  await tenancy.jobs.dispatch('factura', { pedidoId: 124 }, { delayMs: 60_000 });
});

// En el proceso worker
const worker = await tenancy.worker({ concurrency: 10 });
await worker.close();
  • dispatch toma el tenant del contexto. Fuera de un tenant, el trabajo es central (tenant: null).
  • dispatch de un nombre que no está definido lanza InvalidConfigError, igual que definirlo dos veces.
  • Los datos se serializan como JSON: pasa ids, no instancias ni fechas.
  • En el worker, el tenant se vuelve a leer del repositorio. Si ya no existe, el intento falla.
Opción de trabajo (JobOptions)Por defectoQué hace
attempts1Intentos en total. Por defecto no hay reintentos
backoffsin espera{ type: 'fixed' | 'exponential', delayMs }. Exponencial: delayMs · 2^(intento−1)
delayMs0Espera antes del primer intento

Cada intento queda en el log con operation: queue.job, el tenantId, job, jobId y attempt. Un intento fallido con reintentos pendientes es warn; el último es error y queda en tenancy.observability.errors() bajo el tenant.

Drivers ​

MemoryQueue (por defecto) vive en el proceso: se pierde al reiniciar y solo la procesa un tenancy.worker() del mismo proceso. Sirve para desarrollo y tests (queue.drain() espera a que termine todo). El close() del worker deja de tomar trabajos y espera a los que están en curso.

bullmq() usa Redis o Valkey: trabajos persistentes, reintentos y varios workers en distintos procesos. Todos los tenants comparten una cola; cada trabajo lleva su tenantId. Un tenant con muchos trabajos puede retrasar a los demás: no hay colas separadas por tenant.

Opción de bullmq()Por defectoQué hace
urlredis://127.0.0.1:6379
redisOptions—Opciones de ioredis (maxRetriesPerRequest ya es null)
queueNametenancyNombre de la cola
prefixbullPrefijo de las llaves de BullMQ
keepCompleted1000Trabajos terminados que se conservan
keepFailed5000Trabajos fallidos que se conservan (dead-letter)

BullmqQueue también tiene counts(), con la cantidad de trabajos por estado (waiting, active, delayed, failed, completed).

npx tenancy worker ​

El CLI arranca un worker con la configuración de tenancy.config y lo detiene con SIGINT/SIGTERM, esperando los trabajos en curso (CLI). Los trabajos se registran en un archivo aparte:

ts
import type { Tenancy } from '@tenancy-node/core';

// src/jobs.ts
export default (tenancy: Tenancy) => {
  tenancy.jobs.define('factura', async (data) => {
    // ...
  });
};
bash
npx tenancy worker --entry=src/jobs.ts --concurrency=5

La app que encola también necesita esas definiciones: importa el mismo archivo en los dos lados. Si el worker recibe un trabajo que no conoce, lo registra como error y el trabajo falla.

Listeners en modo queue ​

Un listener de eventos con mode: 'queue' se convierte en un trabajo interno event:<nombre>: la operación solo espera a que quede encolado y el worker lo ejecuta con reintentos.

ts
tenancy.events.on('tenant.created', enviarBienvenida, {
  mode: 'queue',
  name: 'bienvenida', // trabajo interno event:bienvenida
  retries: 5, // 6 intentos en total
  backoff: 'exponential', // desde 1 s
});
OpciónPor defectoQué hace
name<patrón>#<n>Identifica al listener entre la app y el worker
retries3Reintentos después del primer intento
backoff'exponential' desde 1 s'fixed' (1 s), 'exponential' o un JobBackoff propio

Pon siempre name

Sin name, el nombre sale del orden de registro (tenant.created#1, tenant.created#2). Si la app y el worker registran los listeners en otro orden, cada uno ejecuta el handler equivocado.

El listener tiene que registrarse en la app (para encolar) y en el worker (para ejecutar). Recibe el mismo sobre que un listener normal, con time convertido de vuelta a Date. Más sobre eventos en Eventos.

Referencia: @tenancy-node/core, cache-redis, cache-memcached, storage-s3, queue-bullmq.

Publicado bajo la licencia MIT.