Saltearse al contenido

Redis pub/sub

Un Dynamic Snapshot sirve todo el contenido desde un snapshot en memoria del servidor y lo refresca en caliente cuando publicas, sin rebuild ni una llamada al API por lectura. El patrón tiene dos piezas: de dónde se lee el snapshot (snapshotLoader) y quién dispara el refresco (refreshSnapshot()).

En esta versión el JSON vive en Redis. Un publisher exporta el snapshot con exportSnapshot(), hace SET + PUBLISH, y todas las instancias suscritas se refrescan a la vez con un único mensaje.

Cómo funciona

Diagrama: Dynamic Snapshot en Express con Redis pub/sub. Un publisher hace SET + PUBLISH y todas las instancias suscritas se refrescan a la vez con un único mensaje
  • Origen: Redis. El snapshotLoader lee la key del snapshot.
  • Disparo con pub/sub: en lugar de un POST por instancia, un único PUBLISH notifica a todas las réplicas suscritas y cada una llama a refreshSnapshot().

Cuándo elegirla

  • Pensada para escalado horizontal: refresca N instancias con un solo mensaje, sin webhooks por instancia ni N llamadas al API.
  • Requiere un Redis accesible por todas las réplicas.

Paso a paso

Parte del proyecto de la versión por endpoint. Cambiamos el snapshotLoader por Redis, añadimos la suscripción y un publisher.

  1. Levanta Redis e instala dependencias:

    Ventana de terminal
    docker run -d --name redis -p 6379:6379 redis:7
    npm i redis
    .env
    REDIS_URL=redis://localhost:6379
  2. Crea la conexión a Redis. Registramos un handler de error para que una caída de conexión no tumbe el proceso:

    src/lib/redis.ts
    import { createClient as createRedis } from 'redis';
    export const redis = createRedis({ url: process.env.REDIS_URL });
    // node-redis lanza en eventos 'error' no manejados; los logueamos para que
    // una conexión caída no tumbe el proceso.
    redis.on('error', err => console.error('Redis error', err));
    export const redisReady = redis.connect();
  3. Cambia el snapshotLoader para leer de Redis y suscríbete al canal. Al primer uso, cada instancia escucha y se refresca sola cuando el publisher avisa. Si el snapshot aún no existe en Redis, no cacheamos el fallo, para que el siguiente request lo reintente:

    src/lib/content-island.ts
    import { createClient } from '@content-island/api-client';
    import { redis, redisReady } from './redis';
    const accessToken = process.env.CONTENT_ISLAND_TOKEN!;
    const SNAPSHOT_KEY = 'content-island:snapshot';
    const CHANNEL = 'content-island:updated';
    // Express corre como un único proceso de larga vida: un singleton a nivel de
    // módulo basta para compartir un cliente (y un snapshot) entre peticiones.
    export const contentIslandClient = createClient({
    accessToken,
    mode: 'snapshot',
    // El loader lee el snapshot de Redis.
    snapshotLoader: async () => {
    await redisReady;
    return (await redis.get(SNAPSHOT_KEY)) ?? '';
    },
    });
    // Al primer uso, esta instancia se suscribe al canal y se refresca sola cada
    // vez que el publisher avisa. También hace la carga inicial del snapshot.
    let primed: Promise<unknown> | null = null;
    export function ensureSnapshot() {
    if (!primed) {
    primed = (async () => {
    await redisReady;
    // Una conexión de Redis no puede suscribirse y ejecutar comandos a la
    // vez, así que el suscriptor usa su propia conexión duplicada.
    const sub = redis.duplicate();
    sub.on('error', err => console.error('Redis subscriber error', err));
    await sub.connect();
    await sub.subscribe(CHANNEL, () => {
    contentIslandClient.refreshSnapshot().catch(console.error);
    });
    await contentIslandClient.refreshSnapshot(); // carga inicial
    })().catch(err => {
    // No cacheamos el fallo: si el snapshot aún no existe en Redis, el
    // siguiente request lo reintenta en vez de reusar una promesa rechazada.
    primed = null;
    throw err;
    });
    }
    return primed;
    }
  4. Crea el publisher: exporta, hace SET y PUBLISH. Fuera del servidor carga el .env con dotenv:

    scripts/publish-redis.mts
    import 'dotenv/config';
    import { exportSnapshot } from '@content-island/api-client';
    import { createClient as createRedis } from 'redis';
    const redis = createRedis({ url: process.env.REDIS_URL });
    await redis.connect();
    const snapshot = await exportSnapshot({ accessToken: process.env.CONTENT_ISLAND_TOKEN! });
    await redis.set('content-island:snapshot', JSON.stringify(snapshot));
    await redis.publish('content-island:updated', '1');
    await redis.quit();
    console.log('✅ snapshot publicado en Redis');

    Añade un script en el package.json para lanzarlo:

    package.json
    "scripts": {
    "publish:redis": "tsx scripts/publish-redis.mts"
    }
  5. Pruébalo con dos instancias (que es la gracia). Haz una primera carga en Redis y arranca dos APIs en puertos distintos:

    Ventana de terminal
    npm run publish:redis # primera carga en Redis
    PORT=3000 npm run dev # en una terminal
    PORT=3001 npm run dev # en otra

    Publica contenido nuevo y lanza npm run publish:redis una sola vez. Consulta GET / en las dos instancias: ambas actualizan su exportedAt a la vez, con un único PUBLISH.

Ejemplo

Código completo (incluye el docker-compose.yml): content-island/examples-dynamic-snapshotexpress/03-redis-pub-sub.

Referencias