Skip to content

ADR-001: Ejecución con exec() + CLI Worker

Fecha: 2026-02-05 Estado: Aprobado Deciders: Architecture Team, Backend Team

Contexto y Problema

Sistema Bautista necesita ejecutar operaciones de larga duración (30 segundos - 30 minutos) sin bloquear el request HTTP ni el navegador del usuario. PHP no tiene threading nativo y necesitamos una solución que:

  • NO bloquee request HTTP (timeout 30-60 segundos)
  • NO requiera dependencias externas complejas
  • Funcione en cualquier entorno PHP (shared hosting friendly)
  • Sea simple de implementar y mantener

Operaciones objetivo:

  • Facturación masiva: 500+ facturas (3-10 minutos)
  • Reportes consolidados: queries multi-schema (2-5 minutos)
  • Importación CSV: miles de líneas (5-15 minutos)
  • Sincronización AFIP: webservices lentos (2-8 minutos)

Opciones Consideradas

Opción A: exec() + CLI Script (SELECCIONADA)

Descripción:

  • Controller crea job en BD
  • Controller lanza proceso PHP CLI con exec()
  • Operador & hace proceso non-blocking (background)
  • Worker script lee job de BD y ejecuta

Comando:

bash
exec("php cli/background-worker.php $jobId > /dev/null 2>&1 &")

Pros:

  • ✅ Request HTTP retorna inmediatamente (< 200ms)
  • ✅ CERO dependencias externas (solo PHP CLI)
  • ✅ Worker es proceso independiente (puede correr 30+ minutos sin timeouts HTTP)
  • ✅ Fácil de implementar (días, no semanas)
  • ✅ Funciona en cualquier entorno PHP
  • ✅ Debugging simple (logs, ps aux)

Contras:

  • ❌ Overhead de inicialización PHP por job (~50-100ms)
  • ❌ 1 proceso PHP por job (límite ~50-100 jobs concurrentes)
  • ❌ Procesos zombie si job crashea (mitigado con cronjob cleanup)
  • ❌ NO retry automático (debe implementarse manualmente)

Opción B: pcntl_fork()

Descripción:

  • Controller hace pcntl_fork() para crear proceso hijo
  • Proceso hijo ejecuta job en background
  • Proceso padre retorna response HTTP

Pros:

  • ✅ Más rápido que exec() (no re-inicializa PHP)
  • ✅ Proceso hijo hereda memoria del padre (menos overhead)

Contras:

  • ❌ Requiere extensión pcntl (no disponible en todos los entornos)
  • ❌ NO funciona en builds thread-safe (Windows, algunos webhosts)
  • ❌ Proceso padre debe esperar a hijo (zombies si no se hace wait())
  • ❌ Más complejo de debuggear

Veredicto: ❌ Descartado (incompatibilidad con entornos comunes)


Opción C: ReactPHP / Amphp (Event Loop)

Descripción:

  • Event loop asíncrono en mismo proceso
  • Jobs se ejecutan como promesas/coroutines
  • NO forking, async I/O

Pros:

  • ✅ Múltiples jobs concurrentes sin forking
  • ✅ Eficiente para I/O-bound operations

Contras:

  • ❌ Job largo bloquea event loop (NO apto para CPU-bound)
  • ❌ Refactoring completo de código existente (todo debe ser async)
  • ❌ Curva de aprendizaje alta (async/await, promises)
  • ❌ Debugging complejo (stack traces de async)

Veredicto: ❌ Descartado (refactoring masivo, no apto para CPU-bound)


Opción D: Swoole / RoadRunner

Descripción:

  • Runtime PHP alternativo con coroutines nativas
  • High performance (10000+ req/s)
  • True concurrency

Pros:

  • ✅ True concurrency (coroutines)
  • ✅ Muy alto performance
  • ✅ Built-in worker pool

Contras:

  • ❌ Requiere extensión Swoole o binario RoadRunner
  • ❌ NO compatible con código PHP tradicional (cambio completo de runtime)
  • ❌ Curva de aprendizaje muy alta
  • ❌ Menor soporte community vs PHP tradicional

Veredicto: ❌ Descartado (cambio de runtime demasiado disruptivo)


Decisión

Seleccionamos Opción A: exec() + CLI Script

Justificación:

  • Balance óptimo simplicidad/funcionalidad para volumen bajo-medio (10-500 jobs/día)
  • CERO dependencias externas = menor riesgo, más fácil deployment
  • Compatibilidad universal con cualquier entorno PHP
  • Time to market más corto (2-3 semanas vs 4-6 semanas)
  • Path de migración claro a worker pool o RabbitMQ cuando volumen justifique

Consecuencias

Positivas

  • ✅ Request HTTP retorna inmediatamente (usuario NO espera)
  • ✅ Jobs pueden ejecutar 30+ minutos sin timeouts
  • ✅ Implementación rápida (MVP en 2-3 semanas)
  • ✅ Debugging simple (logs, ps aux, strace)
  • ✅ Rollback instantáneo (feature flag OFF)

Negativas

  • ❌ Overhead de ~50-100ms por job (inicialización PHP)
  • ❌ Límite de ~50-100 jobs concurrentes (OS process limit)
  • ❌ Cleanup manual de procesos zombie (cronjob cada 10 minutos)

Mitigaciones

Mitigaciones de negativos:

  1. Overhead: Aceptable para jobs de 30s-30min (< 0.5% del tiempo total)
  2. Concurrency limit: Suficiente para volumen objetivo (10-500 jobs/día)
  3. Zombies: Cronjob detecta stale jobs (running > 60 minutos) y marca como failed

Path de Migración (Futuro)

Cuando volumen > 500 jobs/día:

  1. Fase 3: Implementar worker pool (long-running processes)

    • Reduce overhead de inicialización
    • Controla concurrencia (max N workers)
    • Sigue usando exec() para lanzar pool
  2. Fase 4: Migrar a RabbitMQ

    • Reemplazar exec() por RabbitMQ publish
    • Workers consumen de queue
    • Features avanzadas (retry, priority, delay)

Interfaz abstracta para facilitar migración:

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

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

// Fase 4
class RabbitMQQueue implements QueueInterface {
    public function enqueue(BackgroundJob $job): void {
        $this->channel->basic_publish(...);
    }
}

Referencias