Skip to content

src/genro_asgi/session/store.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """Session store — the storage Protocol and the in-memory default.16 17 ``SessionStore`` is a runtime-checkable ``Protocol`` (get/create/delete/18 purge_expired/dump/restore). Its test suite is a shared CONTRACT suite driven19 by a store factory (§5.9), so a custom backend plugs into the SAME tests.20 ``MemorySessionStore`` is the dict-backed only shipped store: ``secrets``21 tokens, a ``default_ttl`` for new sessions, lazy expiry on ``get``, and a22 delta-checked ``purge_expired`` at ``create`` time — the mass reap runs only23 when ``PURGE_INTERVAL`` has elapsed since the last one (no background task:24 this REPLACES the former TaskManager purge loop, a ratified revision of core25 1e/◆D22). ``dump``/``restore`` persist meta and the keyed avatars'26 identity/tags only — never the data Bag. The serialized shape is27 ``avatars: {key: {identity, tags}}``, the whole wardrobe of the session.28 ``save_snapshot``/``load_snapshot`` are the OTHER persistence pair — one29 pickle file carrying every live session whole, data Bag included, the30 development survival line ``SessionMixin`` drives around the lifespan.31 ``create()`` is anonymous by default (``avatar is None``); capturing an identity32 into a session is an explicit ``create(avatar=...)``, which dresses the root33 slot.34 """35 36 from __future__ import annotations37 38 import pickle39 import secrets40 import time41 from pathlib import Path42 from typing import Any, Protocol, runtime_checkable43 44 from .avatar import Avatar45 from .session import Session46 47 __all__ = ["SessionStore", "MemorySessionStore", "PURGE_INTERVAL"]48 49 PURGE_INTERVAL = 300.0   # seconds between two mass reaps of expired sessions50 51 52 @runtime_checkable53 class SessionStore(Protocol):54     """Protocol for session storage backends."""55 56     def get(self, session_id: str) -> Session | None:57         """Retrieve a session by id, or ``None`` if unknown or expired."""58         ...59 60     def create(self, avatar: Avatar | None = None) -> Session:61         """Create a new session with a unique token (anonymous by default).62 63         ``avatar`` dresses the session's root slot.64         """65         ...66 67     def save(self, session: Session) -> None:68         """Persist a dirty session's state (the middleware calls this at request end)."""69         ...70 71     def delete(self, session_id: str) -> None:72         """Remove a session from the store."""73         ...74 75     def purge_expired(self) -> int:76         """Remove every expired session; return how many were purged."""77         ...78 79     def dump(self) -> dict[str, Any]:80         """Serialize the sessions for persistence."""81         ...82 83     def restore(self, data: dict[str, Any]) -> None:84         """Restore sessions from serialized data."""85         ...86 87 88 class MemorySessionStore:89     """In-memory session store — the default implementation."""90 91     __slots__ = ("_sessions", "_default_ttl", "_last_purge")92 93     def __init__(self, default_ttl: int = 3600) -> None:94         """Initialize an empty store with a default TTL for new sessions."""95         self._sessions: dict[str, Session] = {}96         self._default_ttl = default_ttl97         self._last_purge = time.time()98 99     def get(self, session_id: str) -> Session | None:100         """Retrieve a session by id; drop and return ``None`` if it has expired."""101         session = self._sessions.get(session_id)102         if session is None:103             return None104         if session.is_expired():105             del self._sessions[session_id]106             return None107         session.touch()108         return session109 110     def create(self, avatar: Avatar | None = None) -> Session:111         """Create a session (default TTL); a delta-checked mass reap runs first.112 113         The reap is opportunistic AND throttled: it runs only when114         ``PURGE_INTERVAL`` has elapsed since the last one, so a burst of115         creates never pays a full-store scan each time.116         """117         if time.time() - self._last_purge > PURGE_INTERVAL:118             self.purge_expired()119         session_id = secrets.token_urlsafe(32)120         session = Session(session_id=session_id, avatar=avatar, ttl=self._default_ttl)121         self._sessions[session_id] = session122         return session123 124     def save(self, session: Session) -> None:125         """No-op: the in-memory store holds the live object, so it is already saved."""126 127     def delete(self, session_id: str) -> None:128         """Remove a session from the store (a no-op if absent)."""129         self._sessions.pop(session_id, None)130 131     def purge_expired(self) -> int:132         """Drop every expired session from the store; return the count purged."""133         self._last_purge = time.time()134         expired = [sid for sid, session in self._sessions.items() if session.is_expired()]135         for sid in expired:136             del self._sessions[sid]137         return len(expired)138 139     def dump(self) -> dict[str, Any]:140         """Serialize meta and every keyed avatar's identity/tags (never the data Bag)."""141         return {142             session_id: {143                 "meta": dict(session.meta),144                 "avatars": {145                     key: {"identity": avatar.identity, "tags": list(avatar.tags)}146                     for key, avatar in session.avatars.items()147                 },148             }149             for session_id, session in self._sessions.items()150         }151 152     def save_snapshot(self, path: str | Path) -> int:153         """Pickle EVERY live session — data Bag INCLUDED — to *path*.154 155         The development survival line (``genro-asgi serve --name``): the whole156         store crosses a restart through one pickle file. This deliberately157         supersedes the ``dump``/``restore`` contract ("the data Bag is never158         persisted") for the snapshot path. Expired sessions are reaped first;159         parent directories are created. Returns how many sessions were saved.160         """161         self.purge_expired()162         target = Path(path)163         target.parent.mkdir(parents=True, exist_ok=True)164         target.write_bytes(pickle.dumps(self._sessions))165         return len(self._sessions)166 167     def load_snapshot(self, path: str | Path) -> int:168         """Repopulate the store from a ``save_snapshot`` file, dropping expired ones.169 170         The TTL is the only filter: a session whose ``last_access`` is still171         within its ``ttl`` comes back whole (data Bag included). Returns how172         many sessions were restored.173         """174         sessions: dict[str, Session] = pickle.loads(Path(path).read_bytes())175         kept = {sid: session for sid, session in sessions.items() if not session.is_expired()}176         self._sessions.update(kept)177         return len(kept)178 179     def restore(self, data: dict[str, Any]) -> None:180         """Restore non-expired sessions from ``dump()`` output (meta + rebuilt avatars)."""181         for session_id, session_data in data.items():182             meta = session_data["meta"]183             avatars = session_data["avatars"]184             root = avatars.get(Session.ROOT_AVATAR_KEY)185             session = Session(186                 session_id=session_id,187                 avatar=Avatar(root["identity"], root["tags"]) if root else None,188                 ttl=meta["ttl"],189             )190             for key, avatar_data in avatars.items():191                 if key != Session.ROOT_AVATAR_KEY:192                     session.attach_avatar(Avatar(avatar_data["identity"], avatar_data["tags"]), key)193             session.clear_dirty()194             session.meta["created_at"] = meta["created_at"]195             session.meta["last_access"] = meta["last_access"]196             if not session.is_expired():197                 self._sessions[session_id] = session