Un cron no garantiza que una fila se procese una sola vez: únicamente ofrece una ocasión para ejecutar código. Para que una limpieza periódica en Node.js sea recuperable, el trabajo debe seleccionar un lote pequeño, reclamar sus filas dentro de una transacción y borrar solo lo que ya cumple la regla de retención. Aquí montaremos ese patrón con PostgreSQL y node-cron.
La base de datos decide qué filas reclamar; el temporizador solo despierta al trabajador. El ejemplo elimina registros vencidos de una tabla llamada webhook_deliveries. Cambia el nombre y el plazo según tu caso, pero conserva la reclamación condicional y el límite por ejecución.
Preparar la tabla de limpieza
El primer paso es dejar explícita la condición de caducidad. La tabla de ejemplo almacena una fecha de expiración y una marca de reclamación. Esta última permite que una ejecución no confunda filas elegibles con otras que ya están siendo tratadas.
CREATE TABLE webhook_deliveries (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
cleanup_claimed_at TIMESTAMPTZ
);
CREATE INDEX webhook_deliveries_cleanup_idx
ON webhook_deliveries (expires_at, id)
WHERE cleanup_claimed_at IS NULL;
El índice parcial acompaña a la búsqueda de filas vencidas que todavía no se han reclamado. No conviertas la marca de reclamación en un permiso eterno: si un proceso cae, una marca antigua debe poder expirar y volver a ser elegible. En este ejemplo, la reclamación caduca a los diez minutos; el lote máximo es de cien filas.
Instalar el ejecutor y el acceso a PostgreSQL
El quickstart de node-cron indica que el paquete requiere Node.js 20 o posterior. Instalamos el programador y el cliente de PostgreSQL; la conexión se configura mediante la variable de entorno DATABASE_URL.
npm init -y
npm install node-cron pg
Usaremos módulos ES. Guarda el siguiente archivo como cleanup.js y ejecuta node cleanup.js con DATABASE_URL configurada. El ejemplo programa la tarea cada minuto; la expresión cron no decide si dos procesos distintos pueden reclamar la misma fila.
import cron from 'node-cron';
import pg from 'pg';
const { Pool } = pg;
if (!process.env.DATABASE_URL) {
throw new Error('Falta DATABASE_URL');
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
console.log('Conexión PostgreSQL preparada');
Reclamar un lote con una transacción
La reclamación y el borrado deben decidirse con la base de datos, no con una lista cargada en memoria antes de empezar. PostgreSQL permite seleccionar filas con FOR UPDATE SKIP LOCKED: las filas bloqueadas por otra ejecución se saltan, en vez de hacer esperar a los demás trabajadores. La consulta siguiente bloquea un lote, elimina esas filas y devuelve sus identificadores.
async function deleteExpiredBatch() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
WITH candidates AS (
SELECT id
FROM webhook_deliveries
WHERE expires_at <= NOW()
AND (
cleanup_claimed_at IS NULL
OR cleanup_claimed_at < NOW() - INTERVAL '10 minutes'
)
ORDER BY expires_at, id
LIMIT 100
FOR UPDATE SKIP LOCKED
)
DELETE FROM webhook_deliveries AS d
USING candidates AS c
WHERE d.id = c.id
RETURNING d.id
`);
await client.query('COMMIT');
return result.rowCount;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
El límite mantiene acotado cada intento; si quedan más filas, las siguientes ejecuciones continuarán el trabajo. En este ejemplo, la limpieza es puramente local a la base de datos y el borrado se confirma dentro de la transacción. Si necesitas coordinar una operación externa —por ejemplo, llamar a un destino HTTP—, no la mantengas dentro de esta transacción: la llamada de red y el commit no forman una operación atómica.
Conectar el lote al horario
Ahora el callback programado ejecuta la función y registra cuántas filas quitó. node-cron inicia las tareas creadas con schedule de inmediato. Su opción noOverlap evita que una segunda ejecución de esa tarea arranque mientras la anterior sigue activa en el mismo proceso.
async function runCleanup() {
const deleted = await deleteExpiredBatch();
console.log(`${new Date().toISOString()} limpieza: ${deleted} filas`);
}
const task = cron.schedule('* * * * *', async () => {
try {
await runCleanup();
} catch (error) {
console.error('Falló la limpieza:', error);
}
}, {
name: 'limpieza-webhooks',
timezone: 'UTC',
noOverlap: true,
});
console.log(`Tarea activa: ${task.name}`);
noOverlap solo protege este proceso. Si hay varias copias de la aplicación, cada una puede tener su propio temporizador. La selección con bloqueo y SKIP LOCKED evita que compitan por las mismas filas mientras dura la transacción, pero no convierte un cron en una coordinación distribuida ni resuelve por sí solo una tarea externa lenta.
flowchart TD A["node-cron despierta"] --> B["abrir transaccion"] B --> C["seleccionar lote vencido"] C --> D["bloquear filas disponibles"] D --> E["borrar hasta cien filas"] E --> F["confirmar o revertir"]
Detener el trabajo al apagar el proceso
En un cierre normal, deja de aceptar nuevas ejecuciones y cierra el pool de conexiones. El manejador siguiente detiene la tarea y espera a que el pool termine de cerrar antes de salir.
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Recibida señal ${signal}; deteniendo tarea`);
task.stop();
try {
await pool.end();
process.exit(0);
} catch (error) {
console.error('Error al cerrar PostgreSQL:', error);
process.exit(1);
}
}
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
Combina estos fragmentos en un único archivo, colocando la configuración del pool antes de las funciones y el registro de señales después de crear task. Así el manejador puede detener la tarea y cerrar el mismo pool que usa la limpieza.
Fallos que conviene probar
Un lote pequeño limita el trabajo pendiente, pero no sustituye a la observación. Comprueba qué ocurre si dos trabajadores empiezan a la vez, si el proceso cae durante una ejecución y si la limpieza no alcanza a vaciar la tabla en un ciclo. Registra al menos las filas procesadas y el resultado del intento; una aplicación viva no demuestra que el trabajo esté avanzando.
Si la limpieza requiere varias fases o el estado debe sobrevivir a una caída entre ellas, no marques filas y las borres de forma separada sin una política de recuperación. En tareas breves como el borrado mostrado, la transacción agrupa selección y eliminación. Para procesamientos largos o reintentos operativos, el patrón de una cola duradera puede encajar mejor, a cambio de mantener más estado y coordinación.
Por último, trata la caducidad y la zona horaria como decisiones explícitas. La tarea se evalúa en UTC, y la condición de expiración usa la hora de PostgreSQL. Si el requisito es conservar datos durante un plazo distinto, cambia la regla de expiración, no solo la frecuencia del cron. El siguiente ciclo volverá a tomar hasta cien filas disponibles.
Fuentes: DEV Community, node-cron, node-cron, PostgreSQL Documentation