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
+213 -86
View File
@@ -41,18 +41,14 @@ from __future__ import annotations
import argparse
import importlib.util
import json
import os
import re
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
import time
from typing import Optional
# Nº de descargas DAV concurrentes al traer una colección completa (addressbook
# con ~1000 vCards). Secuencial son ~0.11s/recurso (~2 min para 1064); con un
# pool acotado baja a ~10s. Acotado para no saturar al servidor Xandikos.
_DAV_FETCH_WORKERS = 16
def _registry_functions_dir() -> str:
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
@@ -134,8 +130,12 @@ def _load_infra_fn(module_name: str, attr: str):
# --- Grupo de capacidad dav (CardDAV / CalDAV contra Xandikos) + pass ---
dav_list_resources = _load_infra_fn("dav_list_resources", "dav_list_resources")
dav_get_resource = _load_infra_fn("dav_get_resource", "dav_get_resource")
# dav_get_collection trae TODOS los recursos (vCards / VCALENDARs) de una
# colección en UNA petición REPORT con el contenido inline; dav_collection_ctag
# lee el ctag de la colección (PROPFIND Depth:0 barato) para validar la caché en
# disco sin descargar nada cuando nada cambió.
dav_get_collection = _load_infra_fn("dav_get_collection", "dav_get_collection")
dav_collection_ctag = _load_infra_fn("dav_collection_ctag", "dav_collection_ctag")
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
@@ -150,6 +150,15 @@ XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# Caché en disco de los datos DAV ya parseados, indexada por el ctag de la
# colección. Al arrancar el server lee el ctag (PROPFIND barato ~0.1s) y, si
# coincide con el de la caché en disco, sirve los contactos/eventos ya parseados
# sin descargar ni reparsear nada (arranque instantáneo). El directorio vive
# junto al server y está gitignored (datos personales sensibles + regenerable).
_CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache")
_CONTACTS_CACHE_FILE = os.path.join(_CACHE_DIR, "contacts.json")
_CALENDAR_CACHE_FILE = os.path.join(_CACHE_DIR, "calendar.json")
# Extensiones de imagen que el frontend muestra en la galería con lightbox.
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
@@ -194,6 +203,49 @@ def _read_pass_secret(entry: str) -> str:
return value.strip()
# ---------------------------------------------------------------------------
# Caché en disco de los datos DAV ya parseados (indexada por ctag)
# ---------------------------------------------------------------------------
def _read_disk_cache(path: str) -> Optional[dict]:
"""Lee una caché DAV del disco: ``{"ctag": str, "items": list}`` o None.
Devuelve None (recargar de la red) ante cualquier problema: archivo
inexistente, JSON corrupto, o estructura inesperada. Nunca lanza: la caché
es un acelerador, no una fuente de verdad — si falla, se cae a la descarga.
"""
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
return None
return data
def _write_disk_cache(path: str, ctag: str, items: list) -> None:
"""Escribe la caché DAV al disco de forma atómica (tmp + rename).
Persiste ``{"ctag": ctag, "items": items, "saved_at": epoch}``. Errores de
escritura se ignoran (no deben tumbar el endpoint): la caché en memoria sigue
sirviendo y el disco se reintentará en el siguiente refresco.
"""
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(
{"ctag": ctag, "items": items, "saved_at": time.time()},
fh,
ensure_ascii=False,
)
os.replace(tmp, path)
except OSError:
pass
# ---------------------------------------------------------------------------
# Estado del servidor: caché del vault + password Xandikos
# ---------------------------------------------------------------------------
@@ -226,9 +278,15 @@ class VaultState:
self._xandikos_password: Optional[str] = None
# Cachés DAV en memoria (igual que el grafo): se llenan perezosamente al
# primer acceso y se invalidan en POST /api/refresh. None = sin cargar.
# Cada caché lleva su ctag para servir del disco sin red cuando la
# colección no cambió. _force_reload (set por /api/refresh) salta la
# validación de ctag en el siguiente acceso.
self._dav_lock = threading.Lock()
self._contacts_cache: Optional[list] = None
self._calendar_cache: Optional[list] = None
self._contacts_ctag: Optional[str] = None
self._calendar_ctag: Optional[str] = None
self._force_reload = False
self.refresh()
# --- vault --------------------------------------------------------------
@@ -411,50 +469,148 @@ class VaultState:
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
return self._xandikos_password
def contacts(self) -> list:
"""Contactos del addressbook Xandikos, parseados y cacheados en memoria.
def _collection_ctag(self, collection_path: str, password: str) -> Optional[str]:
"""Lee el ctag de una colección (PROPFIND barato), o None si no se puede.
Llena la caché al primer acceso (descarga + parseo de todos los
``.vcf``); accesos posteriores la reutilizan hasta ``invalidate_dav()``.
El ctag es el token de versión de la colección: si no cambió, la caché en
disco sigue vigente. Devolver None significa "no pude validar" → se
recarga de la red por seguridad (nunca se sirve caché potencialmente
obsoleta sin confirmación). No lanza.
"""
res = dav_collection_ctag(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, collection_path
)
return res.get("ctag") if res.get("status") == "ok" else None
def _load_collection(
self,
collection_path: str,
content_type: str,
cache_file: str,
parse_items,
) -> tuple:
"""Carga una colección DAV con caché en disco validada por ctag.
Flujo (1 o 2 peticiones, nunca N):
1. Lee el ctag de la colección (PROPFIND Depth:0, ~0.1s).
2. Si el ctag coincide con el de la caché en disco (y no hay refresh
forzado), parsea los items del disco y devuelve sin descargar.
3. Si no, hace UN REPORT ``dav_get_collection`` que trae todos los
recursos con su contenido inline, los parsea con ``parse_items`` y
reescribe la caché en disco con el nuevo ctag.
``parse_items(resources) -> list`` transforma la lista
``[{href, etag, data}]`` del registry en la lista de objetos JSON que
sirve el endpoint (contactos o eventos).
Returns:
tuple ``(items, ctag)``.
Raises:
DavUnavailable: si Xandikos no responde al REPORT cuando hay que
descargar (sin red, timeout, auth).
"""
password = self.xandikos_password()
ctag = self._collection_ctag(collection_path, password)
# Caché en disco vigente: mismo ctag y sin refresh forzado → sin red.
if ctag is not None and not self._force_reload:
disk = _read_disk_cache(cache_file)
if disk is not None and disk.get("ctag") == ctag:
return parse_items(disk["items"]), ctag
# Hay que (re)descargar: UN REPORT trae todo con el contenido inline.
got = dav_get_collection(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
collection_path,
content_type,
)
if got.get("status") != "ok":
raise DavUnavailable("Xandikos no responde: %s" % got.get("error"))
resources = got.get("resources", [])
items = parse_items(resources)
# Persistir en disco para el arranque instantáneo de la próxima vez. Si
# no obtuvimos ctag, guardamos cadena vacía: nunca matcheará un ctag real,
# así que la próxima vez se revalidará (cae con elegancia a "siempre
# recargar" sin romper).
_write_disk_cache(cache_file, ctag or "", items)
return items, (ctag or "")
@staticmethod
def _parse_contacts(items: list) -> list:
"""Parsea ``[{href, etag, data}]`` (o items cacheados) a contactos JSON.
Acepta dos formas de ``items``: la lista de recursos del registry (cada
uno con ``data`` = texto vCard, posible multi-tarjeta) que hay que
parsear, o la lista de contactos ya parseados (caché en disco), que se
devuelve tal cual. Se distinguen por la presencia de la clave ``data``.
"""
if items and "data" in items[0]:
contacts: list = []
for res in items:
href = res.get("href")
for card_text in split_vcards(res.get("data", "")):
card = _vcard_to_json(card_text)
card["etag"] = res.get("etag")
card["href"] = href
contacts.append(card)
contacts.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
return contacts
return list(items)
@staticmethod
def _parse_events(items: list) -> list:
"""Parsea ``[{href, etag, data}]`` (o items cacheados) a eventos JSON.
Igual criterio que ``_parse_contacts``: si los items llevan ``data`` son
recursos del registry (texto VCALENDAR) y se parsean; si no, ya son
eventos cacheados y se devuelven tal cual.
"""
if items and "data" in items[0]:
events: list = []
for res in items:
href = res.get("href")
for event in _vcalendar_to_events(res.get("data", "")):
event["etag"] = res.get("etag")
event["href"] = href
events.append(event)
events.sort(key=lambda e: e.get("dtstart") or "")
return events
return list(items)
def contacts(self) -> list:
"""Contactos del addressbook Xandikos, parseados y cacheados.
Caché en dos niveles: memoria (mientras vive el proceso) y disco
(``.cache/contacts.json``, validada por ctag para arranque instantáneo).
Al primer acceso descarga TODO en UNA petición REPORT
(``dav_get_collection``) en vez de un GET por ``.vcf``.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
"""
with self._dav_lock:
if self._contacts_cache is not None:
if self._contacts_cache is not None and not self._force_reload:
return self._contacts_cache
password = self.xandikos_password()
listing = dav_list_resources(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
contacts, ctag = self._load_collection(
XANDIKOS_CONTACTS_COLLECTION,
"vcard",
_CONTACTS_CACHE_FILE,
self._parse_contacts,
)
if listing.get("status") != "ok":
raise DavUnavailable(
"Xandikos no responde: %s" % listing.get("error")
)
contacts: list = []
# Descarga concurrente de los .vcf: secuencial son ~0.11s/recurso
# (~2 min para 1064 contactos); con el pool acotado baja a ~10s.
for res, got in self._fetch_resources(
listing.get("resources", []), ".vcf", password
):
href = res.get("href")
for card_text in split_vcards(got.get("text", "")):
card = _vcard_to_json(card_text)
card["etag"] = res.get("etag")
card["href"] = href
contacts.append(card)
contacts.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
self._contacts_cache = contacts
self._contacts_ctag = ctag
self._maybe_clear_force_reload()
return contacts
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
"""Eventos del calendario Xandikos, cacheados; filtrados por rango.
La descarga + parseo completos se cachean; el filtro por ``[from, to]``
Misma caché en dos niveles que ``contacts``. La descarga + parseo
completos se cachean (UNA petición REPORT); el filtro por ``[from, to]``
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to``
devuelve todos.
@@ -463,71 +619,42 @@ class VaultState:
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
"""
with self._dav_lock:
if self._calendar_cache is None:
password = self.xandikos_password()
listing = dav_list_resources(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
if self._calendar_cache is None or self._force_reload:
events, ctag = self._load_collection(
XANDIKOS_CALENDAR_COLLECTION,
"ical",
_CALENDAR_CACHE_FILE,
self._parse_events,
)
if listing.get("status") != "ok":
raise DavUnavailable(
"Xandikos no responde: %s" % listing.get("error")
)
events: list = []
for res, got in self._fetch_resources(
listing.get("resources", []), ".ics", password
):
href = res.get("href")
for event in _vcalendar_to_events(got.get("text", "")):
event["etag"] = res.get("etag")
event["href"] = href
events.append(event)
events.sort(key=lambda e: e.get("dtstart") or "")
self._calendar_cache = events
self._calendar_ctag = ctag
self._maybe_clear_force_reload()
all_events = self._calendar_cache
if not dt_from and not dt_to:
return list(all_events)
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
def _fetch_resources(self, resources: list, suffix: str, password: str) -> list:
"""Descarga en paralelo los recursos DAV con la extensión ``suffix``.
def _maybe_clear_force_reload(self) -> None:
"""Apaga el flag de refresh forzado una vez consumido por una recarga.
Filtra los recursos por extensión (``.vcf`` / ``.ics``), los descarga con
``dav_get_resource`` (función del registry) usando un pool acotado de
hilos (``_DAV_FETCH_WORKERS``) y devuelve la lista de pares
``(res, got)`` de los que respondieron ``status == "ok"``, preservando el
orden del listing. La paralelización es solo de la orquestación: la
descarga sigue delegada a la función del registry, que es stdlib y
thread-safe (abre su propia conexión por request). Acotar el pool evita
saturar al servidor Xandikos.
Llamado bajo ``_dav_lock`` tras recargar una colección. El flag lo activa
``invalidate_dav`` (POST /api/refresh) para forzar UNA recarga que ignore
el ctag; tras ella vuelve a la validación normal por ctag.
"""
targets = [
res
for res in resources
if res.get("href") and res["href"].lower().endswith(suffix)
]
if not targets:
return []
def _get(res):
got = dav_get_resource(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, res["href"]
)
return res, got
workers = min(_DAV_FETCH_WORKERS, len(targets))
with ThreadPoolExecutor(max_workers=workers) as pool:
# pool.map preserva el orden de entrada.
results = list(pool.map(_get, targets))
return [(res, got) for res, got in results if got.get("status") == "ok"]
self._force_reload = False
def invalidate_dav(self) -> None:
"""Vacía las cachés de contactos y calendario (no la password)."""
"""Vacía las cachés de contactos y calendario y fuerza una recarga.
Limpia las cachés en memoria y marca ``_force_reload`` para que el
siguiente acceso a cada colección ignore el ctag cacheado y vuelva a
descargar del servidor (el botón "refrescar" debe traer cambios aunque el
ctag no se haya actualizado todavía). No borra la password.
"""
with self._dav_lock:
self._contacts_cache = None
self._calendar_cache = None
self._force_reload = True
# ---------------------------------------------------------------------------