0015. Tareas programadas (APScheduler)¶
Estado: Propuesto Fecha: 2026-09-04 Autor: Duval Alcivar Módulo(s) afectado(s): todos (core, ERP y CRM) — transversal
Contexto¶
Hay tareas que deben ejecutarse de forma periódica o a una hora exacta en
cada microservicio: crear/caer particiones de tablas históricas (audit_log,
outbox, stock), purgar tokens expirados, despachar el outbox de eventos,
refrescar cachés, programar reportes o sincronizar con integraciones externas.
No hay que depender de cron del sistema operativo, de CronJob de Kubernetes
ni de extensiones de PostgreSQL: este ADR fija APScheduler como el
"cron general" en Python, el equivalente al Scheduler de Laravel.
Decisión¶
Cada microservicio que necesite tareas programadas usa APScheduler con un
BackgroundScheduler que arranca dentro del proceso del servicio. Los
jobs son idempotentes (se pueden reejecutar sin daño) y viven versionados
en el repo del servicio, en app/jobs/.
Por qué APScheduler y no
pg_partman/pg_cron: APScheduler es puro Python, portable a cualquier PostgreSQL (incluido managed) y deja la lógica versionada en el repo.pg_partman/pg_cronexigen instalar extensiones en el servidor (no disponible en todos los RDS/Aurora "managed").
1. Instalar la dependencia¶
# pyproject.toml ([tool.poetry.dependencies])
apscheduler = "^3.10.0"
poetry add apscheduler # agrega la dependencia y fija poetry.lock
2. Job de ejemplo: particionado de audit_log¶
El caso canónico es el particionado mensual de audit_log (ADR 0014): crear
las particiones del mes actual y de los +2 siguientes, y descartar las que
superan la retención.
# app/jobs/partitions.py
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
RETENCION_MESES = 12 # cuántos meses conservar antes de descartar
PREMADE_MESES = 2 # particiones creadas por adelantado hacia el futuro
def _mes_absoluto(fecha: datetime) -> int:
return fecha.year * 12 + (fecha.month - 1)
def _desde_mes_absoluto(total_meses: int) -> datetime:
y, m = divmod(total_meses, 12)
return datetime(y, m + 1, 1)
def crear_particiones_audit_log(session: Session) -> list[str]:
"""Crea las particiones faltantes (mes actual + PREMADE_MESES) y retorna las creadas.
Idempotente: consulta pg_class antes de crear (CREATE TABLE IF NOT EXISTS
... PARTITION OF no es válido en todas las versiones de Postgres).
"""
hoy = datetime.now().replace(day=1) # usar tz-aware: ZoneInfo("America/Guayaquil")
creadas: list[str] = []
base = _mes_absoluto(hoy)
for offset in range(0, PREMADE_MESES + 1):
mes = _desde_mes_absoluto(base + offset)
mes_siguiente = _desde_mes_absoluto(base + offset + 1)
nombre = f"audit_log_{mes.strftime('%Y_%m')}"
existe = session.execute(
text("SELECT 1 FROM pg_class WHERE relname = :n"), {"n": nombre}
).scalar()
if not existe:
session.execute(
text(
"CREATE TABLE erp.{n} PARTITION OF erp.audit_log "
"FOR VALUES FROM (:desde) TO (:hasta)"
),
{"n": nombre, "desde": mes, "hasta": mes_siguiente},
)
session.commit()
creadas.append(nombre)
return creadas
# Retención (política por producto): DETACH + DROP de meses viejos
def caer_particiones_vencidas(session: Session, retencion_meses: int) -> None:
hoy = datetime.now().replace(day=1)
lim = _mes_absoluto(hoy) - retencion_meses
limite = _desde_mes_absoluto(lim)
viejo = f"audit_log_{limite.strftime('%Y_%m')}"
session.execute(text(f"ALTER TABLE erp.audit_log DETACH PARTITION erp.{viejo}"))
session.execute(text(f"DROP TABLE IF EXISTS erp.{viejo}"))
session.commit()
3. Programar el job al arrancar el servicio¶
# app/main.py
import atexit
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.jobs.partitions import crear_particiones_audit_log
scheduler = BackgroundScheduler(timezone="America/Guayaquil")
# Corre el día 1 de cada mes a las 03:00 (mes actual + 2 de margen)
scheduler.add_job(
crear_particiones_audit_log,
CronTrigger(day=1, hour=3, minute=0),
args=[get_db_session], # tu factory de sesión inyectable
id="crear-particiones-audit-log",
replace_existing=True,
)
scheduler.start()
atexit.register(lambda: scheduler.shutdown())
Cron de APScheduler:
CronTrigger(day=1, hour=3, minute=0)equivale a0 3 1 * *en crontab y a->monthlyOn(1)->at('03:00')en Laravel. ConPREMADE_MESES=2, siempre existen las particiones del mes actual y las 2 siguientes.
4. ¿Toca algo a nivel de servidor?¶
No. Con APScheduler no se instala nada en PostgreSQL ni en el sistema
operativo:
- No requiere extensiones (pg_partman, pg_cron).
- No requiere crontab del SO ni CronJob de Kubernetes: el job vive dentro
del proceso del microservicio y corre solo.
Lo único que debe existir es que el servicio esté corriendo (el scheduler
arranca con él). Con múltiples réplicas dos pods podrían ejecutar el job a la
vez; como el job es idempotente (consulta pg_class antes de crear) no
hay daño, pero si se quiere evitar trabajo duplicado se deja el scheduler solo
en la réplica líder o se usa pg_advisory_lock alrededor del job.
5. Otro ejemplo: sirve para mucho más que particionar¶
APScheduler es el "cron general" de cada servicio. Mismo patrón, otras tareas:
# app/jobs/catalogos.py — refresco del catálogo de empresas en caché
def refrescar_cache_catalogos(session: Session):
# p.ej. re-leer mae_empresas y refrescar caché
...
scheduler.add_job(refrescar_cache_catalogos, CronTrigger(hour="*", minute=0), id="refresh-cat-cada-hora")
# app/jobs/outbox.py — despacho de eventos pendientes (outbox, ADR 0003)
def despachar_outbox(session: Session):
... # leer registros outbox sin publicar y enviarlos
scheduler.add_job(despachar_outbox, CronTrigger(minute="*/1"), id="outbox-cada-minuto")
# app/jobs/notificaciones.py — recordatorio puntual (una sola vez)
from apscheduler.triggers.date import DateTrigger
def enviar_recordatorio():
...
scheduler.add_job(enviar_recordatorio, DateTrigger(run_date="2026-09-15 09:00:00"), id="recordatorio-unico")
Usos típicos en los 14 servicios: purga de tokens expirados, despacho de
outbox, generación de reportes programados, sincronización con integraciones
externas, y particionado de históricos (audit_log, outbox, stock).
Alternativas descartadas¶
pg_partman/pg_cron(extensión de Postgres) — descartado: exige instalar extensiones en el servidor (no disponible en todos los RDS/Aurora "managed") y saca la lógica del repo del servicio.- cron del sistema operativo /
CronJobde Kubernetes — descartado: requiere infraestructura adicional por servicio y no queda versionado junto al código ni a las migraciones. - Celery Beat — descartado por ahora: añade un broker (Redis/RabbitMQ) que solo compensa si ya hay un clúster de workers; APScheduler cubre el caso sin dependencia extra.
Consecuencias¶
- Ganancia: tareas programadas dentro de cada servicio, versionadas en el repo, sin dependencias de infraestructura ni de extensiones del motor.
- Ganancia: un solo patrón (APScheduler) para todo tipo de tarea periódica o puntual, consistente entre los 14 servicios.
- Costo: el scheduler es por-proceso; con réplicas hay que cuidar la idempotencia (o dejar el scheduler en la réplica líder).
- Costo: hay que monitorear el job (si el particionado falla meses seguidos, los INSERT sin partición fallan → alerta).
- Pendiente: política de retención por tabla/producto, y zona horaria
corporativa (
America/Guayaquil) como configuración, no hardcodeada en cada job.