Redis cache clé / valeur ou comment éviter l'anti-pattern Singleton dans un contexte Web réparti
Tags: redis, cache, strategy, nosql
Published: 2024-05-15 13:21:31

# Sommaire
  * [Préambule](#intro)
  * [Pourquoi le problème est apparu ?](#problem)
  * [Solution technique](#solution)

---


## Préambule TL;DR <a name="intro"></a>

💥 Dernièrement, lors d'un développement, on a eu un bug bloquant, dû à une variable qui devait préciser la stratégie à adopter post-connexion de l'utilisateur.

📌 Si le connecté est de type A alors utiliser la stratégie A (consultation BDD), sinon prendre la stratégie P (consultation API) car il est de type P. 

**Schéma du déroulé :** 

> 1- connexion ➙ stratégie à adopter, stocker dans une variable type_strategie ➙ obtention des informations selon la stratégie

> N- une fois connecté ➙ lecture de la stratégie via la variable ➙ les informations varient (un Ctrl-F5 ou un retour sur la page)

On reconnait ici une variante du [pattern _Strategy_](https://refactoring.guru/design-patterns/strategy), où la variable (calculée à l'authentification) qui en précise le type était en...global, autrement dit, un _singleton_, et cela a causé des **effets de bord** pour tous les connectés : pour le même connecté, la page affichant des données issus de la stratégie...changeait, parfois aller chercher les informations de la stratégie A et d'autres la stratégie B voire pire, le dernier connecté changeait la stratégie pour tous les autres, "incompréhensible" ! 🥵

### Que se passe-t-il Houston ? <a name="problem"></a>

➢ Dans un contexte Web multi-nœuds, chaque serveur Web a son espace mémoire, indépendant des autres. Le stockage d'une valeur dans une variable le sera uniquement sur le nœud / serveur Web atteint. A chaque requête Web, celle-ci peut changer de nœuds, selon la répartition de charge.

➢ Nous utilisons _Gunicorn_ qui est un serveur Web WSGI, cela permet d'exécuter un serveur d'applications python comme Django. _Gunicorn_ est architecturé en plusieurs _workers_ (ou noeuds), chaque worker a son espace mémoire isolé. Dans un contexte de production, on pourrait également avoir _Gunicorn_ réparti sur plusieurs serveurs VM / noeuds : les requêtes Web sont non seulement réparties sur les différents serveurs, mais aussi réparties au sein de _Gunicorn_, on ne sait pas par avance où ira la requête (sur quel noeud et sur quel worker), pour schématiser ce type d'architecture ops  :

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hkhxc8icdzngulbenz11.png)

source : https://excalidraw.com/#json=CXDUttmskxjwScBjEr35x,BavDLiX5xlisHZB5viyrPQ

💣 ⚠ **Il y a autant de versions différentes de VAR qu'il existe de workers / noeuds.**

💣 ⚠ Corolaire : Cette variable VAR est **unique et partagée** (singleton) à tous les connectés et non selon leur profil propre, l'effet "le dernier qui dit qui a raison" s'appliquera.

Un utilisateur ira par exemple sur le noeud 1 puis sur le worker 2, et la prochaine fois, peut être sur le noeud 2 puis sur le worker 1, ainsi de suite : la stratégie à adopter pouvait donc changer selon le noeud / worker sur lequel la requête tombait 🎭 l'état de la variable n'est pas conservée.

### Solution élégante <a name="solution"></a>

1️⃣ La 1ère solution venant en tête est d'utiliser les variables de session pour stocker le type de stratégie propre au connecté. 

`session['strategy'] = A`

...sauf que cette idée était difficile à mettre en place car non triviale dans le code et cette variable de type de stratégie est utilisé côté frontoffice mais aussi côté backoffice.

2️⃣ C'est là que Redis vient à la rescousse ! Redis est déjà utilisé comme message broker / PubSub, [cet article décrit son usage dans un contexte de lancement de tâches différées](https://dev.to/zorky/celery-redis-execution-de-taches-en-differe-asynchrones-hhj). Redis permet aussi de l'utiliser en base NoSql clé / valeur pour du cache par exemple, autant prendre cette possibilité. Django fournit un _provider_ par défaut pour Redis.

Que cela soit côté Frontoffice ou Backoffice, on stocke la stratégie dans une clé Redis. La clé est ici l'identifiant unique du connecté.

Le scénario se déroule maintenant de la façon suivante :

1- connexion ➙ stratégie à adopter, stocker dans une clé(uid_connecté) Redis  ➙ obtention des informations selon la stratégie
N- une fois connecté ➙ lecture de la stratégie via la clé(uid_connecté) Redis ➙ la bonne stratégie est prise en compte à chaque fois, les informations sont bonnes à chaque requête

La grande différence réside à ce que VAR est maintenant partagée par tous les noeuds / workers, sa clé d'accès est un identifiant unique du connecté

**Schématiquement :**

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cccm3arsqjqdxg6p1igq.png)

source : https://excalidraw.com/#json=tuOIxkk9k1D3Zu8qVNyvD,JPrfOfaUnFnzCQVngYOvjQ 


### Technique

Configuration pour le _provider_ de cache Redis, du settings Django

```python
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": REDIS_CACHE # par ex. redis://api-redis-db:6379
    }
}
```

0️⃣ un énuméré est utilisé pour le type de stratégie

```python
class StrategyType(IntEnum):    
    BDD = 1
    API= 2
``` 

1️⃣ à la connexion, stockage : 

```python
def _set_cache_strategy(uid_connected, strategy: StrategyType):
    """
    pas de timeout 
    sinon par défaut 300 s https://docs.djangoproject.com/fr/5.0/ref/settings/#std-setting-CACHES-TIMEOUT)
    """
    cache.set(uid_connected, strategy, timeout=None)
``` 

2️⃣ pour les autres requêtes, on lit la stratégie :

```python
def _get_cache_strategy(uid_connected):
    return cache.get(uid_connected)
```

Simple et efficace, plus de problème !