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
+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))
await conn.commit()
counts = await ResearchDB(conn).purge_old_sessions(30)
counts = await ResearchDB(conn).purge_old_data(30)
await conn.close()
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"
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
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