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:
+68
-28
@@ -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ó
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user