<- Volver a las notas

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 VACUUM te 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.