perf(dav): multiget en 1 request + cache en disco por ctag (de ~9s a ~1s)

Reemplaza el patron N+1 (PROPFIND + un GET por cada .vcf/.ics, paralelizado con
ThreadPoolExecutor pero ~9s para 1064 contactos) por UNA llamada a la funcion
del registry dav_get_collection (REPORT addressbook-query / calendar-query que
trae todo el contenido inline). Anade cache en disco (.cache/contacts.json y
calendar.json) validada por el ctag de la coleccion (dav_collection_ctag): al
arrancar, si el ctag no cambio, sirve del disco sin tocar la red. POST
/api/refresh fuerza recarga (ignora el ctag). _force_reload distingue refresh
forzado de validacion normal.

Cambios en /api/contacts y /api/calendar: el N+1 (_fetch_resources +
ThreadPoolExecutor) se sustituye por _load_collection (ctag -> disco o REPORT).
El parseo vCard/iCal y el shape JSON no cambian; los items cacheados en disco
preservan el shape completo (osint, nombre, telefonos). Tests actualizados:
fake_dav mockea dav_get_collection + dav_collection_ctag con cache en tmpdir;
nuevos tests de disk-cache-hit en proceso nuevo y recarga al cambiar ctag.

Medido contra Xandikos real: contacts 1064 cold 1.15s / warm 6ms / disco 0.5s;
calendar 98 cold 0.29s. Registry-first: la logica DAV nueva vive en el grupo dav
(dav_get_collection_py_infra + dav_collection_ctag_py_infra), no inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:08:02 +02:00
parent 881a1b9716
commit 4ac8f33318
3 changed files with 284 additions and 114 deletions
+68 -28
View File
@@ -241,7 +241,12 @@ def test_dav_endpoints_degrade_without_network(client, monkeypatch):
Y los endpoints del vault siguen funcionando offline (no se ven afectados).
"""
monkeypatch.setattr(
srv, "dav_list_resources", lambda *a, **k: {"status": "error", "error": "sin red"}
srv,
"dav_get_collection",
lambda *a, **k: {"status": "error", "error": "sin red"},
)
monkeypatch.setattr(
srv, "dav_collection_ctag", lambda *a, **k: {"status": "error", "error": "sin red"}
)
# Evita leer pass en el test (cachea una password ficticia).
client.app.state.vault._xandikos_password = "x"
@@ -325,39 +330,41 @@ _ICS_BODY_2 = (
@pytest.fixture()
def fake_dav(monkeypatch):
def fake_dav(monkeypatch, tmp_path):
"""Parchea las funciones del registry DAV con fixtures en memoria (sin red).
Devuelve un dict ``{"calls": int}`` que cuenta los PROPFIND para verificar
el cacheo (segunda lectura no re-llama a Xandikos).
Mockea ``dav_get_collection`` (UN REPORT que trae todos los recursos con su
contenido inline) y ``dav_collection_ctag`` (token de versión para la caché
en disco). Redirige la caché en disco a un tmpdir para no escribir en
``server/.cache``. Devuelve un dict con ``{"reports": int, "ctag": str}``:
``reports`` cuenta las descargas reales (REPORT) para verificar el cacheo, y
``ctag`` es mutable para simular un cambio en la colección.
"""
state = {"calls": 0}
state = {"reports": 0, "ctag": "ctag-v1"}
contacts_res = [
{"href": "/enmanuel/contacts/addressbook/maria-001.vcf", "etag": '"a"'},
{"href": "/enmanuel/contacts/addressbook/juan-002.vcf", "etag": '"b"'},
{"href": "/enmanuel/contacts/addressbook/maria-001.vcf", "etag": '"a"', "data": _VCF_BODY},
{"href": "/enmanuel/contacts/addressbook/juan-002.vcf", "etag": '"b"', "data": _VCF_BODY_2},
]
calendar_res = [
{"href": "/enmanuel/calendars/calendar/evt-001.ics", "etag": '"c"'},
{"href": "/enmanuel/calendars/calendar/evt-002.ics", "etag": '"d"'},
{"href": "/enmanuel/calendars/calendar/evt-001.ics", "etag": '"c"', "data": _ICS_BODY},
{"href": "/enmanuel/calendars/calendar/evt-002.ics", "etag": '"d"', "data": _ICS_BODY_2},
]
bodies = {
"/enmanuel/contacts/addressbook/maria-001.vcf": _VCF_BODY,
"/enmanuel/contacts/addressbook/juan-002.vcf": _VCF_BODY_2,
"/enmanuel/calendars/calendar/evt-001.ics": _ICS_BODY,
"/enmanuel/calendars/calendar/evt-002.ics": _ICS_BODY_2,
}
def _list(base, user, pw, collection, **kw):
state["calls"] += 1
def _get_collection(base, user, pw, collection, content_type="vcard", **kw):
state["reports"] += 1
res = contacts_res if "contacts" in collection else calendar_res
return {"status": "ok", "http_status": 207, "resources": res}
def _get(base, user, pw, href, **kw):
return {"status": "ok", "http_status": 200, "text": bodies.get(href, "")}
def _ctag(base, user, pw, collection, **kw):
return {"status": "ok", "http_status": 207, "ctag": state["ctag"]}
monkeypatch.setattr(srv, "dav_list_resources", _list)
monkeypatch.setattr(srv, "dav_get_resource", _get)
monkeypatch.setattr(srv, "dav_get_collection", _get_collection)
monkeypatch.setattr(srv, "dav_collection_ctag", _ctag)
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
# Caché en disco aislada por test (no toca server/.cache).
cache_dir = tmp_path / "dav_cache"
monkeypatch.setattr(srv, "_CONTACTS_CACHE_FILE", str(cache_dir / "contacts.json"))
monkeypatch.setattr(srv, "_CALENDAR_CACHE_FILE", str(cache_dir / "calendar.json"))
return state
@@ -372,10 +379,10 @@ def test_contacts_endpoint_parsea_y_cachea(client, fake_dav):
assert maria["alias"] == "Mari"
assert maria["telefonos"] == ["+34600111222"]
assert maria["osint"] == {"dni": "12345678Z", "pais": "España"}
# Segunda llamada NO re-hace PROPFIND (sirve de la caché en memoria).
calls_after_first = fake_dav["calls"]
# Segunda llamada NO re-descarga (sirve de la caché en memoria).
reports_after_first = fake_dav["reports"]
client.get("/api/contacts")
assert fake_dav["calls"] == calls_after_first
assert fake_dav["reports"] == reports_after_first
def test_contact_by_uid_desde_cache(client, fake_dav):
@@ -396,10 +403,43 @@ def test_calendar_endpoint_rango_y_cache(client, fake_dav):
def test_refresh_invalida_cache_dav(client, fake_dav):
client.get("/api/contacts") # llena caché
calls_before = fake_dav["calls"]
client.post("/api/refresh") # invalida
client.get("/api/contacts") # vuelve a hacer PROPFIND
assert fake_dav["calls"] > calls_before
reports_before = fake_dav["reports"]
client.post("/api/refresh") # invalida + fuerza recarga
client.get("/api/contacts") # vuelve a descargar (REPORT)
assert fake_dav["reports"] > reports_before
def test_disk_cache_evita_descarga_en_proceso_nuevo(vault, fake_dav):
"""Un proceso nuevo con la caché en disco y el mismo ctag NO descarga.
Simula el reinicio del server: primer cliente descarga (1 REPORT) y escribe
la caché en disco; un segundo cliente (caché en memoria vacía) con el mismo
ctag sirve del disco sin un nuevo REPORT. Esto es el arranque instantáneo.
"""
c1 = TestClient(srv.create_app(vault))
assert c1.get("/api/contacts").json()["count"] == 2
reports_after_first = fake_dav["reports"]
assert reports_after_first >= 1 # hubo descarga al no haber disco aún
# Proceso "nuevo": estado en memoria vacío, pero la caché en disco existe y
# el ctag no cambió → debe servir del disco sin descargar.
c2 = TestClient(srv.create_app(vault))
data = c2.get("/api/contacts").json()
assert data["count"] == 2
assert {x["uid"] for x in data["contacts"]} == {"maria-001", "juan-002"}
assert fake_dav["reports"] == reports_after_first # CERO descargas nuevas
def test_disk_cache_recarga_si_cambia_ctag(vault, fake_dav):
"""Si el ctag de la colección cambia, el proceso nuevo SÍ vuelve a descargar."""
c1 = TestClient(srv.create_app(vault))
c1.get("/api/contacts")
reports_after_first = fake_dav["reports"]
fake_dav["ctag"] = "ctag-v2" # la colección cambió
c2 = TestClient(srv.create_app(vault))
c2.get("/api/contacts")
assert fake_dav["reports"] > reports_after_first # re-descargó
# ---------------------------------------------------------------------------