Skip to content

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

EventSource no puede enviar headers custom (Authorization) y el browser no envía cookies de app.com a api.com por restricciones cross-domain. El ACCESS_TOKEN es seteado en app.com vía Request::syncCookies() y no viaja al dominio api.com.

El hook useJobStream del 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:

  1. Polling HTTP: Frontend consulta estado cada N segundos
  2. Server-Sent Events (SSE): Servidor envía eventos cuando job cambia
  3. WebSockets: Conexión bidirectional full-duplex
  4. 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


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 to MembershipsApp and CRMApp) reads localStorage.tipo_transaccion (values 0, 1, 2) and listens for the 'modeChanged' CustomEvent.
  • isPrueba is true when tipoTransaccion === 0 (mode 0 = Prueba).
  • useBackgroundJob reads useModo() and sends prueba: isPrueba in the POST body.
  • The backend ConnectionMiddleware routes to the _p database when prueba: true is 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: Endpoint GET /backend/jobs/{id}/stream funcional.
  • 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:

  1. Implementar cookie strategy cross-domain (SameSite=None + Secure entre app.com y api.com)
  2. O implementar un proxy SSE en app.com que reenvie al backend con auth inyectada
  3. 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.