Products Manager APP

Technical

Caching

Caching

Products Manager implémente une stratégie de cache à 2 niveaux qui réduit la charge base de données de ~70% sur les lectures catalogue et maintient les temps de réponse sous les 20ms pour les ressources fréquemment accédées.


Vue d'Ensemble

[Requête API]
      |
      v
[L1 : In-Memory FastAPI] ── TTL 60-300s, par worker, invalidé au restart
      |
      v (Cache Miss)
[L2 : Redis 7] ── TTL configurable par ressource, partagé entre tous les workers
      |
      v (Cache Miss)
[PostgreSQL] ── 8 instances spécialisées

Le cache-aside pattern est utilisé systématiquement : l'application gère explicitement les lectures et écritures en cache. Il n'y a pas de write-through ni de read-through automatique au niveau de l'ORM — le cache est piloté par la couche service.


Cache L1 — FastAPI In-Memory

Données Concernées

Le cache L1 est réservé aux données quasi-statiques qui changent rarement et sont lues à chaque requête authentifiée :

  • Liste des modules actifs par tenant : détermine les fonctionnalités disponibles
  • Permissions RBAC : matrice rôle → permissions, consultée à chaque appel protégé
  • Settings applicatifs : configuration globale (limites d'upload, features flags, paramètres système)
  • Taxonomies et catégories : arbres de catégories, rarement modifiés

Implémentation

# Cache L1 : dict Python avec timestamp de création
_l1_cache: dict[str, tuple[Any, float]] = {}

def get_l1(key: str, ttl: int = 300) -> Any | None:
    if key in _l1_cache:
        value, created_at = _l1_cache[key]
        if time.time() - created_at < ttl:
            return value
        del _l1_cache[key]
    return None

def set_l1(key: str, value: Any) -> None:
    _l1_cache[key] = (value, time.time())

def invalidate_l1(key: str) -> None:
    _l1_cache.pop(key, None)

Caractéristiques

  • TTL : 60s (settings dynamiques) à 300s (modules actifs, RBAC)
  • Taille non bornée par défaut (données statiques, volume limité)
  • Isolé par worker Uvicorn : 8 workers = 8 caches L1 indépendants
  • Invalidé automatiquement au restart du worker
  • Pas de synchronisation inter-workers (acceptable pour les données quasi-statiques)

Cohérence Inter-Workers

Une modification de permissions ou de settings peut mettre jusqu'à 300s à se propager sur tous les workers L1. Pour les changements critiques (révocation d'accès), l'invalidation Redis L2 est suffisante — le L1 sera rafraîchi à son prochain TTL.


Cache L2 — Redis 7

Configuration Redis

# Redis 7 — instance dédiée
maxmemory: 512mb
maxmemory-policy: allkeys-lru   # Éviction LRU quand mémoire pleine
bind: 0.0.0.0
save: ""                         # Pas de persistance (cache pur)
appendonly: no

Redis est utilisé à la fois comme broker Celery et comme cache L2. Les namespaces de clés sont préfixés pour éviter les collisions :

  • cache:* — données applicatives (produits, listes, stats)
  • celery-task-meta-* — résultats de tâches Celery
  • blacklist:* — tokens JWT révoqués
  • session:* — sessions utilisateurs actives

TTL par Type de Ressource

RessourceClé RedisTTLJustification
Produit individuelcache:product:{id}5 minDonnées fréquemment lues, peu modifiées
Liste produits paginéecache:products:{tenant}:{page}:{filters_hash}2 minInvalidée rapidement après import
Stats dashboardcache:dashboard:{tenant}:stats10 minAgrégations coûteuses, tolérance décalage
Settings tenantcache:tenant:{id}:settings30 minTrès rarement modifiés
Modules actifscache:tenant:{id}:modules30 minChangement exceptionnel
Permissions RBACcache:user:{id}:permissions15 minÉquilibre sécurité/performance
Catégoriescache:categories:tree1 heureQuasi-statique
Marquescache:brands:list1 heureQuasi-statique
Mapping EAN/ASINcache:code2asin:{ean}24hDonnées Amazon stables
Token JWT révoquéblacklist:jwt:{jti}Durée du tokenSécurité (voir /docs/technical/security)
Résultat tâche Celerycelery-task-meta-{task_id}24hPolling statut jobs longue durée
Rate limit APIratelimit:{user_id}:{endpoint}60sFenêtre glissante

Pattern Cache-Aside

class ProductService:
    async def get_product(
        self,
        db: AsyncSession,
        product_id: UUID,
        tenant_id: UUID,
    ) -> Product:
        # 1. Vérifier L1 (in-memory worker)
        l1_key = f"product:{product_id}"
        cached_l1 = get_l1(l1_key, ttl=60)
        if cached_l1:
            return cached_l1

        # 2. Vérifier L2 (Redis)
        l2_key = f"cache:product:{product_id}"
        cached_l2 = await redis.get(l2_key)
        if cached_l2:
            product = deserialize(cached_l2)
            set_l1(l1_key, product)  # Promouvoir en L1
            return product

        # 3. Requête base de données
        product = await db.get(Product, product_id)
        if product is None:
            raise ProductNotFound(product_id)

        # 4. Stocker en L2 puis L1
        await redis.setex(l2_key, 300, serialize(product))
        set_l1(l1_key, product)

        return product

Cache Warming

Au démarrage de chaque worker FastAPI, un hook startup précalcule les données les plus demandées pour éliminer les cold starts :

@app.on_event("startup")
async def warm_cache():
    if not settings.CACHE_WARMING_ENABLED:
        return

    # 1. Modules actifs pour tous les tenants
    tenants = await tenant_service.get_all_active()
    for tenant in tenants:
        modules = await module_service.get_active_modules(tenant.id)
        await redis.setex(
            f"cache:tenant:{tenant.id}:modules",
            1800,
            serialize(modules)
        )

    # 2. Permissions RBAC
    roles_with_permissions = await rbac_service.get_all_roles_with_permissions()
    await redis.setex("cache:rbac:matrix", 900, serialize(roles_with_permissions))

    # 3. Settings globaux
    settings_data = await settings_service.get_all()
    await redis.setex("cache:app:settings", 1800, serialize(settings_data))

    # 4. Taxonomies (catégories, marques)
    categories = await category_service.get_tree()
    await redis.setex("cache:categories:tree", 3600, serialize(categories))

    logger.info("Cache warming completed — %d tenants primed", len(tenants))

Durée typique du warming : 2-5 secondes selon le nombre de tenants.


Stratégies d'Invalidation

Invalidation par Tag

Chaque ressource est associée à des tags pour permettre l'invalidation groupée :

# Exemple : après mise à jour d'un produit
async def invalidate_product_cache(product_id: UUID, tenant_id: UUID):
    keys_to_delete = [
        f"cache:product:{product_id}",
        f"cache:products:{tenant_id}:*",  # Toutes les listes paginées du tenant
        f"cache:dashboard:{tenant_id}:stats",
    ]
    # Scan + delete pour les patterns avec wildcard
    await redis_invalidate_pattern(f"cache:products:{tenant_id}:*")
    await redis.delete(f"cache:product:{product_id}")
    await redis.delete(f"cache:dashboard:{tenant_id}:stats")
    invalidate_l1(f"product:{product_id}")

Bulk Invalidation après Import

Un import massif invalide tout le cache catalogue du tenant concerné :

@celery_app.task(queue="imports")
async def on_import_completed(job_id: str, tenant_id: str):
    # Invalider toutes les listes produits et stats du tenant
    await redis_invalidate_pattern(f"cache:products:{tenant_id}:*")
    await redis.delete(f"cache:dashboard:{tenant_id}:stats")
    await redis.delete(f"cache:tenant:{tenant_id}:product_count")

    # Déclencher réindexation Meilisearch
    await trigger_search_reindex.delay(tenant_id)

Invalidation TTL-Based

Pour les ressources à faible criticité de cohérence (stats, counts), on accepte un délai d'invalidation naturel via le TTL plutôt qu'une invalidation explicite. Cela évite les problèmes de cache stampede sur les données analytiques.


Meilisearch comme Cache de Recherche

L'index Meilisearch est traité comme un cache de recherche dédié, pas comme une source de vérité. PostgreSQL reste la source canonique.

  • Mise à jour asynchrone via Celery (queue catalog) après chaque mutation
  • Délai d'indexation typique : 1-3 secondes après la mutation
  • En cas d'incohérence, une réindexation complète peut être déclenchée manuellement depuis l'interface admin
@celery_app.task(queue="catalog", rate_limit="100/m")
async def update_search_index(product_ids: list[str]):
    products = await product_service.get_for_indexing(product_ids)
    documents = [build_search_document(p) for p in products]
    await meili_client.index("products").update_documents(documents)

Monitoring

Métriques Prometheus

# Hit/miss ratio (calculé côté application)
redis_cache_hits_total{resource="product"}
redis_cache_misses_total{resource="product"}

# Mémoire Redis
redis_memory_used_bytes
redis_memory_max_bytes

# Connexions Redis
redis_connected_clients
redis_blocked_clients

Redis INFO

# Statistiques live
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits:1847392
# keyspace_misses:234891
# Hit ratio ~88.7%

redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human"
# used_memory_human:187.43M
# maxmemory_human:512.00M

Hit ratio cible en production : >85%. Un ratio inférieur à 70% indique un problème de dimensionnement TTL ou une invalidation trop agressive.


Variables d'Environnement

VariableDéfautDescription
REDIS_URLredis://localhost:6379/0URL de connexion Redis
CACHE_TTL_DEFAULT300TTL par défaut en secondes
CACHE_WARMING_ENABLEDtrueActiver le cache warming au démarrage
CACHE_L1_ENABLEDtrueActiver le cache in-memory L1
CACHE_L1_TTL60TTL du cache L1 (secondes)
CACHE_PRODUCT_TTL300TTL des produits individuels
CACHE_LIST_TTL120TTL des listes paginées
CACHE_DASHBOARD_TTL600TTL des stats dashboard
CACHE_SETTINGS_TTL1800TTL des settings tenant
REDIS_MAX_CONNECTIONS50Pool de connexions Redis

Ressources

Redis comme Broker et Cache

Redis remplit deux rôles distincts dans Products Manager : broker de messages pour Celery (queues de tâches) et cache L2 applicatif. Les namespaces de clés sont strictement séparés pour éviter toute collision.