Pular para o conteúdo

Cron Jobs

O Rebase inclui um agendador de cron jobs integrado para executar tarefas em segundo plano recorrentes — limpeza de dados, geração de relatórios, verificações de integridade (health checks), sincronizações com APIs externas e muito mais.

Os cron jobs seguem o mesmo padrão de descoberta baseada em arquivos das custom functions: adicione um arquivo TypeScript no diretório crons/, e o Rebase o registrará e agendará automaticamente.

  • Zero dependências — Nenhuma biblioteca externa de agendamento necessária
  • Admin API — Endpoints REST para listar, disparar, habilitar/desabilitar e visualizar logs
  • Painel do Studio — Monitore todos os jobs, veja o histórico de execuções e dispare execuções manualmente
  • Persistência no banco de dados — Logs de execução armazenados no PostgreSQL, sobrevivendo a reinicializações
  • Cache em memória — Ring buffer rápido (últimas 50 execuções) para o painel, suportado pelo banco de dados

Crie um arquivo no seu diretório backend/crons/ que faça a exportação padrão (export default) de uma definição de cron. Utilize o helper defineCron de @rebasepro/server para inferência de tipos e autocompletar:

// 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),
};
},
});

O rebase dev observa o diretório de crons, portanto, um job adicionado enquanto ele está em execução é registrado na próxima recarga — sem necessidade de reinicialização. (Ele precisa ser informado: o diretório é escaneado em vez de importado, logo o observador não pode inferi-lo.)

O nome do arquivo (sem a extensão) torna-se o ID exclusivo do job — por exemplo, health-check.

Habilite os cron jobs adicionando cronsDir à configuração do seu backend:

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

Isso é tudo. O Rebase irá:

  1. Escanear o diretório em busca de arquivos .ts / .js
  2. Registrar cada exportação padrão como um cron job
  3. Criar automaticamente as tabelas rebase.cron_logs, rebase.cron_claims e rebase.cron_job_state no PostgreSQL (se o driver suportar SQL)
  4. Iniciar o agendador e preencher os contadores a partir dos logs existentes no banco de dados
  5. Disponibilizar as rotas REST de administração em /api/admin/cron

Desde 0.25 Na 0.24 os cron jobs vêm apenas de um diretório.

crons recebe os jobs como um record do ID do job para a definição, ao lado de collections. Um diretório é uma das formas de montá-lo: loadCrons(dir) lê um diretório exatamente nesse formato.

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")) });

Dê crons ou cronsDir, não ambos: a inicialização recusa o par. Construir o backend registra os jobs; o que inicia os timers deles é start(), que o initializeRebaseBackend chama para você. Com createRebaseBackend você mesmo o chama, no processo que deve executá-los. loadCronJobsFromDirectory e loadCronJobsWithDiagnostics continuam funcionando, estão obsoletas e avisam uma única vez.

As expressões Cron utilizam o formato padrão de 5 campos:

┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *
Expressão Significado
* * * * * A cada minuto
0 * * * * A cada hora
0 3 * * * Diariamente às 3:00
0 0 * * 1 Toda segunda-feira à meia-noite
0 9 1 * * Primeiro dia de cada mês às 9:00
0,30 * * * * A cada 30 minutos (nos minutos :00 e :30)
0 9-17 * * 1-5 A cada hora, das 9:00 às 17:00, apenas em dias úteis

Valores de intervalo (*/n), faixas (a-b) e listas (a,b,c) são todos suportados.

Um agendamento é comparado com o horário local do seu fuso. Numa mudança de horário de verão, isso significa que um job de horário fixo dentro da hora repetida (30 2 * * * onde os relógios atrasam às 03:00) é executado nas duas passagens dessa hora, e um dentro da hora pulada, quando os relógios adiantam, não é executado naquele dia.

timezone é novo — na versão 0.17.3, o agendamento é sempre interpretado no fuso horário do próprio host. Tudo o mais nesta interface já está disponível.

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 recebe um CronJobContext contendo métodos utilitários e a instância do Rebase Client:

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;
}

Use ctx.log() para gerar saídas estruturadas. Essas linhas são capturadas no log de execução e ficam visíveis no Studio e por meio da REST API. Cada linha também é gravada no log do servidor no nível info, com um campo cron contendo o ID do job, então ela aparece em qualquer lugar que colete a saída do seu contêiner, inclusive nos logs de depuração do Rebase Cloud. Essa cópia é mascarada como as próprias linhas de log do servidor: os valores de chaves como password ou apiKey, e o SQL e os parâmetros de uma consulta que falhou, são substituídos. O log de execução armazena a linha sem esse mascaramento.

Desde 0.25 O log de execução guarda as primeiras 500 linhas de uma execução ou 64 KB delas, o que vier primeiro, e depois uma linha dizendo quantas houve além disso: … 9500 more lines not kept — see the server log. O log do servidor recebe todas as linhas. Um job que registra uma linha por item processado deve esperar que seu histórico mostre o início da execução.

ctx.signal — interrompa o trabalho quando a execução parar

Seção intitulada “ctx.signal — interrompa o trabalho quando a execução parar”

O timeout encerra a execução: o agendador para de esperar e registra uma falha. Ele não encerra o handler. Passe ctx.signal para qualquer chamada que o aceite, e o trabalho será interrompido junto com ele:

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}`);
}
});

Sem isso, um job cujo timeout coincida com seu intervalo deixa vazar uma requisição abandonada por tick — o que é invisível, já que cada execução já foi registrada como falha.

Interagindo com o banco de dados e serviços via ctx.rebase

Seção intitulada “Interagindo com o banco de dados e serviços via ctx.rebase”

ctx.rebase.dataAsAdmin é o plano de dados com escopo administrativo. Um cron não possui um usuário por requisição, portanto não há alternativa com escopo de usuário aqui — você mesmo deve definir os 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, disparar, pausar e retomar jobs por HTTP, e o client.cron do SDK tipado: API de Cron Jobs.

Quando os cron jobs estão configurados, uma ferramenta Cron Jobs aparece no Rebase Studio sob Compute, ao lado do console JS. O painel disponibiliza:

  • Lista de jobs — Todos os jobs registrados com indicadores de status em tempo real
  • Painel de detalhes — Agendamento, próxima/última execução, duração e detalhes de erro
  • Histórico de execuções — Entradas de log expansíveis com saídas e resultados capturados
  • Disparo manual — Execute qualquer job sob demanda com um único clique. O painel aguarda o fim da execução e diz como ela foi: uma execução que falhou mostra o seu erro
  • Habilitar/desabilitar — Pause e retome jobs sem reiniciar o servidor, em todos os processos de uma vez; uma pausa se mantém após reinicializações e deploys
  • Jobs que não estão lá — cada job que o agendador recusou, com o seu agendamento e o motivo, e quantos arquivos de cron falharam ao carregar. Nenhum deles está agendado; veja Jobs Que Não Estão Lá

O painel é atualizado automaticamente a cada 15 segundos.

O painel mostra a mesma coisa, seja qual for o processo que o serve. Um job que outro processo está executando aparece como em execução. Em todo processo que tem um armazenamento persistente, o número de execuções, o número de falhas e a última execução são lidos do banco de dados, onde as execuções de todos os processos são contadas. Os contadores do próprio processo guardam apenas as execuções que ele fez: o papel api ao lado de um worker não executa nada, e, de duas réplicas que agendam, nenhuma vê os slots que a outra reivindicou. Só quando o banco de dados não pode ser lido o card recorre aos contadores do próprio processo, com um aviso no log do servidor.

Desde 0.25 As contagens são de toda a vida; o histórico são os últimos 30 dias. A linha de cada execução em rebase.cron_logs é excluída 30 dias depois de iniciada, por uma varredura a cada hora, enquanto as contagens de execuções e de falhas continuam a contá-la. Uma execução ignorada conta como execução, como sempre contou: é uma linha do histórico.

Na inicialização do backend, o Rebase analisa todos os agendamentos de cron registrados usando um expansor de cron em JS com zero dependências:

  • Verificação de Sintaxe: Garante que a string contenha exatamente 5 campos separados por espaços (minuto, hora, dia do mês, mês, dia da semana).
  • Expansão de Intervalos: Decompõe passos (*/15), faixas (9-17) e listas separadas por vírgulas (0,30) em arrays explícitos de inteiros válidos mapeados para seus respectivos limites (ex.: minutos 0-59, horas 0-23, meses 1-12).
  • Se alguma expressão de cron falhar na validação, o Rebase rejeita a definição, grava um erro de inicialização no log e recusa registrar o job para evitar falhas durante o tempo de execução.

Nos Bastidores: Correção de Desvio de Relógio (Clock-Drift)

Seção intitulada “Nos Bastidores: Correção de Desvio de Relógio (Clock-Drift)”

Agendadores padrão baseados em intervalos (como setInterval) sofrem desvio ao longo do tempo e causam picos significativos de CPU devido a atrasos de agendamento no event loop do sistema operacional. Para garantir a precisão da execução, o Rebase implementa um loop dinâmico de cálculo do tempo-alvo:

  1. Cálculo do Candidato: Ao concluir um job ou iniciar o agendador, o Rebase calcula o timestamp exato do próximo minuto candidato correspondente.
  2. Espera Dinâmica: Ele calcula a diferença em milissegundos (nextRun.getTime() - now.getTime()) e agenda um único setTimeout.
  3. Limite de Segurança contra Desvio: Um buffer mínimo de espera (MIN_SCHEDULE_INTERVAL_MS) de 5.000ms é imposto. Se um tick do agendador for concluído com extrema rapidez, esse limite evita disparos duplos quase instantâneos.
  4. Encerramento Amigável: Os timers são explicitamente desacoplados do event loop do Node.js usando timer.unref(), garantindo que agendadores de cron em segundo plano não bloqueiem o encerramento limpo de processos durante deploys.

Todo processo cujo agendador está ligado arma os mesmos timers; o banco de dados decide qual deles executa cada slot. Como um slot roda uma única vez, como um slot perdido em uma reinicialização é recuperado, como uma pausa chega a todas as réplicas e por que um disparo manual nunca roda ao lado de uma execução agendada está em Cron entre instâncias.


  • Corrida de Timeout Forçada: Os blocos de execução são encapsulados em um Promise.race contra um timer de timeout baseado em timeoutSeconds (padrão: 300 segundos / 5 minutos; Infinity para nenhum timeout). Se o handler travar além desse limite, ctx.signal é abortado e a promise é rejeitada, disparando: Error: Cron job "<id>" timed out after <N>ms A ação de abortar é a parte que realmente interrompe o trabalho; a rejeição apenas faz o agendador parar de esperar. Um handler que ignora ctx.signal continua sendo executado além de sua própria execução.
  • Encerramento: backend.shutdown() aguarda uma execução em andamento, dentro do mesmo orçamento da fila de jobs (dois terços do timeout de encerramento). Uma execução que ainda esteja rodando quando ele se esgota tem ctx.signal abortado e é registrada como falha, com o motivo. Seu slot continua reivindicado, então nenhuma outra instância a executa de novo.
  • Try/Catch com Proteção contra Falhas: Cada handler de job roda dentro de um wrapper isolado. Quaisquer exceções não tratadas são interceptadas, formatando o rastreamento do erro (traceback) em uma string, alterando o status do job para "error" e atualizando o contador de falhas do job. Uma pane dentro de uma tarefa de cron específica nunca derrubará o loop do agendador ou o servidor web HTTP Hono principal.
  • Ring Buffer em Memória: O agendador mantém um ring buffer contendo as últimas 50 execuções de cada job. Esse buffer permanece em memória para viabilizar leituras quase instantâneas a partir do Rebase Studio.

Quando adaptadores de banco de dados compatíveis com SQL (por exemplo, PostgreSQL) estão ativos, o Rebase provisiona a tabela 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 uma é criada na inicialização se estiver faltando, então um banco de dados anterior a ela a recebe no próximo deploy. Nenhuma delas é uma coleção, e o papel de usuário final rebase_user não tem nenhum privilégio sobre elas: um cron_job_state gravável permitiria que um usuário autenticado pausasse um job para todos, ou segurasse seu lease para que nada o execute.

Desde 0.25 A linha de uma execução e sua contagem são gravadas em uma única instrução: o insert em cron_logs e um upsert dos contadores do job em cron_job_state. Uma listagem lê uma linha de cron_job_state por job, por mais longo que seja o histórico, e o agendador também, ao preencher totalRuns e totalFailures na inicialização. As gravações de log não seguram a execução; se uma falhar, o agendador registra o erro e segue em frente, com a execução ainda no ring buffer em memória.

A cada hora, e quando inicia, o agendador exclui as linhas do histórico com mais de 30 dias e conta as execuções ainda não contadas, ambos em lotes de 5.000 linhas. Um histórico gravado antes da 0.24 é contado pela primeira dessas varreduras, fora do caminho de inicialização. Até ela terminar, uma listagem soma aos contadores as execuções que ainda não contou, de modo que os números não mudam na atualização. O mesmo vale para uma execução que um servidor mais antigo registra durante um deploy gradual.

// 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 arquivo de cron é também uma declaração. O comando rebase resources o lista sob o nome do arquivo — o mesmo ID usado pelo agendador para executá-lo e exibido no Studio — junto com seu agendamento e fuso horário, permitindo que o host conheça os cronogramas de um projeto antes de executar qualquer rotina. Um cron não se vincula a nenhuma variável de ambiente; o rebase status o exibe em verde sem necessidade de configuração adicional.

Ler o agendamento requer importar o arquivo, e o rebase resources é uma etapa de build: sem .env, sem segredos. Portanto, mantenha o escopo do módulo de um cron livre de qualquer código que leia configurações no momento da importação — como um cliente de banco de dados instanciado no topo de um helper ou um env.ts que valida DATABASE_URL. Em vez disso, importe esse tipo de operação dentro do handler:

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

O handler é executado na instância de implantação, onde essas variáveis de fato existem. Um import no nível raiz do mesmo módulo tornaria o grafo derivável apenas em uma máquina que por acaso possuísse um .env — além de carregar todas as dependências a cada inicialização que simplesmente registre o job.

Desde 0.25 Carregar um cron não declara nada. Uma etapa de build sua que queira os crons no grafo diz isso com declareCrons:

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