morphos / backend /tests /test_cache_papers.py
Jose Salazar
IP real detrás del proxy y caché de papers atómica y acotada
5118fc3
Raw
History Blame Contribute Delete
3.62 kB
"""Caché en disco de /api/papers: atómica, tolerante a basura y acotada.
Tres fallos que tenía: `write_text` no es atómico (dos fallos de caché simultáneos sobre la
misma consulta se entrelazaban y un lector veía JSON truncado → 500 sin `try`), el TTL sólo se
miraba al LEER (una consulta que no se repite dejaba su fichero para siempre) y el directorio
en `gettempdir()` se reutilizaba aunque lo hubiera creado otro usuario del host.
"""
from __future__ import annotations
import json
import os
import time
from app.routers import papers
def _limpiar():
if papers._DIR_CACHE.exists():
for f in papers._DIR_CACHE.glob("*"):
f.unlink(missing_ok=True)
def test_ida_y_vuelta():
_limpiar()
papers._escribir_cache("pm:anemia", {"resultados": [1, 2, 3]})
assert papers._leer_cache("pm:anemia") == {"resultados": [1, 2, 3]}
def test_fichero_truncado_es_fallo_de_cache_no_un_500():
"""El caso real: `json.loads` sobre un fichero a medio escribir tumbaba la petición."""
_limpiar()
papers._escribir_cache("pm:x", {"a": 1})
papers._ruta("pm:x").write_text('{"a": ', encoding="utf-8") # JSON cortado
assert papers._leer_cache("pm:x") is None
# Y se borra: si no, se releería basura hasta que caduque.
assert not papers._ruta("pm:x").exists()
def test_entrada_caducada_no_se_sirve():
_limpiar()
papers._escribir_cache("pm:viejo", {"a": 1})
viejo = time.time() - papers._TTL_S - 10
os.utime(papers._ruta("pm:viejo"), (viejo, viejo))
assert papers._leer_cache("pm:viejo") is None
def test_la_poda_borra_lo_caducado():
"""El TTL se comprobaba sólo al leer, así que el directorio crecía sin tope."""
_limpiar()
for i in range(5):
papers._escribir_cache(f"pm:{i}", {"i": i})
viejo = time.time() - papers._TTL_S - 10
for i in range(3):
os.utime(papers._ruta(f"pm:{i}"), (viejo, viejo))
papers._podar_cache()
vivas = list(papers._DIR_CACHE.glob("*.json"))
assert len(vivas) == 2
def test_la_escritura_no_deja_temporales():
"""`os.replace` mueve el temporal; si quedara, el directorio crecería igual."""
_limpiar()
papers._escribir_cache("pm:limpio", {"a": 1})
assert list(papers._DIR_CACHE.glob("*.tmp")) == []
assert len(list(papers._DIR_CACHE.glob("*.json"))) == 1
def test_un_lector_nunca_ve_un_json_a_medias(monkeypatch):
"""Con escritura atómica, mientras se escribe la nueva entrada se sigue leyendo la vieja.
Se simula la ventana de entrelazado: durante `json.dump` del segundo escritor, un lector
consulta la misma clave. Sin `os.replace` vería el fichero destino truncado.
"""
_limpiar()
papers._escribir_cache("pm:carrera", {"version": "vieja"})
leido_durante_la_escritura = {}
volcado_real = json.dump
def _volcado_que_lee_a_la_vez(datos, fh, **kw):
volcado_real(datos, fh, **kw)
fh.flush()
leido_durante_la_escritura["valor"] = papers._leer_cache("pm:carrera")
monkeypatch.setattr(papers.json, "dump", _volcado_que_lee_a_la_vez)
papers._escribir_cache("pm:carrera", {"version": "nueva"})
assert leido_durante_la_escritura["valor"] == {"version": "vieja"}
assert papers._leer_cache("pm:carrera") == {"version": "nueva"}
def test_el_directorio_es_privado_y_propio():
"""0700 y con el uid en el nombre: `exist_ok=True` sobre un directorio ajeno lo habría usado."""
_limpiar()
papers._escribir_cache("pm:permisos", {"a": 1})
assert str(os.getuid()) in papers._DIR_CACHE.name
assert (papers._DIR_CACHE.stat().st_mode & 0o777) == 0o700