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
+9 -4
View File
@@ -1346,8 +1346,12 @@ async def _purge_on_startup(app: Application) -> None:
db_conn = await get_db()
try:
db = ResearchDB(db_conn)
result = await db.purge_old_sessions(30)
if result["sessions"] > 0:
result = await db.purge_old_data(30)
# Cualquier borrado, no sólo el de sesiones. Con la retención por fecha
# de output, una pasada puede llevarse 27 outputs y CERO sesiones — y
# con la condición anterior eso no dejaba ni una línea de log. Una purga
# silenciosa es como se descubre tres semanas tarde.
if any(result.values()):
logger.info("Startup purge done", **result)
except Exception as e:
logger.warning("Startup purge failed — bot continues", error=str(e))
@@ -1600,7 +1604,8 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
if days == 0 and not (len(args) >= 2 and args[1] == "confirm"):
await update.message.reply_text(
"⚠️ Esto borrará *todas* las sesiones completadas.\n"
"⚠️ Esto borrará *todos* los outputs y *todas* las sesiones "
"completadas.\n"
"Envía `/purge 0 confirm` para confirmar.",
parse_mode=ParseMode.MARKDOWN
)
@@ -1609,7 +1614,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
db_conn = await get_db()
try:
db = ResearchDB(db_conn)
result = await db.purge_old_sessions(days)
result = await db.purge_old_data(days)
await update.message.reply_text(
f"🗑️ Purged: {result['sessions']} sessions, "
f"{result['sources']} sources, "
+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