Appearance
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:
- Overhead: Aceptable para jobs de 30s-30min (< 0.5% del tiempo total)
- Concurrency limit: Suficiente para volumen objetivo (10-500 jobs/día)
- Zombies: Cronjob detecta stale jobs (running > 60 minutos) y marca como failed
Path de Migración (Futuro)
Cuando volumen > 500 jobs/día:
Fase 3: Implementar worker pool (long-running processes)
- Reduce overhead de inicialización
- Controla concurrencia (max N workers)
- Sigue usando exec() para lanzar pool
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(...);
}
}