fix(db): un spec no envejece con la investigación que lo originó

La retención iba por la edad de la SESIÓN y cascadeaba
`DELETE FROM outputs WHERE session_id = ?`, así que un output generado ayer
sobre una sesión de julio moría en el siguiente arranque del bot.

Pasó hoy, 2026-09-01, al desplegar 198b0e62:

    Startup purge done  sessions=12 outputs=27 chunks=2030 sources=3466
                        api_usage=1118 shorts=6

El pod llevaba 18 días sin reiniciarse, así que tres semanas de material
cumplieron los 30 días de golpe. Y lo que se llevó por delante no fue lo viejo:
los outputs 132-139 tenían 18,8 días —los tres Shorts re-renderizados el día
antes entre ellos— pero colgaban de las sesiones 161-165, de hace 40. Murieron
por la edad de su madre mientras 128-131, más antiguos, sobrevivían.

Ahora la purga va en dos fases:

1. Outputs por SU propia fecha, vivan en la sesión que vivan.
2. Sesiones viejas que ya no sostienen ningún output.

El orden importa: la sesión cuyos outputs eran todos viejos se queda sin
ninguno en la fase 1 y resulta purgable en la fase 2, así que el caso normal
—sesión vieja, material viejo— sigue limpiándose entero en UNA pasada. Hay un
test que lo fija, y es el único de los cuatro nuevos que pasa también con la
lógica anterior: está para probar que no se rompió lo que funcionaba.

Una sesión con un output vivo sobrevive entera, con sus sources y sus chunks.
No es generosidad: los chunks son contra lo que se comprueba el fundamento de
ese output, y conservar el spec tirando aquello con lo que se verifica deja
algo que ya no se puede auditar. El precio es que la retención afloja, y se
paga a sabiendas.

Dos cosas más que salieron al mirarlo:

- `_purge_on_startup` sólo escribía en el log `if result["sessions"] > 0`. Con
  la retención por output, una pasada puede borrar 27 outputs y CERO sesiones,
  y eso no habría dejado ni una línea. Una purga silenciosa es como se
  descubre tres semanas tarde. Ahora informa si borró cualquier cosa.
- El MP4 se llama por sesión, así que cuando la fase 1 se lleva el último
  short_en de una sesión que sigue viva, el fichero queda sin nada que lo
  nombre. Se borra ahí también, o el PVC acumula vídeos que no aparecen en
  ninguna fila.

`purge_old_sessions` pasa a llamarse `purge_old_data`: ya no purga sólo por
sesiones y el nombre viejo describía justo el defecto.

La BD anterior a la purga está a salvo y verificada en
~/rescates/researchowl-purga-2026-09-01 (integrity_check ok, 31 outputs, los
17 short_en). Con esta lógica, un restore conserva 16 de los 17.

Suite: 278 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-09-01 17:27:21 +00:00
co-authored by Claude Opus 5
parent 198b0e6238
commit a14e5b99f9
3 changed files with 200 additions and 27 deletions
+72 -22
View File
@@ -648,41 +648,75 @@ class ResearchDB:
# --- Maintenance ---
async def purge_old_sessions(self, max_age_days: int = 30) -> dict:
async def purge_old_data(self, max_age_days: int = 30) -> dict:
"""Retención en dos fases: primero los outputs por SU fecha, luego las
sesiones que ya no sostienen nada.
Antes iba todo por la edad de la sesión, y la cascada
`DELETE FROM outputs WHERE session_id = ?` se llevaba por delante lo
generado ayer si colgaba de una sesión de hace dos meses. El
2026-09-01 eso borró 27 outputs, entre ellos los tres Shorts
re-renderizados el día antes: sobrevivieron los outputs 128-131 y
murieron los 132-139, que eran **más nuevos**. Un spec no envejece con
la investigación que lo originó.
Las dos fases van en este orden por una razón: la sesión cuyos outputs
eran todos viejos se queda sin ninguno en la fase 1 y resulta purgable
en la fase 2, así que el caso normal —sesión vieja, material viejo—
sigue limpiándose igual que antes en una sola pasada.
**Y una sesión con un output vivo sobrevive entera**, con sus sources y
sus chunks. No es generosidad: los `chunks` son contra lo que se
comprueba el fundamento de ese output, así que conservar el spec y tirar
aquello con lo que se verifica deja algo que ya no se puede auditar. El
precio es que la retención afloja — una sesión de julio con un Short de
ayer mantiene vivos sus cientos de sources — y ese precio se paga a
sabiendas.
"""
await self.db.execute("PRAGMA foreign_keys = ON")
threshold = time.time() - max_age_days * 86400
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
"api_usage": 0, "shorts": 0}
# --- fase 1: outputs por su propia fecha, vivan donde vivan ---------
# Se apuntan las sesiones tocadas antes de borrar: si una se queda sin
# ningún short_en, su MP4 no lo referencia ya nadie.
cursor = await self.db.execute(
"SELECT id FROM research_sessions WHERE created_at < ? AND status != 'running'",
"SELECT DISTINCT session_id FROM outputs WHERE created_at < ?",
(threshold,)
)
touched = [row[0] for row in await cursor.fetchall()]
cur = await self.db.execute("DELETE FROM outputs WHERE created_at < ?",
(threshold,))
counts["outputs"] += cur.rowcount
for sid in touched:
cursor = await self.db.execute(
"SELECT 1 FROM outputs WHERE session_id = ? AND output_type = ?"
" LIMIT 1", (sid, "short_en")
)
if await cursor.fetchone() is None:
counts["shorts"] += self._drop_short(sid)
# --- fase 2: sesiones viejas que ya no sostienen ningún output ------
cursor = await self.db.execute(
"SELECT id FROM research_sessions WHERE created_at < ?"
" AND status != 'running'"
" AND NOT EXISTS (SELECT 1 FROM outputs WHERE session_id ="
" research_sessions.id)",
(threshold,)
)
session_ids = [row[0] for row in await cursor.fetchall()]
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
"api_usage": 0, "shorts": 0}
for sid in session_ids:
# El MP4 del Short vive en disco (los blobs en SQLite hacen
# patológico el WAL), así que su borrado no lo arrastra ninguna FK:
# se hace aquí, que es el único sitio que sabe qué sesiones
# desaparecen. Best-effort — un fichero que no se puede borrar no
# va a impedir purgar la sesión.
try:
video = Path(settings.shorts_dir) / f"{sid}.mp4"
if video.is_file():
video.unlink()
counts["shorts"] += 1
except OSError as e:
logger.warning("No se pudo borrar el Short de una sesión purgada",
session_id=sid, error=str(e))
counts["shorts"] += self._drop_short(sid)
await self.db.execute(
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
(sid,)
)
cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,))
counts["chunks"] += cur.rowcount
cur = await self.db.execute("DELETE FROM outputs WHERE session_id = ?", (sid,))
counts["outputs"] += cur.rowcount
cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,))
counts["api_usage"] += cur.rowcount
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
@@ -691,6 +725,22 @@ class ResearchDB:
counts["sessions"] += cur.rowcount
await self.db.commit()
logger.info("Purged sessions older than days",
sessions=counts["sessions"], days=max_age_days)
logger.info("Purga por antigüedad", days=max_age_days, **counts)
return counts
def _drop_short(self, sid: int) -> int:
"""Borra el MP4 de una sesión, si queda. Devuelve 1 si borró algo.
El vídeo vive en disco (los blobs en SQLite hacen patológico el WAL),
así que su borrado no lo arrastra ninguna FK y hay que hacerlo aquí.
Best-effort: un fichero que no se puede borrar no va a impedir la purga.
"""
try:
video = Path(settings.shorts_dir) / f"{sid}.mp4"
if video.is_file():
video.unlink()
return 1
except OSError as e:
logger.warning("No se pudo borrar el Short de una sesión purgada",
session_id=sid, error=str(e))
return 0