Appearance
ADR-003: Polling HTTP → SSE (Fases)
Fecha: 2026-02-05 Estado: Aprobado — Fase 1 en produccion. Backend SSE implementado. Frontend SSE bloqueado (ver ENMIENDA 2026-02-24) Deciders: Architecture Team, Frontend Team
⚠️ Fase 2 (SSE) bloqueada temporalmente
EventSourceno puede enviar headers custom (Authorization) y el browser no envía cookies deapp.comaapi.compor restricciones cross-domain. ElACCESS_TOKENes seteado enapp.comvíaRequest::syncCookies()y no viaja al dominioapi.com.El hook
useJobStreamdel frontend fue simplificado para delegar completamente al polling HTTP (useBackgroundJob). La interfaz de retorno se mantiene idéntica para poder activar SSE en el futuro sin romper consumidores.Prerequisito para Fase 2: Refactorizar el sistema de autenticación para unificar la estrategia de cookies entre dominios, o implementar un proxy SSE en
app.com.
Contexto y Problema
Usuario necesita saber cuando su job termina (success/error). Opciones de notificación:
- Polling HTTP: Frontend consulta estado cada N segundos
- Server-Sent Events (SSE): Servidor envía eventos cuando job cambia
- WebSockets: Conexión bidirectional full-duplex
- Push Notifications: Service worker + Push API
Criterios de evaluación:
- Latencia de notificación (tiempo entre job completo → usuario notificado)
- Complejidad de implementación
- Overhead de servidor/red
- Compatibilidad con navegadores
- Infraestructura necesaria
Opciones Consideradas
Opción A: Polling HTTP (MVP - FASE 1)
Descripción:
- Frontend hace
setInterval(() => fetch('/api/jobs/${id}'), 2000) - Backend retorna estado actual del job
- Cuando status='completed' o 'failed', frontend detiene polling
Pros:
- ✅ Muy simple de implementar (días)
- ✅ CERO infraestructura adicional
- ✅ Funciona en 100% de navegadores
- ✅ NO requiere conexión persistente
- ✅ Compatible con todos los proxies/load balancers
Contras:
- ❌ Latencia: 2-5 segundos (intervalo de polling)
- ❌ Overhead: requests aunque job no haya cambiado
- ❌ No escala bien con muchos jobs concurrentes (N usuarios = N polling loops)
Casos de uso:
- ✅ MVP (Fase 1)
- ✅ Jobs de larga duración (> 1 minuto) donde latencia de 2-5s es aceptable
- ✅ Volumen bajo (< 50 jobs concurrentes)
Opción B: Server-Sent Events (SSE - FASE 2)
Descripción:
- Backend: Trigger PostgreSQL AFTER UPDATE en
background_jobs - Trigger envía:
NOTIFY job_updates_{id}, '{"status":"completed"}' - Controller escucha:
LISTEN job_updates_{id} - Controller envía evento SSE al frontend
- Frontend:
new EventSource('/api/jobs/${id}/stream')
Pros:
- ✅ Latencia mínima: 50-200ms
- ✅ CERO overhead cuando job no cambia (conexión idle)
- ✅ Protocolo simple (HTTP streaming)
- ✅ Reconexión automática (built-in en EventSource)
- ✅ Sin dependencias externas (PostgreSQL nativo)
Contras:
- ❌ 1 conexión persistente por usuario activo
- ❌ EventSource NO soporta custom headers (workaround: auth via query param)
- ❌ Límite de 6 conexiones por dominio en HTTP/1.1
- ❌ Más complejo que polling (días vs semanas)
Casos de uso:
- ✅ UX crítica (usuarios no toleran espera de polling)
- ✅ Volumen medio (50-500 jobs concurrentes)
- ✅ Jobs de cualquier duración (notificación instantánea)
Opción C: WebSockets
Descripción:
- Full-duplex bidirectional connection
- Servidor envía eventos cuando job cambia
- Cliente puede enviar mensajes también
Pros:
- ✅ Latencia mínima: 10-50ms
- ✅ Bidirectional (si se necesita en futuro)
- ✅ Binary data support
Contras:
- ❌ Protocolo más complejo (handshake, framing, ping/pong)
- ❌ Requiere servidor WS dedicado o librería (Ratchet, Swoole)
- ❌ Proxies pueden bloquear conexiones (requiere wss://)
- ❌ NO hay reconexión automática (debe implementarse)
- ❌ Overkill para unidirectional notifications
Veredicto: ❌ Descartado (complejidad sin beneficio vs SSE)
Opción D: Push Notifications
Descripción:
- Service worker + Push API
- Servidor envía push notification cuando job completa
- Usuario recibe notificación aunque navegador cerrado
Pros:
- ✅ Funciona aunque usuario cierre navegador
- ✅ Notificación nativa del OS
Contras:
- ❌ Requiere HTTPS
- ❌ Requiere permiso del usuario (muchos lo deniegan)
- ❌ Complejo de implementar (service worker, VAPID keys, etc.)
- ❌ NO funciona en desktop si browser cerrado
Veredicto: ❌ Descartado (complejidad excesiva, NO cubre caso desktop)
Decisión
Implementar en FASES:
Fase 1 (MVP - 2-3 semanas): Polling HTTP Fase 2 (Optimización - 1-2 semanas): SSE + PostgreSQL NOTIFY
Justificación:
- Time to market: Polling permite MVP funcional rápido (2-3 semanas)
- Progressive enhancement: SSE mejora UX sin breaking changes
- Path claro: Frontend abstrae notificación (polling o SSE), backend solo agrega endpoint SSE
Consecuencias
Fase 1: Polling HTTP
Positivas:
- ✅ MVP funcional en 2-3 semanas
- ✅ CERO complejidad adicional
- ✅ Latencia aceptable para jobs largos (> 1 minuto)
Negativas:
- ❌ Latencia 2-5 segundos
- ❌ Overhead de requests constantes
Fase 2: SSE + NOTIFY
Positivas:
- ✅ Latencia 50-200ms (near real-time)
- ✅ CERO overhead cuando job no cambia
- ✅ Sin dependencias externas (PostgreSQL nativo)
Negativas:
- ❌ 1 conexión persistente por usuario activo
- ❌ Complejidad adicional (trigger, LISTEN, SSE)
Implementación
Fase 1: Polling
Frontend (React):
javascript
function useJobStatus(jobId) {
return useQuery({
queryKey: ['job', jobId],
queryFn: () => api.get(`/api/jobs/${jobId}`),
refetchInterval: (data) => {
if (data?.data?.status === 'pending' || data?.data?.status === 'running') {
return 2000; // Poll cada 2 segundos
}
return false; // Stop polling si completed/failed
}
});
}Fase 2: SSE
Backend (PHP):
php
public function stream(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$jobId = (int) $args['id'];
// Headers SSE
$response = $response
->withHeader('Content-Type', 'text/event-stream')
->withHeader('Cache-Control', 'no-cache');
// LISTEN PostgreSQL
$pdo->exec("LISTEN job_updates_{$jobId}");
while (true) {
$notification = pg_get_notify($pdo);
if ($notification) {
$response->getBody()->write("data: {$notification['payload']}\n\n");
flush();
$data = json_decode($notification['payload']);
if (in_array($data['status'], ['completed', 'failed'])) {
break; // Cerrar stream
}
}
usleep(100000); // 100ms
}
return $response;
}Frontend (React):
javascript
function useJobStatusSSE(jobId) {
const [status, setStatus] = useState('pending');
useEffect(() => {
const eventSource = new EventSource(`/api/jobs/${jobId}/stream?token=${getToken()}`);
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
setStatus(data.status);
if (data.status === 'completed' || data.status === 'failed') {
eventSource.close();
}
});
return () => eventSource.close();
}, [jobId]);
return status;
}Migration (PostgreSQL):
sql
CREATE OR REPLACE FUNCTION notify_job_update()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'job_updates_' || NEW.id,
json_build_object(
'id', NEW.id,
'status', NEW.status,
'result', NEW.result,
'error', NEW.error
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER background_jobs_update_trigger
AFTER UPDATE ON background_jobs
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_job_update();Referencias
- Architecture: Background Jobs
- Backend: Background Jobs
- Server-Sent Events Spec
- PostgreSQL NOTIFY/LISTEN
- EventSource MDN
- SSE vs WebSockets
Amendment — 2026-02-23
Mode-Awareness in useBackgroundJob
Change: useBackgroundJob now reads ModoContext and includes prueba: true/false in the POST body when dispatching a job.
How it works:
ModoProvider(added toMembershipsAppandCRMApp) readslocalStorage.tipo_transaccion(values 0, 1, 2) and listens for the'modeChanged'CustomEvent.isPruebaistruewhentipoTransaccion === 0(mode 0 = Prueba).useBackgroundJobreadsuseModo()and sendsprueba: isPruebain the POST body.- The backend
ConnectionMiddlewareroutes to the_pdatabase whenprueba: trueis received.
Important: The frontend does NOT manipulate payload.db. Mode routing is entirely handled by the backend's ConnectionMiddleware based on the prueba boolean.
Single source of truth: ModoContext is the single source of truth for mode-awareness in the React tree. It reads from localStorage.tipo_transaccion and listens to the 'modeChanged' CustomEvent for live updates.
Safe fallback: useModo() outside a ModoProvider returns { tipoTransaccion: 1, isPrueba: false } (Oficial mode) — this ensures standalone mountComponent-based apps don't default to Prueba mode unintentionally.
ENMIENDA (2026-02-24) — Estado de implementacion de las fases
Fase 1: Polling HTTP — IMPLEMENTADA Y EN PRODUCCION
La Fase 1 esta completamente implementada y funcionando en produccion. El frontend usa HTTP polling cada 2 segundos via useBackgroundJob (TanStack Query con refetchInterval). El polling se detiene automaticamente cuando el job alcanza un estado terminal (completed o failed).
Backend SSE — IMPLEMENTADO
El backend SSE esta completamente implementado:
JobStreamController: EndpointGET /backend/jobs/{id}/streamfuncional.- PostgreSQL LISTEN/NOTIFY: Trigger
notify_job_update()envia notificaciones en tiempo real cuando cambia el estado de un job. - Infraestructura lista: El endpoint esta registrado en las rutas y protegido por autenticacion.
Frontend SSE — BLOQUEADO (sin cambios respecto a la nota original)
El frontend SSE permanece desactivado. El hook useJobStream retorna valores constantes hardcodeados:
isConnected: false(constante)usingSseFallback: true(constante)
Internamente, useJobStream delega completamente a useBackgroundJob (polling HTTP). La interfaz de retorno se mantiene identica para no romper consumidores.
Causa tecnica (sin cambios): EventSource no puede enviar headers custom (Authorization). El ACCESS_TOKEN es seteado en app.com via Request::syncCookies() y el browser no envia cookies de app.com a api.com por restricciones cross-domain.
Para desbloquear Fase 2 en frontend:
- Implementar cookie strategy cross-domain (SameSite=None + Secure entre app.com y api.com)
- O implementar un proxy SSE en
app.comque reenvie al backend con auth inyectada - O migrar a un esquema donde ambos esten en el mismo dominio
Impacto: Los hooks estan "SSE-ready". Cuando se resuelva el auth cross-domain, activar SSE en el frontend no requiere breaking changes — solo cambiar las constantes en useJobStream por la logica real de EventSource.