Skip to content

feat(api): historial persistente y GET /history con paginación (#102) - #104

Merged
g-garciac2022 merged 3 commits into
devfrom
feat/102-historial-persistencia
Aug 14, 2026
Merged

feat(api): historial persistente y GET /history con paginación (#102)#104
g-garciac2022 merged 3 commits into
devfrom
feat/102-historial-persistencia

Conversation

@g-garciac2022

Copy link
Copy Markdown
Collaborator

#102 · Historial: persistir análisis y exponer GET /history con paginación

Closes #102

Hasta aquí la API era sin estado: cada petición se atendía con lo que traía
dentro y no dejaba rastro, así que reiniciar el proceso no perdía nada porque no
había nada que perder. R9 rompe eso —el usuario tiene que poder volver a ver un
análisis de ayer— y con ello aparece la primera escritura a disco del backend.

Qué incluye

  • backend/api/history.py (nuevo): el almacén, SQLite detrás de tres
    funciones (record, query y el gestor de conexión). Lo que protege de un
    cambio de requisitos no es elegir el almacén más flexible sino aislarlo:
    cambiar de motor es reescribir este fichero sin tocar endpoints ni sus tests.
  • backend/api/app.py: registro tras /analyze y tras
    /tools/{name}/execute, y la ruta GET /history. Una petición rechazada por
    404 o 422 no se registra: no es una ejecución, y ensuciaría el historial
    con intentos fallidos del formulario.
  • backend/api/schemas.py: HistoryEntry, HistoryPage y los enums
    HistoryKind / Origin, que viven aquí con el resto del contrato para que el
    conjunto cerrado de valores salga publicado en OpenAPI.
  • backend/config/settings.py y .gitignore: history_db apunta a
    var/ y no a data/. data/ está versionado —datasets y splits congelados,
    que no deben cambiar nunca— y esto es estado que cambia en cada petición;
    juntarlos acaba en un git add data/ que commitea la base de datos.
  • tests/api/test_history.py (22 tests) y el aislamiento en
    tests/conftest.py.
  • spikes/bench_historial_*.py: los dos bancos de prueba que sostienen las
    cifras de abajo, con sus resultados y conclusiones en la cabecera.

La decisión de fondo: se guardan análisis, no invocaciones

Al pie de la letra, «registrar cada ejecución de herramienta» haría que un solo
POST /analyze dejara cinco filas, una por señal, y la pantalla resultante
sería una lista de detect_clickbait_lexical repetido sin el titular por ningún
sitio. Esa granularidad ya existe donde sirve: log_tool_invocation registra
cada invocación con parámetros y duración. Son dos registros con dos públicos.

Se guarda la respuesta completa, no sólo el veredicto: reejecutar al abrir la
entrada costaría ~20 s en frío y las señales remotas no son deterministas, así
que el «resultado anterior» podría salir distinto. Un historial que cambia lo que
dice no es un historial.

Lo que salió de medir en vez de suponer

Una revisión externa señaló cinco puntos de rendimiento. Medirlos descartó tres,
corrigió uno y destapó otro que no estaba en la lista.

Operación Coste
Path.mkdir(parents=True, exist_ok=True) sobre directorio existente 7,2 µs
CREATE TABLE IF NOT EXISTS sobre tabla existente 9,9 µs
_conectar() + INSERT + commit 193 792 µs

Ejecutar el esquema y el mkdir en cada conexión cuesta el 0,005 % y el
0,004 % de lo que envuelven: se quedan, porque hacen que el sistema funcione
recién clonado el repo sin ningún paso de instalación, y cachearlos en una
variable de módulo rompería los tests con tmp_path. El timeout que se
proponía añadir ya existía: sqlite3.connect lo trae en 5 s por defecto, medido
en 5,01 s antes de database is locked.

La conexión sin cerrar sí era real, aunque no por el motivo que se le
atribuía. En sqlite3, el with de una conexión gestiona la transacción y no
la cierra:

Escrituras Descriptores tras la ráfaga Tras gc.collect()
2 000 +96 +0
20 000 +163 +0
20 000 con close() +0

No es la fuga lineal que llevaría a Too many open files, pero el atasco crece y
gc.collect() lo devuelve siempre a cero: son conexiones esperando al recolector
de ciclos. El código era correcto por accidente, apoyado en un detalle de
CPython que PyPy no comparte. _conectar pasa a gestor de contexto propio que
cierra en un finally, confirmando antes de cerrar — al revés se perdería la
escritura, porque cerrar con una transacción pendiente la deshace.

WAL: la recomendación de manual, descartada por medición

journal_mode synchronous ms/escritura
DELETE (por defecto) FULL 164
WAL FULL 226
WAL NORMAL 251
WAL OFF 0,47

Al cerrar la última conexión a una base en modo WAL, SQLite ejecuta un
checkpoint completo; con «una conexión por operación» eso ocurre en cada
escritura. WAL rinde cuando las conexiones se mantienen abiertas, que es justo lo
que este diseño no hace: son dos decisiones acopladas, y quedarse con media de
cada una es peor que con cualquiera entera.

Los 0,47 ms de synchronous=OFF prueban que esos ~164 ms son todo fsync.
No se toca: un historial que se pierde al cortarse la luz no es un historial.

Un fallo que sólo apareció mirando var/

Añadir el registro a /analyze convirtió, sin avisar, todos los tests de esa
ruta en escritores del historial real: una corrida de la suite dejaba cuatro
entradas «Un titular» en var/history.db. El aislamiento va en un fixture
autouse de tests/conftest.py y no en el fichero que prueba el historial,
porque quien contamina no es quien lo prueba: lo hace cualquier test que llame a
un endpoint que registre, incluidos los que aún no existen.

Notas

  • Los filtros y la retención son [api] Historial: filtros y retención (R9.4, R9.5) #103. Sin retención la tabla crece sin
    límite; las columnas tool, verdict y status ya existen para no tener que
    migrar cuando llegue.
  • origin es hoy siempre api: form y chat están declarados porque el
    prototipo los distingue y añadir la columna después obligaría a migrar, pero no
    hay quien los emita hasta que existan la SPA y el agente.
  • status y verdict van como cadena y no como enum, a propósito: son datos
    leídos de disco que pudo escribir otra versión del código, y un enum sobre eso
    hace que el día que cambie un valor las filas antiguas dejen de validar y
    GET /history devuelva un 500 por una entrada de hace meses. El significado de
    status además difiere entre tipos —en un análisis es «alguna señal funcionó»,
    en una herramienta es «no falló»— y se replantea en [api] Historial: filtros y retención (R9.4, R9.5) #103.
  • Los 164 ms de fsync se midieron sobre el disco virtual de WSL2, donde
    atraviesa hasta el anfitrión Windows; en Linux nativo son décimas de ms. Queda
    apuntado para volver a medirlo al contenerizar (H4) y decidir entonces si la
    escritura debe salir de la ruta de respuesta.
  • HistoryEntry(**fila) sigue asumiendo que las claves del almacén casan con
    los campos del contrato.
    El SELECT nombra las columnas para que la salida
    del módulo sea una declaración deliberada y no el reflejo de la tabla, pero
    cerrar el acoplamiento del todo exigiría construir el modelo campo a campo. La
    red hoy está en los tests, no en el código.

feat(bench): add benchmarks for database connection and write performance

feat(bench): measure impact of connection management on SQLite performance
@g-garciac2022
g-garciac2022 merged commit 3910d0e into dev Aug 14, 2026
1 check passed
@g-garciac2022
g-garciac2022 deleted the feat/102-historial-persistencia branch August 14, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[api] Historial: persistir análisis y exponer GET /history con paginación

1 participant