Ir al contenido

Cron Jobs

Rebase incluye un programador de cron jobs integrado para ejecutar tareas recurrentes en segundo plano: limpieza de datos, generación de informes, comprobaciones de estado (health checks), sincronizaciones de APIs externas y más.

Los cron jobs siguen el mismo patrón de descubrimiento basado en archivos que las funciones personalizadas: coloca un archivo TypeScript en tu directorio crons/, y Rebase lo registrará y programará automáticamente.

  • Cero dependencias — No requiere librerías externas de programación
  • API de administración — Endpoints REST para listar, activar manualmente, habilitar/deshabilitar y ver registros (logs)
  • Panel de Studio — Monitoriza todas las tareas, consulta el historial de ejecución y activa ejecuciones manualmente
  • Persistencia en base de datos — Registros de ejecución almacenados en PostgreSQL, que sobreviven a los reinicios
  • Caché en memoria — Búfer circular rápido (últimas 50 ejecuciones) para el panel, respaldado por la base de datos

Crea un archivo en tu directorio backend/crons/ que exporte por defecto una definición de cron. Utiliza el helper defineCron de @rebasepro/server para inferencia de tipos y autocompletado:

// backend/crons/health-check.ts
import { defineCron } from "@rebasepro/server";
export default defineCron({
schedule: "*/5 * * * *", // every 5 minutes
name: "System Health Check",
description: "Monitors uptime and memory usage",
async handler(ctx) {
ctx.log("Running health check...");
const uptime = process.uptime();
const mem = process.memoryUsage();
ctx.log(`Uptime: ${Math.round(uptime)}s`);
ctx.log(`Heap: ${Math.round(mem.heapUsed / 1024 / 1024)}MB`);
return {
uptimeSeconds: Math.round(uptime),
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
};
},
});

rebase dev observa el directorio crons, por lo que una tarea agregada mientras se está ejecutando se registra en la siguiente recarga, sin necesidad de reiniciar. (Se le debe indicar: el directorio se escanea en lugar de importarse, por lo que el watcher no puede inferirlo).

El nombre del archivo (sin extensión) se convierte en el ID único de la tarea; por ejemplo, health-check.

Habilita los cron jobs añadiendo cronsDir a tu configuración del backend:

const instance = await initializeRebaseBackend({
// ... other config
functionsDir: path.resolve(__dirname, "../functions"),
cronsDir: path.resolve(__dirname, "../crons"), // ← add this
});

Eso es todo. Rebase hará lo siguiente:

  1. Escaneará el directorio en busca de archivos .ts / .js
  2. Registrará cada exportación por defecto como un cron job
  3. Creará automáticamente las tablas rebase.cron_logs, rebase.cron_claims y rebase.cron_job_state en PostgreSQL (si el controlador soporta SQL)
  4. Iniciará el programador y cargará los contadores a partir de los registros existentes en la base de datos
  5. Montará las rutas REST de administración en /api/admin/cron

Desde 0.25 En 0.24 los cron jobs provienen solo de un directorio.

crons recibe las tareas como un objeto que va del id de la tarea a su definición, junto a collections. Un directorio es una forma de construirlo: loadCrons(dir) lee uno y lo devuelve exactamente con esa forma.

import path from "path";
import { defineCron, initializeRebaseBackend, loadCrons } from "@rebasepro/server";
const nightlyReport = defineCron({
schedule: "0 3 * * *",
async handler({ log }) {
log("building the report");
}
});
await initializeRebaseBackend({ app, server, crons: { "nightly-report": nightlyReport } });
// the same as cronsDir:
await initializeRebaseBackend({ app, server, crons: await loadCrons(path.resolve(__dirname, "../crons")) });

Pasa crons o cronsDir, no ambos: el arranque rechaza el par. Construir el backend registra las tareas; lo que pone en marcha sus temporizadores es start(), que initializeRebaseBackend llama por ti. Con createRebaseBackend lo llamas tú, en el proceso que deba ejecutarlas. loadCronJobsFromDirectory y loadCronJobsWithDiagnostics siguen funcionando, están deprecados y avisan una sola vez.

Las expresiones cron utilizan el formato estándar de 5 campos:

┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *
Expresión Significado
* * * * * Cada minuto
0 * * * * Cada hora
0 3 * * * Diariamente a las 3:00 AM
0 0 * * 1 Todos los lunes a medianoche
0 9 1 * * El primer día de cada mes a las 9:00 AM
0,30 * * * * Cada 30 minutos (en :00 y :30)
0 9-17 * * 1-5 Cada hora, de 9 AM a 5 PM, solo días laborables

Se admiten valores de intervalo o pasos (*/n), rangos (a-b) y listas (a,b,c).

Una programación se compara con la hora local de su zona. En un cambio de horario de verano, eso significa que una tarea a hora fija dentro de la hora repetida (30 2 * * * donde los relojes se atrasan a las 03:00) se ejecuta en las dos pasadas de esa hora, y una dentro de la hora que se salta, cuando los relojes se adelantan, no se ejecuta ese día.

timezone es nuevo; en la versión 0.17.3 una programación siempre se lee en la zona horaria propia del host. Todo lo demás en esta interfaz ya ha sido lanzado.

interface CronJobDefinition {
// Cron schedule expression (5-field format)
schedule: string;
// IANA zone the schedule is read in, e.g. "Europe/Madrid". Without it the
// schedule is read in the host's own zone — UTC in nearly every container,
// yours on a laptop — so name it. An unknown zone is refused when the job
// loads rather than read as local time.
timezone?: string;
// Human-readable name shown in Studio
name: string;
// Optional description shown in Studio
description?: string;
// Whether the job runs (default: true). A pause or a resume from Studio
// or the admin API overrides it, for every process, until reset.
enabled?: boolean;
// Max execution time in seconds (default: 300). Infinity means no
// timeout; 0, a negative number or NaN is refused when the job loads.
timeoutSeconds?: number;
// How far back to look on startup for a slot that elapsed while no
// instance was ticking (default: off). See "Cron across instances".
catchUpWindowSeconds?: number;
// The function to run on each tick
handler: (ctx: CronJobContext) => Promise<unknown> | unknown;
}

Cada handler recibe un CronJobContext que contiene métodos de utilidad y la instancia del cliente de Rebase:

interface CronJobContext {
// The job's unique ID (derived from filename)
jobId: string;
// The scheduled tick timestamp
scheduledAt: Date;
// Logger — lines appear in Studio, the logs API and the server log
log: (...args: unknown[]) => void;
// Aborted when the run exceeds `timeoutSeconds`, or when a shutdown's
// wait for it runs out
signal: AbortSignal;
// The server-side Rebase singleton — the same object `import { rebase }
// from "@rebasepro/server"` returns, and the same one `defineFunction`
// hands its callback.
rebase: RebaseServerClient;
}

Usa ctx.log() para emitir salida estructurada. Estas líneas se capturan en el registro de ejecución y son visibles en Studio y a través de la API REST. Cada línea también se escribe en el registro del servidor con nivel info y un campo cron que contiene el ID del job, así que aparece en cualquier sistema que recoja la salida de tu contenedor, incluidos los registros de depuración de Rebase Cloud. En esa copia se ocultan los mismos datos que en las líneas de registro del propio servidor: se sustituyen los valores de claves como password o apiKey, y el SQL y los parámetros de una consulta fallida. El registro de ejecución guarda la línea sin ocultar nada.

Desde 0.25 El registro de ejecución conserva las primeras 500 líneas de una ejecución o 64 KB de ellas, lo que ocurra primero, y después una línea que dice cuántas más hubo: … 9500 more lines not kept — see the server log. El registro del servidor recibe todas las líneas. Un job que registra una línea por cada elemento que procesa debe esperar que su historial muestre el comienzo de la ejecución.

ctx.signal — detén el trabajo cuando la ejecución se detenga

Sección titulada «ctx.signal — detén el trabajo cuando la ejecución se detenga»

El tiempo de espera (timeout) finaliza la ejecución: el programador deja de esperar y registra un fallo. No finaliza el handler. Pasa ctx.signal a cualquier cosa que acepte una señal, y el trabajo se detendrá con ella:

export default defineCron({
name: "Sync inventory",
schedule: "*/15 * * * *",
timeoutSeconds: 60,
async handler({ signal, log }) {
const res = await fetch("https://supplier.example.com/stock", { signal });
log(`fetched ${res.status}`);
}
});

Sin ella, una tarea cuyo tiempo de espera coincide con su intervalo pierde (fuga) una petición abandonada por cada tick, de forma invisible, porque cada ejecución ya se ha registrado como fallida.

Interactuar con la base de datos y los servicios mediante ctx.rebase

Sección titulada «Interactuar con la base de datos y los servicios mediante ctx.rebase»

ctx.rebase.dataAsAdmin es el plano de datos con alcance de administrador (admin-scoped). Un cron no tiene un usuario por petición, por lo que aquí no hay una alternativa con alcance de usuario; define tú mismo los filtros de cada consulta.

// backend/crons/expire-users.ts
import { defineCron } from "@rebasepro/server";
export default defineCron({
schedule: "0 0 * * *", // Daily at midnight
name: "Expire Inactive Accounts",
async handler(ctx) {
ctx.log("Checking for expired trial users...");
// Fetch using the pre-initialized data driver. `collection<Row>(slug)`
// gives the query builder the row type — `where` keys are checked
// against it. Every filter is an `[operator, value]` tuple; a bare
// value is passed straight through and builds a malformed query.
const users = ctx.rebase.dataAsAdmin.collection<{
id: string;
email: string;
trial_status: string;
trial_ends_at: string;
status: string;
}>("users");
const { data: trials } = await users.find({
where: {
trial_status: ["==", "active"],
trial_ends_at: ["<", new Date().toISOString()]
}
});
ctx.log(`Found ${trials.length} users with expired trials.`);
for (const user of trials) {
await users.update(user.id, {
trial_status: "expired",
status: "disabled"
});
// Send email notification using the Rebase email service
await ctx.rebase.email.send({
to: user.email,
subject: "Your trial has expired",
html: "<p>Please upgrade your subscription to continue.</p>"
});
}
}
});

Listar, ejecutar, pausar y reanudar trabajos por HTTP, y el client.cron del SDK tipado: API de Cron Jobs.

Cuando los cron jobs están configurados, aparece una herramienta Cron Jobs en Rebase Studio bajo Compute, junto a la consola de JS. El panel proporciona:

  • Lista de tareas — Todas las tareas registradas con indicadores de estado en vivo
  • Panel de detalles — Programación, próxima/última ejecución, duración e información de errores
  • Historial de ejecución — Entradas de registro desplegables con salida capturada y resultados
  • Activación manual — Ejecuta cualquier tarea bajo demanda con un solo clic. El panel espera a que termine la ejecución y dice cómo fue: una ejecución que falló muestra su error
  • Habilitar/deshabilitar — Pausa y reanuda tareas sin reiniciar el servidor, en todos los procesos a la vez; una pausa se mantiene tras reinicios y despliegues
  • Tareas que no están ahí — cada tarea que el programador rechazó, con su programación y el motivo, y cuántos archivos cron fallaron al cargar. Ninguna de ellas está programada; consulta Tareas que no están ahí

El panel se actualiza automáticamente cada 15 segundos.

El panel muestra lo mismo sea cual sea el proceso que lo sirve. Una tarea que otro proceso está ejecutando aparece como en ejecución. En todo proceso que tiene un almacén, el número de ejecuciones, el número de fallos y la última ejecución se leen de la base de datos, donde se cuentan las ejecuciones de todos los procesos. Los contadores propios de un proceso solo recogen las ejecuciones que hizo él: el rol api junto a un worker no ejecuta nada, y de dos réplicas que programan ambas, ninguna ve los turnos que reclamó la otra. Solo cuando no se puede leer la base de datos recurre la tarjeta a los contadores propios de este proceso, con una advertencia en el registro del servidor.

Desde 0.25 Los recuentos son de toda la vida; el historial son los últimos 30 días. La fila de cada ejecución en rebase.cron_logs se elimina 30 días después de que empezó, mediante un barrido cada hora, mientras que los recuentos de ejecuciones y fallos siguen contándola. Una ejecución omitida cuenta como ejecución, como siempre: es una fila del historial.

Validación de programación y análisis AST

Sección titulada «Validación de programación y análisis AST»

Durante la inicialización del backend, Rebase analiza todas las programaciones de cron registradas utilizando un expansor de cron basado en JS sin dependencias:

  • Comprobación de sintaxis: Verifica que la cadena contenga exactamente 5 campos separados por espacios en blanco (minute, hour, day of month, month, day of week).
  • Expansión de rangos: Descompone pasos (*/15), rangos (9-17) y listas separadas por comas (0,30) en arrays explícitos de enteros válidos mapeados a sus respectivos límites (por ejemplo, minutos 0-59, horas 0-23, meses 1-12).
  • Si alguna expresión cron no supera la validación, Rebase rechaza la definición, registra un error de inicio y se niega a registrar la tarea para evitar fallos de ejecución en tiempo de ejecución.

Bajo el capó: Corrección de desvío del reloj (Clock-Drift)

Sección titulada «Bajo el capó: Corrección de desvío del reloj (Clock-Drift)»

Los programadores estándar basados en intervalos (como setInterval) se desvían con el tiempo y causan picos significativos de CPU debido a los retrasos en la programación del bucle de eventos a nivel del sistema operativo. Para garantizar la precisión en la ejecución, Rebase implementa un bucle dinámico de cálculo de tiempo objetivo:

  1. Cálculo de candidatos: Al completar una tarea o iniciar el programador, Rebase calcula la marca de tiempo exacta del siguiente minuto candidato coincidente.
  2. Espera dinámica (Dynamic Sleep): Calcula la diferencia en milisegundos (nextRun.getTime() - now.getTime()) y programa un único setTimeout.
  3. Umbral de seguridad contra desvíos: Se aplica un búfer mínimo de espera (MIN_SCHEDULE_INTERVAL_MS) de 5.000 ms. Si un tick del programador se completa con extrema rapidez, este umbral evita una doble activación casi instantánea.
  4. Facilidad de apagado (Shutdown Friendliness): Los identificadores de temporizadores se desacoplan explícitamente del bucle de eventos de Node.js mediante timer.unref(), asegurando que los programadores de cron en segundo plano no bloqueen las terminaciones limpias de procesos durante los despliegues.

Cada proceso cuyo programador está activo arma los mismos temporizadores; la base de datos decide cuál de ellos ejecuta cada intervalo. Cómo un intervalo se ejecuta una sola vez, cómo se recupera un intervalo que un reinicio perdió, cómo una pausa llega a todas las réplicas y por qué una activación manual nunca se ejecuta junto a una programada se explica en Cron entre instancias.


  • Carrera de tiempo de espera forzado (Forced Timeout Race): Los bloques de ejecución están envueltos en un Promise.race contra un temporizador de tiempo de espera derivado de timeoutSeconds (por defecto: 300 segundos / 5 minutos; Infinity para ningún límite). Si el handler se queda colgado superando este umbral, ctx.signal se aborta y la promesa se rechaza, lanzando: Error: Cron job "<id>" timed out after <N>ms El aborto es la parte que detiene el trabajo; el rechazo solo detiene la espera del programador. Un handler que ignore ctx.signal seguirá ejecutándose más allá de su propia ejecución.
  • Apagado: backend.shutdown() espera a una ejecución en curso, dentro del mismo presupuesto que la cola de trabajos (dos tercios del tiempo de espera de apagado). Si la ejecución sigue en marcha cuando se agota, ctx.signal se aborta y se registra como fallida, con el motivo. Su intervalo sigue reclamado, así que ninguna otra instancia lo vuelve a ejecutar.
  • Try/Catch a prueba de fallos: El handler de cada tarea se ejecuta dentro de un contenedor aislado. Cualquier excepción no capturada es interceptada, formateando el seguimiento de errores (traceback) en una cadena, estableciendo el estado de la tarea en "error" y actualizando el contador de fallos de la tarea. Un fallo dentro de una sola tarea cron nunca hará caer el bucle del programador ni el servidor web HTTP principal de Hono.
  • Búfer circular en memoria: El programador mantiene un búfer circular que contiene las últimas 50 ejecuciones por tarea. Este búfer se mantiene en memoria para permitir lecturas casi instantáneas desde Rebase Studio.

Cuando los adaptadores de base de datos compatibles con SQL (por ejemplo, PostgreSQL) están activos, Rebase aprovisiona la tabla rebase.cron_logs:

CREATE SCHEMA IF NOT EXISTS rebase;
CREATE TABLE IF NOT EXISTS rebase.cron_logs (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
job_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
duration_ms INTEGER NOT NULL,
success BOOLEAN NOT NULL DEFAULT true,
error TEXT, -- Stack trace or error message
result JSONB, -- Return value of handler
logs JSONB, -- Ring buffer array of ctx.log outputs
manual BOOLEAN NOT NULL DEFAULT false -- True if triggered from Studio/REST
);
ALTER TABLE rebase.cron_logs ADD COLUMN IF NOT EXISTS counted BOOLEAN; -- the run is in the counters below
CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON rebase.cron_logs(job_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_cron_logs_started ON rebase.cron_logs(started_at);
CREATE INDEX IF NOT EXISTS idx_cron_logs_uncounted ON rebase.cron_logs(job_id, started_at DESC) WHERE counted IS NULL;
CREATE TABLE IF NOT EXISTS rebase.cron_job_state (
job_id TEXT PRIMARY KEY,
enabled BOOLEAN, -- NULL: follow the job's own `enabled`
updated_at TIMESTAMPTZ, -- when `enabled` was last set, and by whom
updated_by TEXT,
running_until TIMESTAMPTZ, -- the run lease: live while in the future
running_by TEXT,
total_runs BIGINT NOT NULL DEFAULT 0, -- all-time, skips included
total_failures BIGINT NOT NULL DEFAULT 0,
last_run_at TIMESTAMPTZ, -- the most recently started run
last_duration_ms INTEGER,
last_success BOOLEAN,
last_error TEXT
);

Cada una se crea al arrancar si falta, así que una base de datos anterior a ella la obtiene en su siguiente despliegue. Ninguna es una colección, y el rol de usuario final rebase_user no tiene ningún privilegio sobre ellas: un cron_job_state escribible permitiría a un usuario autenticado pausar una tarea para todos, o retener su lease para que nada la ejecute.

Desde 0.25 La fila de una ejecución y su recuento se escriben en una sola sentencia: la inserción en cron_logs y un upsert de los contadores de la tarea en cron_job_state. Un listado lee una fila de cron_job_state por tarea, por largo que sea el historial, y lo mismo hace el programador cuando inicializa totalRuns y totalFailures al arrancar. Las escrituras del registro no retienen la ejecución; si una falla, el programador registra el error y continúa, con la ejecución todavía en el búfer circular en memoria.

Una vez por hora, y al arrancar, el programador elimina las filas del historial de más de 30 días y cuenta las ejecuciones que aún no están contadas, ambas cosas en lotes de 5.000 filas. Un historial escrito antes de la 0.24 lo cuenta el primero de estos barridos, fuera de la ruta de arranque. Hasta que termina, un listado suma a los contadores las ejecuciones que todavía no ha contado, de modo que las cifras no cambian al actualizar. Lo mismo vale para una ejecución que un servidor más antiguo registra durante un despliegue progresivo.

// backend/crons/cleanup-sessions.ts
import type { CronJobDefinition } from "@rebasepro/types";
import { rebase } from "@rebasepro/server";
const job: CronJobDefinition = {
schedule: "0 3 * * *", // daily at 3 AM
name: "Cleanup Expired Sessions",
description: "Removes user sessions older than 30 days",
async handler(ctx) {
ctx.log("Starting session cleanup...");
// Admin-scoped data access — see `ctx.rebase` above.
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const expired = await ctx.rebase.dataAsAdmin.sessions.findAll({
where: { last_seen_at: ["<", cutoff] }
});
for (const session of expired) {
await ctx.rebase.dataAsAdmin.sessions.delete(session.id as string);
}
ctx.log(`Cleaned up ${expired.length} expired sessions`);
return { deletedSessions: expired.length };
},
};
export default job;

Cada archivo de cron es también una declaración. rebase resources lo lista bajo el nombre del archivo —el mismo id con el que el programador lo ejecuta y el que muestra Studio— con su programación y zona horaria, de modo que el host lee las programaciones de un proyecto antes de ejecutar nada. Un cron no se vincula a ninguna variable de entorno; rebase status lo muestra en verde sin nada que configurar.

Leer la programación implica importar el archivo, y rebase resources es un paso de compilación: sin .env, sin secretos. Por lo tanto, mantén el alcance del módulo (module scope) de un cron libre de cualquier cosa que lea la configuración al importarse: un cliente de base de datos creado en la parte superior de un helper, un env.ts que valide DATABASE_URL. En su lugar, importa ese trabajo dentro del handler:

async handler({ log }) {
const { runSeed } = await import("../src/seed.js");
await runSeed();
log("done");
}

El handler se ejecuta en el despliegue, donde esas variables existen. Una importación en el nivel superior (top-level) del mismo módulo hace que el grafo solo pueda derivarse en una máquina que tenga un .env, y carga toda la dependencia en cada arranque que simplemente registre la tarea.

Desde 0.25 Cargar un cron no declara nada. Un paso de compilación propio que quiera los crons en el grafo lo dice con declareCrons:

import { declareCrons, loadCrons } from "@rebasepro/server";
declareCrons(await loadCrons("./crons", { onSkip: (problem) => console.warn(problem) }));