CreaRack-SL

Incidente: bib_compute_communities genera runs zombie por subrequest budget (sesión 25)

Resumen

El handler bib_compute_communities del Bibliotecario excedía el límite de subrequests de Cloudflare Workers paid plan (1 000/req), muriendo antes de ejecutar completeRun. El cliente recibía HTTP 200 — sin cuerpo de error visible — pero el run D1 quedaba en status='running' para siempre. Se acumularon 160 rows zombie en la tabla bib_index_runs durante varias sesiones, y las estadísticas de comunidades que mostraba el sensor (bib_stats: 292 communities, cohesión 0.83) provenían de un run antiguo que sí había cerrado correctamente.

Regla 15 al 100 %: HTTP 200 ≠ éxito semántico. El cliente debe parsear el body y verificar la presencia de "run_id" antes de loguear OK.


Cronología

MomentoEvento
Sesiones 20–24Cada cron de 10 min lanzaba bib_compute_communities; cada run moría silenciosamente ~52s después (CF Worker timeout/budget).
2026-04-25 08:00 UTCEdu pide revisar sensor Bibliotecario → descubiertos 160 runs en status='running'.
2026-04-25 08:16 UTCCommit 1af002b con fix definitivo en 5 piezas.
2026-04-25 ~08:30 UTCCleanup D1 manual: wrangler d1 execute → 160 rows marcados status='failed'.

Causa raíz

El handler iteraba secuencialmente sobre cada comunidad detectada (292 con el grafo real). Por cada comunidad realizaba:

  • 2 SELECT para display_name de topNodes y bridgeNodes
  • 1 INSERT en bib_communities
  • N/90 UPDATE en bib_nodes SET community_id

Subrequests estimados con 292 comunidades: ~1 500–2 000 vs límite 1 000/req del plan paid. El Worker mataba el evento tras superar el budget, pero la response HTTP 200 ya había viajado al cliente (streaming). completeRun nunca se ejecutaba → row queda en running indefinidamente.

Agravante: no había try/catch en el case, así que tampoco se ejecutaba ningún failRun. El reaper automático tampoco existía hasta este fix.


Impacto

  • Estadísticas falsas: bib_stats mostraba datos de un run pre-Fase 7. Las comunidades reales del grafo expandido (Fase 7 +669 nodos TS) nunca se recalcularon.
  • D1 contaminado: 160 runs en running hacían inútil la tabla bib_index_runs para monitorización.
  • Cron desperdiciado: el cron de 10 min en Hetzner Staging lanzaba bib_compute_communities en cada tick, quemando subrequests sin utilidad.
  • Diagnóstico difícil: bib_list_runs requería paginación para ver los zombies; el sensor Pulse no tenía alerta específica para este estado.

Solución definitiva (commit 1af002b)

1. Refactor handler — de ~1 500 a ~10 round trips

functions/api/mcp/handlers/biblioteca.ts:

  • Pre-fetch masivo de display_name: un único loop recorre todos los topNodes y bridgeNodes de todas las comunidades, los deduplica y los carga en chunks de 90 (límite bind params D1). ceil(N_unique / 90) SELECTs en lugar de 2 × N_comunidades.
  • db.batch() para INSERTs: todos los INSERT INTO bib_communities se preparan como array de D1PreparedStatement y se envían en una sola llamada await db.batch(insertStmts). 1 round trip vs 292.
  • db.batch() para UPDATEs: todos los UPDATE bib_nodes SET community_id se acumulan en updateStmts[] y se envían en un único await db.batch(updateStmts). 1 round trip vs ~600.
  • Wipe con batch: DELETE FROM bib_communities + UPDATE bib_nodes SET community_id = NULL en un solo db.batch([...]).

Presupuesto total resultante:

1 SELECT nodes + 1 SELECT edges
+ ceil(N_unique_ids / 90) SELECTs nombres
+ 1 batch [DELETE + UPDATE NULL]
+ 1 batch INSERT communities
+ 1 batch UPDATE community_id
≈ 6–12 round trips independientemente del número de comunidades

2. try/catch/finally con failRun

Nueva función failRun(db, runId, error, startTime) que escribe status='failed', errors, duration_ms y completed_at. El case bib_compute_communities queda envuelto en try { ... } catch (err) { await failRun(...); throw err; }, garantizando que cualquier excepción (incluido budget excedido si CF lo propaga) cierra el run.

3. Auto-reaper de zombies en createRun

Nueva función reapZombieRuns(db) llamada al inicio de cada createRun. Marca como failed todos los runs en status='running' con started_at < datetime('now', '-5 minutes'):

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')

Previene acumulación silenciosa en futuras regresiones.

4. Sensor de zombie_runs

functions/_lib/hot-cache.ts: nueva query en fetchGraphHealth que cuenta runs en status='running' con started_at < datetime('now', '-30 minutes'). El campo zombie_runs: number se añade a la interfaz GraphHealth.

src/components/biblioteca/PulsePanel.tsx: alerta visual roja (#ef4444, fontWeight 600) en la sección “Salud del grafo” si zombie_runs > 0:

⚠ N run(s) zombie (status=running >30 min) — handler murió sin completar

5. Cron separado en CreaRack-Pro

scripts/cron-bib-reindex.sh recibe flag --communities-only. El cron en Hetzner Staging (/etc/cron.d/bib-reindex) pasa de 1 entrada a 2:

*/10 * * * *  root  /opt/crearack-pro/scripts/cron-bib-reindex.sh        # AST — barato, <12s
0    4  * * *  root  /opt/crearack-pro/scripts/cron-bib-reindex.sh --communities-only  # 1×/día 04:00 UTC

AST (indexación incremental) sigue cada 10 min porque completa en <12s y <50 subrequests. bib_compute_communities pasa a 1×/día.


Cleanup D1

wrangler d1 execute crearack-workspace-db --remote \
  --command "UPDATE bib_index_runs SET status='failed', errors='[\"zombie reaper: manual cleanup 2026-04-25\"]', completed_at=datetime('now') WHERE status='running'"
# → 160 rows afectados

Lecciones aprendidas

  1. HTTP 200 ≠ éxito (Regla 15): el cron script debe parsear el body y verificar "run_id" antes de loguear OK.
  2. db.batch() es obligatorio en handlers que tocan >100 filas en un loop. La regla mental: si hay más de ~100 statements en un loop secuencial, es un bug en gestación.
  3. try/catch/finally siempre en handlers MCP que abren un runId. Si el Worker muere, el finally no se ejecuta en CF (a diferencia de Node.js) — por eso se necesita el reaper externo.
  4. Sensor primero: sin zombie_runs en el Pulse, este fallo habría seguido meses sin detectarse porque las stats parecían correctas.
  5. Crons separados por coste: mezclar operaciones de coste O(1) con O(N·comunidades) en el mismo cron interval es una trampa. Separar por frecuencia adecuada al coste real.

Estado final

  • ✅ 160 rows zombie limpios (D1 manual)
  • ✅ Handler refactorizado a ~10 round trips
  • ✅ failRun + reapZombieRuns en producción
  • ✅ Sensor zombie_runs activo en Pulse
  • ✅ Cron separado en Hetzner Staging
  • ✅ wiki_lint_bulk orphans bajó de 3 a 1 (solo catalog meta)

Véase también

  • [[concept—biblioteca—supercontexto]]
  • [[feature—workspace—pulse-tiempo-real]]
  • [[feature—supercontexto—reconcile-d1-repo]]
  • [[feature—supercontexto—weekly-report]]
  • [[runbook—biblioteca—manual-reindex]]
  • [[feature—supercontexto—zombie-runs-sensor]] — sensor que detecta los runs zombie tratados en este incidente