Les sessions persistantes dans Forge (forge-mvc-sessions-db)¶
forge-mvc-sessions-db fournit DbSessionStore, un store de session adossé à la base de données (table forge_sessions).
Le cœur de Forge, agnostique du SGBD, ne fournit qu'un store mémoire et un store fichier ; ce paquet ajoute le store BDD, partagé entre processus et persistant.
1. Rôle du module
Une session Forge conserve l'état d'un visiteur entre deux requêtes (jeton CSRF, utilisateur authentifié, messages flash).
Le store par défaut du cœur (MemorySessionStore) garde ces données en mémoire du processus : elles disparaissent au redémarrage et ne sont pas partagées entre workers.
DbSessionStore stocke chaque session dans la table forge_sessions de la base configurée du projet, ce qui la rend partagée entre processus et durable.
2. Installation
Prérequis : activez le venv du projet
Quelle que soit la source, installez dans le venv du projet :
Lancé hors d'un venv, pip vise le Python système (Debian 12+, Ubuntu 23.04+),
protégé par PEP 668. Il refuse alors d'installer, pour ne pas écraser les paquets
gérés par apt, et affiche externally-managed-environment.
Le venv de projet créé par forge new n'a pas ce verrou.
Installer le paquet¶
B. Depuis Git (avant-garde)¶
Cœur puis opt-in depuis git, dans le venv du projet (l'opt-in trouve le cœur git déjà en place, sans version publiée sur PyPI) :
pip install "git+https://github.com/caucrogeGit/Forge.git@main"
pip install "git+https://github.com/caucrogeGit/Forge.git@main#subdirectory=packages/forge-mvc-sessions-db"
Cet opt-in est une bibliothèque : on l'importe et on passe le store à forge.configure, il n'y a pas de câblage de routes.
3. Mise en service
Installer le paquet ne suffit pas à le rendre opérationnel.
Voici les gestes propres à forge-mvc-sessions-db, dans l'ordre.
Ils déclinent la procédure canonique, Rendre un opt-in opérationnel : les cinq
points.
1. L'épingler¶
Dans requirements.txt, à la même version ou au même commit que forge-mvc.
Sans cette ligne, l'opt-in n'existe que sur votre machine.
2. L'inscrire¶
L'opt-in est inscrit dans optins/registry.py (ADR-061), ce qui le rend visible du
projet.
--apply est obligatoire : sans lui, la commande simule et n'écrit rien.
3. Poser ce dont il a besoin¶
sessions:init copie la migration embarquée dans mvc/migrations/ ;
migration:apply l'exécute et la trace (ADR-071).
Sans cette étape, le premier appel échoue sur une table absente.
4. Le brancher là où il agit¶
Il s'importe dans le code qui s'en sert. Il n'y a ni route à monter ni middleware
à poser.
5. Le prouver¶
Puis un premier usage réel.
Un opt-in installé, inscrit et provisionné qu'aucun code n'appelle n'est pas
opérationnel : il est seulement présent.
4. Désinstallation
Le cœur revient alors à son store par défaut (MemorySessionStore).
forge opt-in:remove sessions-db affiche la commande pip uninstall sans l'exécuter.
5. Commandes
Cet opt-in n'expose aucune commande CLI : il s'utilise par import dans le code applicatif (voir l'API publique ci-dessous).
6. Vue d'ensemble rapide
| Élément | Valeur |
|---|---|
| Paquet | forge-mvc-sessions-db |
| Module | forge_mvc_sessions_db |
| Catégorie | Exploitation et outillage (ADR-055) |
| Couche | opt-in (brique optionnelle) |
| Dépend de | forge-mvc et un backend BDD (ADR-054) |
| API publique | DbSessionStore |
| Table SQL | forge_sessions |
| Exécuteurs | injectés en callables (fetch_one, execute), défaut core.database.db |
| Contrat implémenté | core.sessions.SessionStore |
| Principe | SQL portable, horodatages calculés côté Python (pas de NOW() propriétaire) |
| Décision d'architecture | ADR-054 (backends BDD et extraction du store de session) |
| Installation | pip install --pre forge-mvc-sessions-db |
7. Schémas UML
Le diagramme de classe montre l'implémentation du contrat ; le diagramme de séquence montre le cycle d'une session persistée.
5.1 Diagramme de classe¶
DbSessionStore implémente l'intégralité du contrat SessionStore du cœur et délègue tout son SQL à un exécuteur injecté.
Il ajoute cleanup_expired(), qui ne figure pas au contrat : purger les sessions périmées n'a de sens que pour un store persistant, un store en mémoire disparaissant avec le processus.
classDiagram
class SessionStore {
<<protocol>>
+create(data) str
+get(session_id) dict
+set(session_id, data) None
+replace(session_id, data) None
+delete(session_id) None
+regenerate(session_id) str
+authenticate(session_id, user_data, ttl) str
+touch_expiry(session_id, ttl) bool
}
class DbSessionStore {
-_fetch_one
-_execute
-_ttl
+create(data) str
+get(session_id) dict
+set(session_id, data) None
+replace(session_id, data) None
+delete(session_id) None
+regenerate(session_id) str
+authenticate(session_id, user_data, ttl) str
+touch_expiry(session_id, ttl) bool
+cleanup_expired() int
}
class forge_sessions {
<<table>>
session_id
data
expire_at
created_at
updated_at
}
SessionStore <|.. DbSessionStore : implémente
DbSessionStore ..> forge_sessions : lit / écrit via core.database.db
Ce que le diagramme révèle :
DbSessionStorerespecte le contratSessionStore, donc il se configure comme n'importe quel autre store ;- les données vivent dans la table
forge_sessions; - le store ne touche jamais la base directement : il passe par les exécuteurs
fetch_one/execute(par défaut ceux decore.database.db).
5.2 Diagramme de séquence¶
sequenceDiagram
participant App as Application
participant Store as DbSessionStore
participant DB as core.database.db
App->>Store: create()
Store->>DB: INSERT forge_sessions (id, data, expire_at, created_at, updated_at)
App->>Store: get(session_id)
Store->>DB: SELECT data WHERE id = ? AND expire_at > ?
DB-->>Store: data JSON
Store-->>App: dict de session
App->>Store: cleanup_expired()
Store->>DB: DELETE WHERE expire_at < ?
L'horodatage comparé (?) est calculé côté Python, jamais par une fonction SQL propriétaire.
8. API publique
| Nom | Signature | Rôle |
|---|---|---|
DbSessionStore |
DbSessionStore(fetch_one=None, execute=None, ttl=SESSION_TTL) |
Store de session BDD ; exécuteurs injectables, durée de vie en secondes |
create |
create(data=None) -> str |
Crée une session (structure Forge standard) et retourne son identifiant |
get |
get(session_id) -> dict | None |
Retourne les données, ou None si absente, expirée ou corrompue |
set |
set(session_id, data) -> None |
Met à jour (merge) une session existante |
replace |
replace(session_id, data) -> None |
Remplace intégralement les données (sans merge) |
delete |
delete(session_id) -> None |
Supprime la session |
regenerate |
regenerate(session_id) -> str |
Nouvel identifiant, données préservées (anti-fixation) |
authenticate |
authenticate(session_id, user_data, ttl_seconds) -> str | None |
Rotation atomique vers une session authentifiée |
touch_expiry |
touch_expiry(session_id, ttl_seconds) -> bool |
Repousse l'expiration |
cleanup_expired |
cleanup_expired() -> int |
Supprime les sessions expirées, retourne le nombre supprimé |
Le module expose aussi set_flash / get_flash pour les messages flash.
9. Contextes d'utilisation
| Besoin | Store recommandé |
|---|---|
| Développement local, tests | MemorySessionStore (cœur) |
| Persistance mono-processus simple | FileSessionStore (cœur) |
| Production multi-worker (Gunicorn, uWSGI) | DbSessionStore (cet opt-in) |
| Déploiement multi-nœud derrière la même base | DbSessionStore (cet opt-in) |
10. Exemples d'utilisation
8.1 Configurer le store¶
import core.forge as forge
from forge_mvc_sessions_db import DbSessionStore
forge.configure(session_store=DbSessionStore(ttl=3600))
La table forge_sessions doit exister au préalable (forge sessions:init puis forge migration:apply).
8.2 Nettoyer les sessions expirées¶
from forge_mvc_sessions_db import DbSessionStore
store = DbSessionStore()
supprimees = store.cleanup_expired()
print(f"{supprimees} sessions expirées supprimées")
En ligne de commande, forge sessions:gc fait la même purge :
Rien n'est planifié automatiquement : branchez forge sessions:gc sur un cron ou un systemd timer.
8.3 Tester sans base réelle¶
from forge_mvc_sessions_db import DbSessionStore
rows = {}
def fake_fetch_one(sql, params):
row = rows.get(params[0])
return {"data": row} if row else None
def fake_execute(sql, params=()):
return 1
store = DbSessionStore(fetch_one=fake_fetch_one, execute=fake_execute)
Les exécuteurs fetch_one / execute sont injectables : les tests n'ont pas besoin d'une base.
11. Portabilité et exécuteurs injectés
Le store délègue tout son SQL aux callables fetch_one / execute, qui pointent par défaut vers core.database.db.
core.database.db dispatche vers le backend BDD actif (forge-mvc-mariadb, forge-mvc-sqlite, etc.), en traduisant le style de paramètres (?) et le dialecte.
Comme les horodatages sont calculés côté Python et passés en paramètres, aucune fonction date propriétaire n'apparaît dans le SQL : le store fonctionne à l'identique sur tous les backends (ADR-054).
Création de la table
Le store suppose la table forge_sessions présente.
Elle n'est pas créée automatiquement : lancez forge sessions:init puis forge migration:apply.
12. Une durée de vie par nature de session
Le store portait une durée pour tout le monde (SESSIONS-TTL-PER-KIND-001). Les trois natures de session n'ont pourtant ni le même risque ni le même usage.
| Nature | Ce qu'elle porte | Ce qu'une fuite coûte |
|---|---|---|
anonymous |
un jeton CSRF, un panier | presque rien |
authenticated |
une identité | l'accès au compte |
remembered |
une identité, sur des semaines | l'accès au compte, longtemps |
Une durée unique force un arbitrage perdant. Réglée court, elle déconnecte les utilisateurs authentifiés toutes les heures. Réglée long, elle laisse traîner des sessions anonymes par milliers, que la purge doit balayer et qui occupent la table pour un jeton CSRF.
Le vocabulaire est fermé
Une quatrième nature inventée par une application rendrait la métrique et la purge incomparables d'un projet à l'autre, ce qui est ce que ce champ doit permettre.
Une valeur illisible lève
Comme pour les quotas de forge-mvc-files et les limites de forge-mvc-images.
Retomber en silence sur le défaut donnerait une durée que personne n'a écrite, et une session qui expire trop tôt se diagnostique très mal.
Le réglage de l'authentifié ne servait à rien
ttl_for() n'était appelée qu'à un seul endroit, la création d'une session anonyme (SESSIONS-TTL-AUTHENTICATED-APPLIED-001).
La connexion passe par authenticate(), qui prenait le ttl_seconds de son appelant, et le cœur appelle avec SESSION_DURATION, égal au défaut historique.
Mesuré, un exploitant réglant SESSION_TTL_AUTHENTICATED=1800 obtenait trois mille six cents secondes quand même.
C'est un réglage de sécurité : celui qui l'a posé croyait ses sessions raccourcies, et elles ne l'étaient pas.
Le module refuse pourtant une valeur illisible, en disant que « retomber en silence sur le défaut donnerait une durée que personne n'a écrite » ; la valeur lisible était ignorée tout aussi silencieusement.
Les deux chemins suivent désormais la même règle, et un garde-fou lu sur l'arbre syntaxique refuse qu'ils divergent à nouveau.
La nature remembered s'écrit depuis l'application
Le protocole SessionStore du cœur déclare create(data=None), sans nature : Forge n'implémente pas de « se souvenir de moi ».
Une application qui en veut un tient son DbSessionStore et appelle create(kind=KIND_REMEMBERED) elle même.
La durée est prête, le geste lui appartient.
Un ttl passé au constructeur reste prioritaire
Un projet qui l'avait réglé à la main garde son réglage : le retirer sous ses pieds serait une rupture silencieuse.
La colonne kind arrive par une migration additive. Les projets déjà provisionnés ne rejouent pas la création de la table, son empreinte étant enregistrée.
13. Compter les sessions actives
sessions:gc dit combien de sessions il a purgées. Personne ne pouvait dire combien il en reste, ni comment ce nombre évolue (SESSIONS-ACTIVE-METRIC-001).
C'est pourtant la première chose qu'on veut savoir d'un magasin adossé à la base : une table qui grossit sans fin signale une purge qui ne tourne pas, et une chute brutale signale une déconnexion de masse.
from forge_mvc_sessions_db import session_metrics
mesures = session_metrics()
mesures.active, mesures.expired, mesures.by_kind
mesures.purge_backlog_ratio # au delà de 0.5, la purge est en retard
Le filtre est en SQL
Compter toutes les lignes puis écarter les expirées en Python rapatrierait une table entière pour en rendre un nombre.
Une session expirée n'est pas active
Elle occupe la table sans plus servir à personne, même si la purge ne l'a pas encore retirée.
La compter ferait passer un retard de purge pour de la fréquentation, ce qui est le contraire de ce que la métrique doit montrer.
Les trois natures figurent toujours dans by_kind, à zéro le cas échéant : une clé absente et une valeur nulle se lisent différemment dans un tableau de bord, et l'absence ferait croire à une métrique cassée.
14. Faire tourner la purge, avec systemd
sessions:gc doit tourner régulièrement (SESSIONS-GC-TIMER-DOC-001). Forge ne fournit pas de planificateur : c'est le rôle du système, et en embarquer un ferait de Forge un ordonnanceur, ce que le principe 8 refuse.
Deux fichiers, dans /etc/systemd/system/.
# forge-sessions-gc.service
[Unit]
Description=Purge des sessions Forge expirées
After=network.target
[Service]
Type=oneshot
User=monapp
WorkingDirectory=/srv/monapp
EnvironmentFile=/srv/monapp/env/prod
ExecStart=/srv/monapp/.venv/bin/forge sessions:gc
# forge-sessions-gc.timer
[Unit]
Description=Purge horaire des sessions Forge
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
sudo systemctl enable --now forge-sessions-gc.timer
systemctl list-timers forge-sessions-gc.timer
journalctl -u forge-sessions-gc.service --since today
Persistent=true rattrape les exécutions manquées
Un serveur éteint pendant la nuit ne saute pas trois purges : la première au redémarrage les remplace.
Sans lui, un serveur redémarré chaque matin ne purgerait jamais ce qui a expiré la nuit.
RandomizedDelaySec évite la synchronisation
Plusieurs applications purgeant à la même minute frappent la base ensemble.
Cinq minutes de dispersion suffisent à étaler la charge.
Le minuteur, pas le service
systemctl enable porte sur le .timer. Activer le .service le ferait tourner une fois au démarrage, puis plus jamais.
EnvironmentFile porte les identifiants de base
Le fichier doit appartenir au compte de service et n'être lisible que par lui, chmod 600.
sessions:gc se connecte à la base, et un env/prod lisible par tous rend les identifiants applicatifs lisibles par tous.
La fréquence dépend de la durée de vie la plus courte : purger toutes les heures des sessions anonymes de deux heures laisse la table à deux fois sa taille utile, ce qui est raisonnable. session_metrics().purge_backlog_ratio le vérifie.
Voir aussi¶
- Le store (store.py) :
DbSessionStoreet ses méthodes. - Les sessions dans le cœur : contrat
SessionStore, stores mémoire et fichier. - Welcome-Sessions BDD : parcours d'apprentissage.