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
| Momento | Evento |
|---|---|
| Sesiones 20–24 | Cada 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 UTC | Edu pide revisar sensor Bibliotecario → descubiertos 160 runs en status='running'. |
| 2026-04-25 08:16 UTC | Commit 1af002b con fix definitivo en 5 piezas. |
| 2026-04-25 ~08:30 UTC | Cleanup 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
SELECTparadisplay_namedetopNodesybridgeNodes - 1
INSERTenbib_communities - N/90
UPDATEenbib_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_statsmostraba 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
runninghacían inútil la tablabib_index_runspara monitorización. - Cron desperdiciado: el cron de 10 min en Hetzner Staging lanzaba
bib_compute_communitiesen cada tick, quemando subrequests sin utilidad. - Diagnóstico difícil:
bib_list_runsrequerí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 lostopNodesybridgeNodesde todas las comunidades, los deduplica y los carga en chunks de 90 (límite bind params D1).ceil(N_unique / 90)SELECTs en lugar de2 × N_comunidades. db.batch()para INSERTs: todos losINSERT INTO bib_communitiesse preparan como array deD1PreparedStatementy se envían en una sola llamadaawait db.batch(insertStmts). 1 round trip vs 292.db.batch()para UPDATEs: todos losUPDATE bib_nodes SET community_idse acumulan enupdateStmts[]y se envían en un únicoawait db.batch(updateStmts). 1 round trip vs ~600.- Wipe con batch:
DELETE FROM bib_communities+UPDATE bib_nodes SET community_id = NULLen un solodb.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
- HTTP 200 ≠ éxito (Regla 15): el cron script debe parsear el body y verificar
"run_id"antes de loguear OK. 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.try/catch/finallysiempre en handlers MCP que abren unrunId. Si el Worker muere, elfinallyno se ejecuta en CF (a diferencia de Node.js) — por eso se necesita el reaper externo.- Sensor primero: sin
zombie_runsen el Pulse, este fallo habría seguido meses sin detectarse porque las stats parecían correctas. - 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+reapZombieRunsen producción - ✅ Sensor
zombie_runsactivo en Pulse - ✅ Cron separado en Hetzner Staging
- ✅
wiki_lint_bulkorphans bajó de 3 a 1 (solocatalogmeta)
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