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() db_conn = await get_db()
try: try:
db = ResearchDB(db_conn) db = ResearchDB(db_conn)
result = await db.purge_old_sessions(30) result = await db.purge_old_data(30)
if result["sessions"] > 0: # 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) logger.info("Startup purge done", **result)
except Exception as e: except Exception as e:
logger.warning("Startup purge failed — bot continues", error=str(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 return
if days == 0 and not (len(args) >= 2 and args[1] == "confirm"): if days == 0 and not (len(args) >= 2 and args[1] == "confirm"):
await update.message.reply_text( 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.", "Envía `/purge 0 confirm` para confirmar.",
parse_mode=ParseMode.MARKDOWN parse_mode=ParseMode.MARKDOWN
) )
@@ -1609,7 +1614,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
db_conn = await get_db() db_conn = await get_db()
try: try:
db = ResearchDB(db_conn) db = ResearchDB(db_conn)
result = await db.purge_old_sessions(days) result = await db.purge_old_data(days)
await update.message.reply_text( await update.message.reply_text(
f"🗑️ Purged: {result['sessions']} sessions, " f"🗑️ Purged: {result['sessions']} sessions, "
f"{result['sources']} sources, " f"{result['sources']} sources, "
+72 -22
View File
@@ -648,41 +648,75 @@ class ResearchDB:
# --- Maintenance --- # --- 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") await self.db.execute("PRAGMA foreign_keys = ON")
threshold = time.time() - max_age_days * 86400 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( 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,) (threshold,)
) )
session_ids = [row[0] for row in await cursor.fetchall()] 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: for sid in session_ids:
# El MP4 del Short vive en disco (los blobs en SQLite hacen counts["shorts"] += self._drop_short(sid)
# 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))
await self.db.execute( await self.db.execute(
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)", "DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
(sid,) (sid,)
) )
cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,))
counts["chunks"] += cur.rowcount 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,)) cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,))
counts["api_usage"] += cur.rowcount counts["api_usage"] += cur.rowcount
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
@@ -691,6 +725,22 @@ class ResearchDB:
counts["sessions"] += cur.rowcount counts["sessions"] += cur.rowcount
await self.db.commit() await self.db.commit()
logger.info("Purged sessions older than days", logger.info("Purga por antigüedad", days=max_age_days, **counts)
sessions=counts["sessions"], days=max_age_days)
return 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
+119 -1
View File
@@ -331,7 +331,7 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
" created_at, updated_at) VALUES (2,'nuevo','saturated',1,?,?)", (now, now)) " created_at, updated_at) VALUES (2,'nuevo','saturated',1,?,?)", (now, now))
await conn.commit() await conn.commit()
counts = await ResearchDB(conn).purge_old_sessions(30) counts = await ResearchDB(conn).purge_old_data(30)
await conn.close() await conn.close()
assert counts["shorts"] == 1 assert counts["shorts"] == 1
@@ -339,6 +339,124 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo" assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo"
async def purge_fixture(tmp_path, monkeypatch, sessions, outputs, days=30):
"""Una BD con sesiones y outputs de las edades que se le pidan, purgada.
`sessions` y `outputs` llevan la edad en días: positiva es pasado. Devuelve
(counts, short_en supervivientes, ids de sesión supervivientes).
"""
import time
import aiosqlite
from src.db import database
from src.db.database import ResearchDB
shorts = tmp_path / "shorts"
shorts.mkdir()
monkeypatch.setattr(settings, "shorts_dir", str(shorts))
conn = await aiosqlite.connect(tmp_path / "p.db")
conn.row_factory = aiosqlite.Row
await conn.executescript(database.SCHEMA)
now = time.time()
for sid, age in sessions:
t = now - age * 86400
await conn.execute(
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
" created_at, updated_at) VALUES (?,?, 'saturated', 1, ?, ?)",
(sid, f"s{sid}", t, t))
(shorts / f"{sid}.mp4").write_bytes(b"x")
# Un source por sesión, para ver si la cascada la alcanza o no.
await conn.execute(
"INSERT INTO sources (session_id, url, title, scraped_at)"
" VALUES (?,?,?,?)", (sid, f"http://x/{sid}", "t", t))
for oid, sid, age in outputs:
await conn.execute(
"INSERT INTO outputs (id, session_id, output_type, content, created_at)"
" VALUES (?,?, 'short_en', '{}', ?)", (oid, sid, now - age * 86400))
await conn.commit()
try:
counts = await ResearchDB(conn).purge_old_data(days)
vivos = [r[0] for r in await (await conn.execute(
"SELECT id FROM outputs ORDER BY id")).fetchall()]
sesiones = [r[0] for r in await (await conn.execute(
"SELECT id FROM research_sessions ORDER BY id")).fetchall()]
finally:
# Sin esto, un fallo dentro del `try` deja el hilo de aiosqlite vivo y
# pytest no termina NUNCA: el error se presenta como un cuelgue, que es
# la forma más cara de leer un fallo.
await conn.close()
return counts, vivos, sesiones, shorts
@pytest.mark.asyncio
async def test_a_new_output_on_an_old_session_survives(tmp_path, monkeypatch):
"""El fallo del 2026-09-01, exacto. La cascada iba por la edad de la SESIÓN,
así que los outputs 132-139 generados el día antes sobre casos viejos se
borraron mientras 128-131, más antiguos, sobrevivían. Un spec no envejece
con la investigación que lo originó.
"""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 90)], # sesión de hace tres meses
outputs=[(139, 1, 1)]) # con un spec de ayer
assert vivos == [139], "el spec de ayer murió con su sesión"
assert sesiones == [1], "la sesión tiene que sobrevivir o la FK se rompe"
assert counts["outputs"] == 0
assert (shorts / "1.mp4").exists(), "y su vídeo con ella"
@pytest.mark.asyncio
async def test_an_old_output_dies_even_on_a_live_session(tmp_path, monkeypatch):
"""La otra mitad, y sin ella lo de arriba no es retención por fecha de
output: es no purgar outputs nunca."""
counts, vivos, sesiones, _ = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 2)], # sesión de anteayer
outputs=[(1, 1, 90), (2, 1, 2)]) # un spec viejo y uno reciente
assert vivos == [2]
assert counts["outputs"] == 1
assert sesiones == [1], "la sesión reciente no se toca"
@pytest.mark.asyncio
async def test_the_ordinary_case_still_cleans_up_whole(tmp_path, monkeypatch):
"""Sesión vieja con material viejo: se limpia entera en UNA pasada. La fase 1
la deja sin outputs y la fase 2 se la lleva si el orden se invirtiera, la
sesión sobreviviría a su propio material hasta el arranque siguiente."""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 90), (2, 2)],
outputs=[(1, 1, 90), (2, 2, 2)])
assert vivos == [2] and sesiones == [2]
assert counts["sessions"] == 1 and counts["outputs"] == 1
assert counts["sources"] == 1, "la cascada alcanza a la sesión purgada"
assert not (shorts / "1.mp4").exists() and (shorts / "2.mp4").exists()
@pytest.mark.asyncio
async def test_a_session_that_loses_its_last_short_loses_its_video(tmp_path,
monkeypatch):
"""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. Sin esto el PVC acumula vídeos que ya no aparecen en ninguna fila."""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 2)], # la sesión sigue viva
outputs=[(1, 1, 90)]) # pero su único short se va
assert vivos == [] and sesiones == [1]
assert counts["shorts"] == 1
assert not (shorts / "1.mp4").exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path): async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path):
"""Subir un Short escribe su URL de YouTube en `published_url`. Si """Subir un Short escribe su URL de YouTube en `published_url`. Si