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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user