Volver a la wiki

Sensor zombie_runs: detección de runs colgados en el Bibliotecario

Qué es

El sensor zombie_runs es una métrica de observabilidad añadida al sistema Supercontexto en sesión 25 (2026-04-25). Detecta handlers MCP del Bibliotecario que han abierto un bib_index_run pero han muerto sin cerrarlos (sin llamar a completeRun ni failRun), dejando el run en status='running' de forma indefinida.

Un run se considera zombie si lleva más de 30 minutos en status='running'. Ese umbral supera con holgura cualquier ejecución legítima (el handler más lento, bib_compute_communities con 300+ comunidades, completa en <60s tras el refactor).


Motivación

El incidente de sesión 25 reveló que bib_compute_communities excedía el budget de subrequests de CF Workers (~1 500/req vs límite 1 000) y moría antes de completeRun. El cliente recibía HTTP 200, las estadísticas del grafo parecían correctas (datos de un run previo), y sin un sensor específico el fallo era invisible. Se acumularon 160 runs zombie en producción.

→ Ver [[incident—20260425—bib-compute-communities-zombie]] para el diagnóstico completo.


Implementación

Query D1 (hot-cache.ts)

// functions/_lib/hot-cache.ts — dentro de fetchGraphHealth()
env.DB.prepare(
  `SELECT COUNT(*) as n FROM bib_index_runs
   WHERE status = 'running' AND started_at < datetime('now', '-30 minutes')`,
)
  .first<{ n: number }>()
  .catch(() => null)

El resultado se expone como zombie_runs: number en la interfaz GraphHealth y viaja al cliente a través del endpoint /api/supercontext/hot-cache (o el mecanismo de polling del Pulse).

Interfaz TypeScript

// functions/_lib/hot-cache.ts
export interface GraphHealth {
  node_count: number;
  edge_count: number;
  community_count: number;
  avg_cohesion_pct: number;
  stale_count: number;
  last_indexed: string | null;
  zombie_runs: number;   // ← nuevo en sesión 25
}

// src/components/biblioteca/PulsePanel.tsx
interface GraphHealth {
  // ...campos anteriores...
  zombie_runs?: number;  // optional para compatibilidad hacia atrás
}

Alerta visual en PulsePanel

// src/components/biblioteca/PulsePanel.tsx
{(data.graph_health.zombie_runs ?? 0) > 0 && (
  <>
    <br />
    <span style={{ color: '#ef4444', fontWeight: 600 }}>
      ⚠ {data.graph_health.zombie_runs} run{data.graph_health.zombie_runs === 1 ? '' : 's'} zombie
      (status=running &gt;30 min) — handler murió sin completar
    </span>
  </>
)}

La alerta aparece en rojo (#ef4444) directamente bajo las estadísticas de “Salud del grafo” en el Pulse Panel. Es visible de inmediato sin necesidad de navegar a ninguna vista secundaria.


Mecanismo complementario: reapZombieRuns

El sensor detecta zombies; el reaper los resuelve automáticamente en el siguiente run de cualquier tipo.

// functions/api/mcp/handlers/biblioteca.ts
async function reapZombieRuns(db: D1Database): Promise<void> {
  await db.prepare(
    `UPDATE bib_index_runs
       SET status = 'failed',
           errors = '["zombie reaper: status=running > 5min, asumido muerto"]',
           completed_at = datetime('now')
     WHERE status = 'running'
       AND started_at < datetime('now', '-5 minutes')`
  ).run();
}

async function createRun(db: D1Database, runType: string, source?: string): Promise<number> {
  await reapZombieRuns(db);  // ← llamada preventiva antes de cada nuevo run
  // ...INSERT nuevo run...
}

El umbral del reaper (5 min) es diferente al del sensor (30 min): el reaper es agresivo porque 5 min ya garantiza que ningún run legítimo está en curso; el sensor usa 30 min para evitar falsos positivos en picos de carga.


Umbrales y calibración

UmbralComponenteRazón
>5 minreapZombieRunsNingún handler legítimo tarda más (el más lento: ~60s post-refactor)
>30 minSensor zombie_runsMargen para evitar alertas falsas en picos; permite al reaper actuar primero

Si se añaden handlers nuevos con tiempos de ejecución >5 min, revisar el umbral de reapZombieRuns para no marcar como failed runs legítimos en curso.


Integración con el flujo de runs

createRun()
  └─ reapZombieRuns()           # limpia zombies previos
  └─ INSERT bib_index_runs      # abre nuevo run
       │
       ├─ [handler ejecuta]
       │
       ├─ completeRun()          # happy path → status='completed'
       └─ failRun()              # error path → status='failed'

fetchGraphHealth() [cada poll]
  └─ COUNT running > 30min → zombie_runs
       └─ PulsePanel: alerta roja si > 0

Runbook de respuesta

Si el sensor Pulse muestra zombie_runs > N:

  1. Verificar causa: bib_list_runs filtrando status=running. Ver run_type y started_at de los afectados.
  2. Forzar reap manual si el reaper automático no ha actuado (p.ej. no ha habido ningún createRun reciente):
    wrangler d1 execute crearack-workspace-db --remote \
      --command "UPDATE bib_index_runs SET status='failed', errors='[\"manual reap\"]', completed_at=datetime('now') WHERE status='running' AND started_at < datetime('now', '-5 minutes')"
  3. Investigar causa raíz: si el handler involucrado tiene loops con >100 statements D1 secuenciales, refactorizar con db.batch().
  4. Verificar cron: confirmar que el cron externo (Hetzner Staging) no está lanzando el handler con una frecuencia superior a su tiempo de ejecución real.

→ Ver también [[runbook—biblioteca—manual-reindex]] para contexto completo de operaciones manuales.


Véase también

Subir