Skip to content

Arquitectura de Background Jobs

Tipo: Arquitectural Alcance: Sistema transversal (todos los módulos) Estado: Planificado (Fase 1 en implementación) Fecha: 2026-02-05

Technical Implementation: Para la implementación técnica detallada del sistema, consulte Backend: Background Jobs System. Este documento se enfoca en decisiones arquitecturales y conceptos.


Tabla de Contenidos


Descripción General

El Sistema de Background Jobs es una infraestructura transversal que permite ejecutar operaciones de larga duración de forma asíncrona, mejorando la experiencia de usuario al evitar timeouts y bloqueo del navegador durante procesos pesados.

Problema Arquitectural

Contexto: Sistema Bautista tiene operaciones que pueden tardar varios minutos:

  • Facturación masiva: 500+ facturas en lote (3-10 minutos)
  • Reportes consolidados: Queries multi-schema con miles de registros (2-5 minutos)
  • Importación de datos: CSV/Excel con miles de líneas (5-15 minutos)
  • Sincronización AFIP: Consulta de padrones, webservices lentos (2-8 minutos)

Problema:

  • Usuario espera en navegador (mala UX)
  • Timeouts PHP/Nginx (30-60 segundos)
  • Reintentos duplican operaciones (ej: doble facturación)
  • NO hay feedback de progreso
  • Errores ocultos (usuario cierra navegador)

Necesidad:

  • Ejecución asíncrona (no bloquear request HTTP)
  • Notificaciones al usuario (success/error)
  • Consulta de estado/progreso
  • Arquitectura escalable (de 10 a 1000 jobs/día)

Solución Seleccionada

Se evaluaron 4 opciones arquitecturales. La seleccionada es Opción 2: SSE + PostgreSQL NOTIFY por su balance óptimo entre simplicidad y funcionalidad.

Opciones descartadas brevemente:

  • Opción 1 (Simple Polling síncrono): Descartada - No resuelve el problema (request HTTP bloqueado, timeouts)
  • Opción 3 (RabbitMQ): Diferida a Fase 4 - Complejidad y costo injustificados para volumen actual (< 500 jobs/día)
  • Opción 4 (DB Queue + Async): Descartada - Complejidad similar a RabbitMQ sin sus beneficios

Arquitectura Seleccionada: SSE + PostgreSQL NOTIFY ⭐

Descripción:

Jobs se ejecutan en procesos PHP separados lanzados con exec(). Estado se persiste en PostgreSQL. Frontend recibe actualizaciones vía Server-Sent Events (SSE) con PostgreSQL NOTIFY/LISTEN.

Arquitectura (Fase 1 - MVP):

1. POST /api/jobs/batch_invoicing
   → JobDispatcher crea job en BD (status='pending')
   → Lanza worker: exec("php cli/background-worker.php {$jobId} &")
   → Retorna 202 Accepted con job_id

2. Worker ejecuta job en proceso separado:
   → Actualiza status='running'
   → Ejecuta handler
   → Actualiza status='completed'/'failed'
   → Crea notificación

3. Frontend hace polling a GET /api/jobs/{id}
   (Fase 1: polling HTTP cada 2-5 segundos)

Arquitectura (Fase 2 - Real-Time):

3. Frontend conecta SSE: GET /api/jobs/{id}/stream
   → Backend escucha: LISTEN job_updates_{id}
   → PostgreSQL trigger envía: NOTIFY job_updates_{id}
   → SSE envía evento al frontend
   (Latencia: ~50-200ms vs ~2-5 segundos)

Pros:

  • ✅ Request HTTP retorna inmediatamente (~50ms)
  • ✅ CERO dependencias externas (solo PHP + PostgreSQL)
  • ✅ Multi-tenant friendly (schema isolation)
  • ✅ Notificaciones en tiempo real (Fase 2)
  • ✅ Escalable a 100-500 jobs/día
  • ✅ Path de migración claro a RabbitMQ (Fase 4)
  • ✅ Rollback instantáneo (feature flag)

Contras:

  • ❌ Polling en Fase 1 (latencia 2-5s)
  • ❌ 1 proceso PHP por job (límite ~50 jobs concurrentes)
  • ❌ Cleanup manual de jobs antiguos (cronjob)
  • ❌ NO retry automático (debe implementarse manualmente)

Casos de uso:

  • ✅ Operaciones 30 segundos - 30 minutos
  • ✅ Volumen bajo-medio (10-500 jobs/día)
  • ✅ Infraestructura simple (PHP + PostgreSQL)
  • ✅ NO requiere devops especializado

Justificación de selección:

  • ✅ Request HTTP retorna inmediatamente (~50ms)
  • ✅ CERO dependencias externas ($0 infraestructura)
  • ✅ Multi-tenant nativo (schema isolation)
  • ✅ Notificaciones en tiempo real (Fase 2)
  • ✅ Path de migración claro a RabbitMQ cuando volumen justifique
  • ✅ Rollback instantáneo (feature flag)
  • ✅ Escalable a 100-500 jobs/día (volumen actual < 50/día)

Decisiones Arquitecturales (ADRs)

Se documentaron 5 decisiones arquitecturales clave:

  1. ADR-001: Ejecución con exec() + CLI Worker - Por qué exec() en lugar de pcntl_fork, ReactPHP o Swoole
  2. ADR-002: Tablas por Schema Multi-Tenant - Por qué LEVEL_SUCURSAL en lugar de tabla global
  3. ADR-003: Polling → SSE Progresivo - Por qué implementación en fases en lugar de SSE desde día 1
  4. ADR-004: Wrapper Pattern (NO Refactoring) - Por qué envolver services existentes en lugar de refactorizarlos
  5. ADR-005: Schema Isolation en Background (CRÍTICO) - Cómo garantizar aislamiento multi-tenant en workers CLI

Para el detalle completo de cada ADR con contexto, alternativas consideradas, consecuencias y mitigaciones, consultar: Architecture Decision Records: Background Jobs


Patrones Utilizados

Strategy Pattern (JobHandlerInterface)

Propósito: Permitir agregar nuevos tipos de jobs sin modificar JobExecutor

Implementación:

php
interface JobHandlerInterface
{
    public function getType(): string;
    public function handle(array $payload): array;
}

class JobExecutor
{
    private array $handlers = [];

    public function registerHandler(JobHandlerInterface $handler): void
    {
        $this->handlers[$handler->getType()] = $handler;
    }

    public function execute(int $jobId): void
    {
        $job = $this->repo->findById($jobId);
        $handler = $this->handlers[$job->type];
        $result = $handler->handle($job->payload);
    }
}

Beneficio: Agregar nuevo job = crear clase handler + registrar en DI container (NO tocar JobExecutor)


Repository Pattern

Propósito: Abstraer acceso a datos, facilitar testing

Implementación:

php
class JobRepository
{
    public function create(BackgroundJob $job): int;
    public function findById(int $id): BackgroundJob|null;
    public function update(BackgroundJob $job): void;
    public function countPendingByUser(int $userId): int;
}

Beneficio:

  • Services NO dependen de implementación SQL
  • Tests unitarios mockean Repository
  • Tests de integración usan Repository real

Value Objects

Propósito: Encapsular lógica de dominio, validación, comportamiento

Implementación:

php
class BackgroundJob
{
    public function isPending(): bool { return $this->status === 'pending'; }
    public function isRunning(): bool { return $this->status === 'running'; }
    public function isCompleted(): bool { return $this->status === 'completed'; }
    public function isFailed(): bool { return $this->status === 'failed'; }
    public function getExecutionTime(): int|null { /* ... */ }
}

Beneficio:

  • Lógica de estados centralizada
  • Type safety (no trabajar con arrays asociativos)
  • Comportamiento cohesivo

Wrapper Pattern (Handler → Service)

Propósito: Agregar feature asíncrona sin modificar código existente

Implementación:

php
class BatchInvoicingJobHandler implements JobHandlerInterface
{
    public function __construct(
        private FacturaService $service // Existente sin cambios
    ) {}

    public function handle(array $payload): array
    {
        // Wrapper: reconstruir DTO y delegar
        $dto = $this->buildDTO($payload);
        return $this->service->batchInvoice($dto);
    }
}

Beneficio: CERO refactoring de código legacy, feature flag permite rollback instantáneo


Roadmap de Implementación

Fase 1: MVP con Polling (2-3 semanas) 🎯 ACTUAL

Objetivo: Background jobs funcionales con notificaciones por polling

Features:

  • ✅ Tabla background_jobs (LEVEL_SUCURSAL)
  • ✅ Tabla notifications (LEVEL_SUCURSAL)
  • ✅ JobDispatcher con exec()
  • ✅ CLI worker (background-worker.php)
  • ✅ JobExecutor con registro de handlers
  • ✅ Ejemplo: BatchInvoicingJobHandler
  • ✅ JobController (dispatch + getStatus)
  • ✅ Frontend: Polling con setInterval() o TanStack Query
  • ✅ Feature flag para rollout gradual
  • ✅ Tests unitarios (> 80% coverage)
  • ✅ Tests de integración (end-to-end + multi-tenant)

Entregables:

  • Documentación técnica completa
  • Tests passing
  • Deploy a staging
  • 1 módulo piloto (Ventas - facturación masiva)

Criterios de éxito:

  • Request HTTP retorna en < 200ms
  • Jobs ejecutan correctamente en background
  • Usuario recibe notificación cuando job termina
  • CERO cross-tenant data leakage (tests verifican)

Fase 2: Real-Time con SSE (1-2 semanas)

Objetivo: Reducir latencia de notificaciones de 2-5s a 50-200ms

Features:

  • ✅ Migration: Trigger notify_job_update() en PostgreSQL
  • ✅ JobStreamController con SSE endpoint
  • ✅ Backend: LISTEN en canal job_updates_{id}
  • ✅ Frontend: EventSource en lugar de polling
  • ✅ Manejo de reconexión automática (EventSource built-in)

Entregables:

  • SSE endpoint funcionando
  • Frontend con EventSource integration
  • Tests de SSE (complejo: simular notificaciones)
  • Documentación de troubleshooting SSE

Criterios de éxito:

  • Notificación llega en < 500ms después de job completar
  • Reconexión automática si se pierde conexión
  • Fallback a polling si SSE no disponible (progressive enhancement)

Cuándo ejecutar:

  • Volumen > 50 jobs/día
  • UX crítica (usuarios no toleran polling)

Fase 3: Production Hardening (2-3 semanas)

Objetivo: Reliability y observability para producción

Features:

  • ✅ Worker pool (long-running processes)
  • ✅ Retry automático con exponential backoff
  • ✅ Dead letter queue (background_jobs_dead_letter table)
  • ✅ Cronjob: Cleanup de stale jobs
  • ✅ Cronjob: Cleanup de jobs antiguos (> 30 días)
  • ✅ Metrics endpoint (/api/admin/jobs/stats)
  • ✅ Prometheus exporter (opcional)
  • ✅ Dashboard de monitoring (Grafana opcional)
  • ✅ Alertas (Slack/email cuando failed rate > threshold)

Entregables:

  • Worker pool script (cli/worker-pool.php)
  • Retry logic en JobExecutor
  • Cronjobs configurados
  • Dashboard básico de métricas
  • Runbook de troubleshooting

Criterios de éxito:

  • Worker pool gestiona 50+ jobs concurrentes
  • Jobs fallidos se reintentan automáticamente (max 3 veces)
  • Stale jobs se detectan y marcan como failed (< 10 minutos)
  • Métricas visibles en tiempo real
  • Alertas funcionan (test con job fallido intencional)

Cuándo ejecutar:

  • Volumen > 200 jobs/día
  • Jobs críticos para operación (ej: facturación, AFIP)

Fase 4: Scale con RabbitMQ (4-6 semanas) 🔮 FUTURO

Objetivo: Escalar a 1000+ jobs/día con features avanzadas

Features:

  • ✅ RabbitMQ setup (HA, monitoring)
  • ✅ Reemplazar exec() por RabbitMQ::publish()
  • ✅ Workers consumen de RabbitMQ queues
  • ✅ Priority queues (jobs urgentes primero)
  • ✅ Delayed jobs (ejecutar en X minutos)
  • ✅ Message TTL (expiración automática)
  • ✅ Workers distribuidos en múltiples servidores

Entregables:

  • Infraestructura RabbitMQ (Terraform/Ansible)
  • Código migrado a RabbitMQ
  • Tests de integración con RabbitMQ
  • Documentación de operación RabbitMQ
  • Plan de rollback (a Fase 3)

Criterios de éxito:

  • RabbitMQ gestiona 1000+ jobs/día sin degradación
  • Priority queues funcionan (urgent jobs < 10s, normal < 60s)
  • Workers en múltiples servidores distribuyen carga
  • HA probado (caída de 1 servidor no afecta jobs)

Cuándo ejecutar:

  • Volumen > 500 jobs/día sostenido
  • Necesidad de features avanzadas (priority, delay)
  • Multi-server deployment justificado
  • Equipo con experiencia RabbitMQ

Escalabilidad

Cuando Migrar a RabbitMQ

Indicadores de necesidad:

  1. Volumen sostenido > 500 jobs/día

    • exec() lanza muchos procesos concurrentes
    • OS tiene dificultad gestionando procesos
    • Latency de dispatch aumenta
  2. Picos de carga (ej: 200 jobs en 10 minutos)

    • Workers NO pueden procesar lo suficientemente rápido
    • Queue de jobs pendientes crece indefinidamente
    • Timeouts y errores aumentan
  3. Necesidad de retry automático

    • Jobs fallan por errores transitorios (API externa down)
    • Re-ejecutar manualmente es tedioso
    • Retry inteligente (exponential backoff) es crítico
  4. Necesidad de priority

    • Jobs urgentes (ej: factura fiscal) deben ejecutarse antes
    • Jobs batch (ej: reporte mensual) pueden esperar
    • FIFO simple NO es suficiente
  5. Multi-server deployment

    • Single server NO tiene capacidad para procesar volumen
    • Necesidad de horizontal scaling (agregar más workers)
    • Distribución de carga automática

Métricas concretas:

MétricaFase 1-3 (exec())Migrar a RabbitMQ si...
Jobs/día10-500> 500
Concurrencia pico10-50> 50
Failed rate< 5%> 5% (transient errors)
Avg queue time< 60s> 300s
Worker crashes< 1/día> 5/día

Path de Migración exec() → RabbitMQ

Paso 1: Abstraer Queue Interface (1 día)

php
interface QueueInterface
{
    public function enqueue(BackgroundJob $job): void;
}

Paso 2: Implementar ExecQueue (actual, 1 día)

php
class ExecQueue implements QueueInterface
{
    public function enqueue(BackgroundJob $job): void
    {
        exec("php cli/background-worker.php {$job->id} &");
    }
}

Paso 3: Implementar RabbitMQQueue (2-3 días)

php
class RabbitMQQueue implements QueueInterface
{
    public function enqueue(BackgroundJob $job): void
    {
        $message = new AMQPMessage(json_encode(['job_id' => $job->id]));
        $this->channel->basic_publish($message, '', 'background_jobs');
    }
}

Paso 4: Feature Flag (1 día)

php
$container->set(QueueInterface::class, function ($c) {
    return match ($c->get('config')['queue_backend']) {
        'rabbitmq' => new RabbitMQQueue($c->get(AMQPChannel::class)),
        default => new ExecQueue(),
    };
});

Paso 5: Desplegar con flag=exec (1 día)

bash
# .env
QUEUE_BACKEND=exec

Paso 6: Testing en staging con flag=rabbitmq (3-5 días)

bash
# staging .env
QUEUE_BACKEND=rabbitmq
RABBITMQ_HOST=rabbitmq-staging

Paso 7: Canary deployment en producción (1 semana)

php
// 10% de requests usan RabbitMQ
$useRabbitMQ = (mt_rand(1, 100) <= 10);
$backend = $useRabbitMQ ? 'rabbitmq' : 'exec';

Paso 8: Rollout completo (1 día)

bash
# production .env
QUEUE_BACKEND=rabbitmq

Paso 9: Deprecar ExecQueue (siguiente release)

Tiempo total estimado: 4-6 semanas (incluyendo setup RabbitMQ, testing, rollout gradual)


Trade-offs y Consideraciones

Cuándo Migrar a RabbitMQ (Fase 4)

Considerar migración cuando:

  • ✅ Volumen > 500 jobs/día sostenido
  • ✅ Se necesita retry automático con exponential backoff
  • ✅ Se requieren priority queues (jobs urgentes primero)
  • ✅ Delayed jobs son necesarios
  • ✅ Workers distribuidos en múltiples servidores

Costo de migración:

  • Infraestructura: $50-200/mes (RabbitMQ cloud)
  • Desarrollo: 4-6 semanas (implementación + testing + rollout)
  • Complejidad operacional: Media-Alta (monitoring, HA, clustering)

Beneficio esperado:

  • Retry automático (reduce intervención manual)
  • Escalabilidad a 10,000+ jobs/día
  • Features avanzadas (priority, delay, DLQ)

Por Qué NO WebSockets

WebSockets fueron descartados porque:

  • ❌ Overkill para comunicación unidireccional (servidor → cliente)
  • ❌ Complejidad adicional (protocol upgrade, connection management)
  • ❌ SSE es suficiente para notificaciones (latencia 50-200ms aceptable)
  • ❌ WebSockets solo necesarios para bidireccional (chat, collaborative editing)

Investigación Tecnológica

SSE vs WebSockets

Server-Sent Events (SSE):

Pros:

  • ✅ Protocolo simple (HTTP streaming)
  • ✅ Reconexión automática (built-in)
  • ✅ EventSource API nativo en navegadores
  • ✅ Funciona con proxies HTTP (no requiere upgrade)
  • ✅ Unidirectional (server → client) es suficiente para notificaciones

Contras:

  • ❌ Solo server → client (no bidirectional)
  • ❌ No soporta binary data (solo text)
  • ❌ EventSource no soporta custom headers (workaround: query param)
  • ❌ Límite de 6 conexiones por dominio (HTTP/1.1)

WebSockets:

Pros:

  • ✅ Bidirectional (server ↔ client)
  • ✅ Binary data support
  • ✅ Sin límite de conexiones
  • ✅ Menor latencia (sin overhead HTTP)

Contras:

  • ❌ Protocolo más complejo (handshake, framing)
  • ❌ Requiere servidor WS dedicado o librería (Ratchet, Swoole)
  • ❌ Proxies pueden bloquear (requiere wss://)
  • ❌ NO hay reconexión automática (debe implementarse)

Veredicto: SSE es suficiente para notificaciones de jobs (unidirectional), WebSockets es overkill.


Async PHP Options

1. exec() + CLI scripts ⭐ SELECCIONADO

Pros:

  • ✅ Simple, funciona en cualquier entorno PHP
  • ✅ Jobs son procesos independientes (NO bloquean)
  • ✅ CERO dependencias

Contras:

  • ❌ Overhead de inicialización PHP por job
  • ❌ NO true concurrency (1 proceso por job)

2. pcntl_fork()

Pros:

  • ✅ True forking (proceso hijo hereda memoria padre)
  • ✅ Más rápido que exec() (no re-inicializa PHP)

Contras:

  • ❌ Requiere extensión pcntl (no disponible en todos los entornos)
  • ❌ NO funciona en entornos thread-safe (Windows, algunos webhosts)

3. ReactPHP / Amphp (Event Loop)

Pros:

  • ✅ Async I/O en mismo proceso
  • ✅ Múltiples jobs concurrentes sin forking

Contras:

  • ❌ Job largo bloquea event loop
  • ❌ Curva de aprendizaje (async/await, promises)
  • ❌ Refactoring completo de código existente

4. Swoole / RoadRunner

Pros:

  • ✅ True concurrency (coroutines)
  • ✅ High performance (escala a 10000+ req/s)

Contras:

  • ❌ Requiere extensión Swoole o binario RoadRunner
  • ❌ Cambio completo de runtime (no compatible con código PHP tradicional)
  • ❌ Curva de aprendizaje alta

Veredicto: exec() es la mejor opción para Fase 1 (simplicidad + compatibilidad).


Queue Systems Comparados

SistemaTipoProsContrasCasos de uso
PostgreSQL + exec()Pseudo-queueSimple, CERO depsNo escala > 500/díaFase 1-3
RabbitMQFull brokerFeatures completas, escalaDependencia crítica, ops complexFase 4
Redis QueuesIn-memoryMuy rápido, simplePérdida de datos si crash, no HACache + jobs no críticos
Amazon SQSCloud managedHA, escala infinitoVendor lock-in, latencyCloud-first deployments
Apache KafkaEvent streamingEscala masivo, replayOverkill para jobs, curva altaBig data, event sourcing

Veredicto: PostgreSQL + exec() para empezar, RabbitMQ cuando volumen justifique.


Última Actualización: 2026-02-05 Versión: 1.0 Próxima Revisión: Después de implementar Fase 1 (recopilar feedback)