perf(webui): stop re-redacting large strings on every response
`_redact_fn_cached` skipped memoization for any string over 16KB, so those strings re-ran the full ~15-pass redactor on every single request. On a real 22MB session that is 59 large strings (29 unique, 0.76MB) costing 1.68s per request — 99.9% of the recurring redaction cost once the small-string cache is warm — repaid by every tab and every poll for a byte-identical result. Large strings now get their own bounded cache, separate from the 4096-entry small-string one so a large blob can never evict the thousands of small recurring strings. Entry count and per-entry size are both capped; anything above the ceiling stays uncached, keeping retained bytes bounded. `_redact_value` additionally stops rebuilding containers whose descendants were unchanged. Most of a transcript carries no credential marker, so the unconditional dict/list comprehension deep-copied the whole payload to reproduce an identical structure — pure CPU under the GIL, which serializes concurrent tab loads on a threaded server. Callers treat redacted output as read-only (`_public_message_projection` and `redact_session_data` both build fresh containers), so sharing an unmodified subtree cannot leak a later mutation back into session state. As soon as one string is masked, every container on the path to it is rebuilt and the caller's original is untouched. Measured on the live 22.6MB session, steady state: redact_session_data 3.78s -> 1.96s single tab (-48%), 7.93s -> 4.06s with two concurrent tabs (-49%), byte-identical output.
This commit is contained in:
1 parent
2cf8e8a5ea
commit
6f05ff9657
3 files changed
+273
-3
No files matched your search
@@ -0,0 +1,114 @@
|
||||
"""Grosses chaines: memoiser au lieu de repayer ~15 regex a chaque requete.
|
||||
|
||||
``_redact_fn_cached`` excluait du LRU toute chaine > 16 384 caracteres, pour
|
||||
eviter d'evincer les milliers de petites chaines recurrentes et de gonfler la
|
||||
RSS. Consequence mesuree sur une session reelle de 22 Mo: 59 grosses chaines
|
||||
(29 uniques, 0,76 Mo) repassaient l'integralite des regex a CHAQUE requete,
|
||||
pour un resultat toujours identique — 1,68 s par requete, soit 99,9% du cout
|
||||
recurrent de la redaction une fois le cache des petites chaines chaud.
|
||||
|
||||
Le correctif ajoute un SECOND cache, dedie aux grosses chaines, borne en
|
||||
nombre d'entrees ET par une taille maximale par entree, pour que la RSS reste
|
||||
plafonnee. Les chaines geantes (> plafond) restent volontairement non cachees.
|
||||
|
||||
Contrat verifie ici:
|
||||
1. une grosse chaine est redigee exactement comme avant;
|
||||
2. la meme grosse chaine n'est pas recalculee au second appel;
|
||||
3. les deux caches restent separes (pas d'eviction croisee);
|
||||
4. une chaine au-dela du plafond n'est jamais retenue.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from api import helpers # noqa: E402
|
||||
from api.helpers import ( # noqa: E402
|
||||
_REDACT_CACHE_MAX_TEXT_LEN,
|
||||
_REDACT_LARGE_CACHE_MAX_TEXT_LEN,
|
||||
_redact_fn_cached,
|
||||
_redact_fn_uncached,
|
||||
)
|
||||
|
||||
SECRET = "ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_large_cache():
|
||||
"""Ne pas laisser ce fichier polluer l'etat global du process.
|
||||
|
||||
Le cache est un singleton de module partage par toute la suite. Le vider
|
||||
sans le restaurer laisse les tests suivants demarrer a froid, ce qui
|
||||
perturbe ceux qui dependent de fenetres de fraicheur ou de temps de
|
||||
reponse. On repart d'un cache vide ET on le revide en sortie, pour que ce
|
||||
fichier n'ait aucun effet observable en dehors de lui-meme.
|
||||
"""
|
||||
helpers._redact_fn_large_lru.cache_clear()
|
||||
yield
|
||||
helpers._redact_fn_large_lru.cache_clear()
|
||||
|
||||
|
||||
def _big(secret: str, size: int) -> str:
|
||||
"""Chaine > seuil du petit cache, porteuse d'un secret.
|
||||
|
||||
Le remplissage doit etre assez long pour atteindre ``size`` meme au-dela
|
||||
du plafond du grand cache, sinon la troncature produit une chaine trop
|
||||
courte et le test ne verifie plus ce qu'il annonce.
|
||||
"""
|
||||
unit = "lorem ipsum dolor sit amet "
|
||||
filler = unit * (size // len(unit) + 1)
|
||||
text = (filler + secret + filler)[:size]
|
||||
assert len(text) == size
|
||||
return text
|
||||
|
||||
|
||||
def test_large_string_redaction_matches_uncached():
|
||||
text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 5000)
|
||||
assert len(text) > _REDACT_CACHE_MAX_TEXT_LEN
|
||||
assert _redact_fn_cached(text) == _redact_fn_uncached(text)
|
||||
assert SECRET not in _redact_fn_cached(text)
|
||||
|
||||
|
||||
def test_large_string_is_memoized():
|
||||
helpers._redact_fn_large_lru.cache_clear()
|
||||
text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 7000)
|
||||
|
||||
first = _redact_fn_cached(text)
|
||||
misses_after_first = helpers._redact_fn_large_lru.cache_info().misses
|
||||
|
||||
second = _redact_fn_cached(text)
|
||||
info = helpers._redact_fn_large_lru.cache_info()
|
||||
|
||||
assert second == first
|
||||
assert info.hits >= 1, "grosse chaine recalculee au second appel"
|
||||
assert info.misses == misses_after_first, "miss supplementaire inattendu"
|
||||
|
||||
|
||||
def test_small_strings_do_not_use_the_large_cache():
|
||||
helpers._redact_fn_large_lru.cache_clear()
|
||||
small = f"petit texte {SECRET}"
|
||||
assert len(small) <= _REDACT_CACHE_MAX_TEXT_LEN
|
||||
_redact_fn_cached(small)
|
||||
info = helpers._redact_fn_large_lru.cache_info()
|
||||
assert info.hits == 0 and info.misses == 0, "petite chaine routee vers le grand cache"
|
||||
|
||||
|
||||
def test_giant_string_is_not_retained():
|
||||
"""Au-dela du plafond: correct, mais jamais mis en cache (RSS bornee)."""
|
||||
helpers._redact_fn_large_lru.cache_clear()
|
||||
giant = _big(SECRET, _REDACT_LARGE_CACHE_MAX_TEXT_LEN + 10000)
|
||||
assert len(giant) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN
|
||||
|
||||
out = _redact_fn_cached(giant)
|
||||
|
||||
assert out == _redact_fn_uncached(giant)
|
||||
assert SECRET not in out
|
||||
info = helpers._redact_fn_large_lru.cache_info()
|
||||
assert info.currsize == 0, "chaine geante retenue en cache"
|
||||
|
||||
|
||||
def test_large_cache_is_bounded():
|
||||
assert helpers._redact_fn_large_lru.cache_info().maxsize is not None
|
||||
assert helpers._redact_fn_large_lru.cache_info().maxsize <= 128
|
||||
Reference in new issue
Block a user