merge: perf(dav) multiget + cache en disco por ctag
This commit is contained in:
@@ -6,3 +6,6 @@ server.log
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
local_files/
|
local_files/
|
||||||
|
|
||||||
|
# Caché en disco de los datos DAV (datos personales sensibles + regenerable)
|
||||||
|
server/.cache/
|
||||||
|
|||||||
+213
-86
@@ -41,18 +41,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
import time
|
||||||
from typing import Optional
|
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:
|
def _registry_functions_dir() -> str:
|
||||||
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
|
"""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 ---
|
# --- Grupo de capacidad dav (CardDAV / CalDAV contra Xandikos) + pass ---
|
||||||
dav_list_resources = _load_infra_fn("dav_list_resources", "dav_list_resources")
|
# dav_get_collection trae TODOS los recursos (vCards / VCALENDARs) de una
|
||||||
dav_get_resource = _load_infra_fn("dav_get_resource", "dav_get_resource")
|
# 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")
|
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
|
||||||
pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
|
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_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
||||||
XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
|
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.
|
# Extensiones de imagen que el frontend muestra en la galería con lightbox.
|
||||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
||||||
|
|
||||||
@@ -194,6 +203,49 @@ def _read_pass_secret(entry: str) -> str:
|
|||||||
return value.strip()
|
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
|
# Estado del servidor: caché del vault + password Xandikos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -226,9 +278,15 @@ class VaultState:
|
|||||||
self._xandikos_password: Optional[str] = None
|
self._xandikos_password: Optional[str] = None
|
||||||
# Cachés DAV en memoria (igual que el grafo): se llenan perezosamente al
|
# 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.
|
# 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._dav_lock = threading.Lock()
|
||||||
self._contacts_cache: Optional[list] = None
|
self._contacts_cache: Optional[list] = None
|
||||||
self._calendar_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()
|
self.refresh()
|
||||||
|
|
||||||
# --- vault --------------------------------------------------------------
|
# --- vault --------------------------------------------------------------
|
||||||
@@ -411,50 +469,148 @@ class VaultState:
|
|||||||
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
|
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
|
||||||
return self._xandikos_password
|
return self._xandikos_password
|
||||||
|
|
||||||
def contacts(self) -> list:
|
def _collection_ctag(self, collection_path: str, password: str) -> Optional[str]:
|
||||||
"""Contactos del addressbook Xandikos, parseados y cacheados en memoria.
|
"""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
|
El ctag es el token de versión de la colección: si no cambió, la caché en
|
||||||
``.vcf``); accesos posteriores la reutilizan hasta ``invalidate_dav()``.
|
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:
|
Raises:
|
||||||
RuntimeError: si no se puede leer la password de ``pass``.
|
RuntimeError: si no se puede leer la password de ``pass``.
|
||||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||||
"""
|
"""
|
||||||
with self._dav_lock:
|
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
|
return self._contacts_cache
|
||||||
password = self.xandikos_password()
|
contacts, ctag = self._load_collection(
|
||||||
listing = dav_list_resources(
|
|
||||||
XANDIKOS_BASE_URL,
|
|
||||||
XANDIKOS_USERNAME,
|
|
||||||
password,
|
|
||||||
XANDIKOS_CONTACTS_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_cache = contacts
|
||||||
|
self._contacts_ctag = ctag
|
||||||
|
self._maybe_clear_force_reload()
|
||||||
return contacts
|
return contacts
|
||||||
|
|
||||||
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
|
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
|
||||||
"""Eventos del calendario Xandikos, cacheados; filtrados por rango.
|
"""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``
|
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to``
|
||||||
devuelve todos.
|
devuelve todos.
|
||||||
|
|
||||||
@@ -463,71 +619,42 @@ class VaultState:
|
|||||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||||
"""
|
"""
|
||||||
with self._dav_lock:
|
with self._dav_lock:
|
||||||
if self._calendar_cache is None:
|
if self._calendar_cache is None or self._force_reload:
|
||||||
password = self.xandikos_password()
|
events, ctag = self._load_collection(
|
||||||
listing = dav_list_resources(
|
|
||||||
XANDIKOS_BASE_URL,
|
|
||||||
XANDIKOS_USERNAME,
|
|
||||||
password,
|
|
||||||
XANDIKOS_CALENDAR_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_cache = events
|
||||||
|
self._calendar_ctag = ctag
|
||||||
|
self._maybe_clear_force_reload()
|
||||||
all_events = self._calendar_cache
|
all_events = self._calendar_cache
|
||||||
if not dt_from and not dt_to:
|
if not dt_from and not dt_to:
|
||||||
return list(all_events)
|
return list(all_events)
|
||||||
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
|
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:
|
def _maybe_clear_force_reload(self) -> None:
|
||||||
"""Descarga en paralelo los recursos DAV con la extensión ``suffix``.
|
"""Apaga el flag de refresh forzado una vez consumido por una recarga.
|
||||||
|
|
||||||
Filtra los recursos por extensión (``.vcf`` / ``.ics``), los descarga con
|
Llamado bajo ``_dav_lock`` tras recargar una colección. El flag lo activa
|
||||||
``dav_get_resource`` (función del registry) usando un pool acotado de
|
``invalidate_dav`` (POST /api/refresh) para forzar UNA recarga que ignore
|
||||||
hilos (``_DAV_FETCH_WORKERS``) y devuelve la lista de pares
|
el ctag; tras ella vuelve a la validación normal por ctag.
|
||||||
``(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.
|
|
||||||
"""
|
"""
|
||||||
targets = [
|
self._force_reload = False
|
||||||
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"]
|
|
||||||
|
|
||||||
def invalidate_dav(self) -> None:
|
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:
|
with self._dav_lock:
|
||||||
self._contacts_cache = None
|
self._contacts_cache = None
|
||||||
self._calendar_cache = None
|
self._calendar_cache = None
|
||||||
|
self._force_reload = True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+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).
|
Y los endpoints del vault siguen funcionando offline (no se ven afectados).
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(
|
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).
|
# Evita leer pass en el test (cachea una password ficticia).
|
||||||
client.app.state.vault._xandikos_password = "x"
|
client.app.state.vault._xandikos_password = "x"
|
||||||
@@ -325,39 +330,41 @@ _ICS_BODY_2 = (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def fake_dav(monkeypatch):
|
def fake_dav(monkeypatch, tmp_path):
|
||||||
"""Parchea las funciones del registry DAV con fixtures en memoria (sin red).
|
"""Parchea las funciones del registry DAV con fixtures en memoria (sin red).
|
||||||
|
|
||||||
Devuelve un dict ``{"calls": int}`` que cuenta los PROPFIND para verificar
|
Mockea ``dav_get_collection`` (UN REPORT que trae todos los recursos con su
|
||||||
el cacheo (segunda lectura no re-llama a Xandikos).
|
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 = [
|
contacts_res = [
|
||||||
{"href": "/enmanuel/contacts/addressbook/maria-001.vcf", "etag": '"a"'},
|
{"href": "/enmanuel/contacts/addressbook/maria-001.vcf", "etag": '"a"', "data": _VCF_BODY},
|
||||||
{"href": "/enmanuel/contacts/addressbook/juan-002.vcf", "etag": '"b"'},
|
{"href": "/enmanuel/contacts/addressbook/juan-002.vcf", "etag": '"b"', "data": _VCF_BODY_2},
|
||||||
]
|
]
|
||||||
calendar_res = [
|
calendar_res = [
|
||||||
{"href": "/enmanuel/calendars/calendar/evt-001.ics", "etag": '"c"'},
|
{"href": "/enmanuel/calendars/calendar/evt-001.ics", "etag": '"c"', "data": _ICS_BODY},
|
||||||
{"href": "/enmanuel/calendars/calendar/evt-002.ics", "etag": '"d"'},
|
{"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):
|
def _get_collection(base, user, pw, collection, content_type="vcard", **kw):
|
||||||
state["calls"] += 1
|
state["reports"] += 1
|
||||||
res = contacts_res if "contacts" in collection else calendar_res
|
res = contacts_res if "contacts" in collection else calendar_res
|
||||||
return {"status": "ok", "http_status": 207, "resources": res}
|
return {"status": "ok", "http_status": 207, "resources": res}
|
||||||
|
|
||||||
def _get(base, user, pw, href, **kw):
|
def _ctag(base, user, pw, collection, **kw):
|
||||||
return {"status": "ok", "http_status": 200, "text": bodies.get(href, "")}
|
return {"status": "ok", "http_status": 207, "ctag": state["ctag"]}
|
||||||
|
|
||||||
monkeypatch.setattr(srv, "dav_list_resources", _list)
|
monkeypatch.setattr(srv, "dav_get_collection", _get_collection)
|
||||||
monkeypatch.setattr(srv, "dav_get_resource", _get)
|
monkeypatch.setattr(srv, "dav_collection_ctag", _ctag)
|
||||||
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
|
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
|
return state
|
||||||
|
|
||||||
|
|
||||||
@@ -372,10 +379,10 @@ def test_contacts_endpoint_parsea_y_cachea(client, fake_dav):
|
|||||||
assert maria["alias"] == "Mari"
|
assert maria["alias"] == "Mari"
|
||||||
assert maria["telefonos"] == ["+34600111222"]
|
assert maria["telefonos"] == ["+34600111222"]
|
||||||
assert maria["osint"] == {"dni": "12345678Z", "pais": "España"}
|
assert maria["osint"] == {"dni": "12345678Z", "pais": "España"}
|
||||||
# Segunda llamada NO re-hace PROPFIND (sirve de la caché en memoria).
|
# Segunda llamada NO re-descarga (sirve de la caché en memoria).
|
||||||
calls_after_first = fake_dav["calls"]
|
reports_after_first = fake_dav["reports"]
|
||||||
client.get("/api/contacts")
|
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):
|
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):
|
def test_refresh_invalida_cache_dav(client, fake_dav):
|
||||||
client.get("/api/contacts") # llena caché
|
client.get("/api/contacts") # llena caché
|
||||||
calls_before = fake_dav["calls"]
|
reports_before = fake_dav["reports"]
|
||||||
client.post("/api/refresh") # invalida
|
client.post("/api/refresh") # invalida + fuerza recarga
|
||||||
client.get("/api/contacts") # vuelve a hacer PROPFIND
|
client.get("/api/contacts") # vuelve a descargar (REPORT)
|
||||||
assert fake_dav["calls"] > calls_before
|
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