Una cola de jobs en Postgres en vez de un broker
apsis es un pequeño backend open source que construí para predecir pases de satélite. Como casi todos los sistemas de su tipo, tiene dos clases de trabajo en segundo plano: algo recurrente (refrescar los datos orbitales con una cadencia) y algo reactivo (cuando esos datos cambian, recalcular las predicciones). La respuesta refleja es un broker de mensajes más un framework de workers. Yo usé PostgreSQL para las dos. Este es el diseño y, lo que importa más, cuándo no lo haría.
Dos clases de trabajo en segundo plano
- Recurrente: ingerir TLEs (elementos orbitales) de CelesTrak cada un par de horas.
- Reactivo: cuando la órbita de un satélite cambia, recalcular sus pases sobre cada estación terrestre registrada.
Por qué no un broker
- apsis ya corre Postgres. Un broker es un servicio más que desplegar, asegurar, monitorizar y razonar.
- El trabajo reactivo debe dispararse si y solo si la escritura en la base de datos se confirmó. Con un broker eso es el problema de la doble escritura: puedes confirmar la fila y fallar al publicar el mensaje, o publicar y fallar al confirmar. Dentro de una sola base de datos, el evento y la escritura comparten una única transacción.
Jobs programados: una tabla, un lease, sin scheduler aparte
Hay una fila en scheduled_jobs por cada job recurrente. Un worker sondea las
filas vencidas y reclama una con un update condicional:
UPDATE scheduled_jobs
SET status = 'RUNNING', lease_until = now() + :lease
WHERE id = :id AND status = 'PENDING' AND next_run_at <= now()
RETURNING job_name, interval_seconds;
Cero filas devueltas significa que otro worker ganó la carrera. No hacen falta
locks porque hay un puñado de jobs. Una columna lease_until más una pasada de
recuperación (un RUNNING con el lease caducado vuelve a PENDING) recupera un
job cuyo worker murió a mitad. Cuando el handler termina, la fila se reprograma a
now() + intervalo.
El outbox transaccional
Cuando la ingesta de TLEs cambia un satélite, inserta una fila en outbox_events
en la misma transacción que el update y emite un pg_notify. El evento existe
si y solo si el cambio de negocio se confirmó. Un worker aparte drena el outbox:
despertado por LISTEN/NOTIFY, reclama un evento con SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1, lo despacha y hace commit - un evento por transacción, de modo que
un segundo worker (una réplica, un despliegue rodante) nunca puede llevarse la
misma fila. Los fallos incrementan un contador de reintentos con backoff
exponencial y jitter; pasado un límite, el evento va a dead-letter. El estado por
handler guardado en la fila permite que un reintento se salte los handlers que ya
tuvieron éxito.
At-least-once, por tanto idempotente
Las dos piezas son at-least-once: un worker puede hacer el trabajo y caerse antes
de marcarlo como hecho. Por eso los handlers son idempotentes. recompute_passes
borra y reinserta las predicciones de cada par (satélite, estación); la ingesta
de TLEs hace upsert por número de catálogo. Reejecutar converge al mismo estado.
Cuándo NO lo haría
Esta es la parte importante del post.
- Throughput muy alto (decenas de miles de claims por segundo): el churn de
filas y la presión de
VACUUMte van a doler. Usa un broker de verdad. - Fan-out a muchos consumidores independientes, streaming o routing complejo: eso es justo en lo que los brokers son buenos.
- Consumidores en otros lenguajes u otros servicios: una tabla es una mala frontera de integración entre equipos.
- No corres ya Postgres: no lo metas solo para esto.
Para un servicio que ya es dueño de una base de datos Postgres y necesita trabajo en segundo plano fiable y transaccional a escala humana, una tabla suele bastar - y puedes leerla, consultarla y respaldarla con las herramientas que ya tienes.
La implementación completa es open source en github.com/aJustDev/apsis; los trade-offs de arriba están documentados como ADRs en el repositorio.