Every request arrives with a cookie and a question: who is this? A session is the one piece of per-user state almost every web app keeps server side to answer it: who’s logged in, since when, with which roles. Keeping it in a cache directory means every worker sees the same session the instant it’s written, a login on one worker is a login on all of them, and there’s no database row per request and no Redis to run. The cookie holds only a random id.
Start, load, end
import secrets
import tempfile
from diskcache_oxide import Cache
now = [1_000_000.0] # a fake clock; in a real app use time.time()
sessions = Cache(tempfile.mkdtemp(), clock=lambda: now[0])
IDLE = 30 * 60 # a session ends after 30 minutes without a request
ABSOLUTE = 12 * 3600 # and 12 hours after login, however active
def start_session(user_id: int, roles: list[str]) -> str:
sid = secrets.token_urlsafe(32)
sessions.set(f'session:{sid}', {'user_id': user_id, 'roles': roles, 'started': now[0]}, expire=IDLE)
sessions.set(f'user-sessions:{user_id}:{sid}', True, expire=ABSOLUTE)
return sid
def load_session(sid: str) -> dict | None:
data, expires_at = sessions.get(f'session:{sid}', expire_time=True)
if data is None or now[0] - data['started'] > ABSOLUTE:
return None
if expires_at - now[0] < IDLE / 2: # slide the idle timeout, but not on every request
sessions.touch(f'session:{sid}', expire=IDLE)
return data
def end_session(sid: str) -> None:
data = sessions.pop(f'session:{sid}')
if data is not None:
sessions.delete(f'user-sessions:{data["user_id"]}:{sid}')
sid = start_session(42, ['member'])
now[0] += 20 * 60
print('after 20 min:', load_session(sid) is not None) # active, and the timeout slides
now[0] += 20 * 60
print('after 40 min:', load_session(sid) is not None) # still active: the last request was 20 min ago
now[0] += 31 * 60
print('after 71 min:', load_session(sid) is not None) # idle too long
after 20 min: True
after 40 min: True
after 71 min: False
- touch() slides the idle timeout without rewriting the session. It’s a small write, so it’s done only when less than half the idle time is left, which
get(expire_time=True) tells you in the same read as the data. A busy user costs one write every 15 minutes instead of one per request.
- The absolute lifetime is inside the value. expire can only say when the session dies if it’s left alone; the login time says when it dies regardless.
- The id is the secret.
secrets.token_urlsafe(32) in an HttpOnly, Secure, SameSite cookie, and nothing else in the cookie. The session dict holds ids and roles, not the whole user object, which belongs in the database.
Log out everywhere
A password change or a stolen laptop should end every session a user has. The second key written at login, user-sessions:{user_id}:{sid}, is an index for exactly this: a prefix scan finds them all.
other = start_session(42, ['member'])
sid = start_session(42, ['member'])
def end_all_sessions(user_id: int) -> int:
ended = 0
for key in sessions.iterkeys(prefix=f'user-sessions:{user_id}:'):
sid = key.rsplit(':', 1)[1]
ended += sessions.delete(f'session:{sid}')
sessions.delete(key)
return ended
print(end_all_sessions(42), 'sessions ended;', 'still valid:', load_session(other) is not None)
2 sessions ended; still valid: False
Index entries for sessions that idled out stay until their own ABSOLUTE expiry, which is why they have one. To show a user their active sessions, read each session: key the index names and skip the ones that are gone.
Sessions are not cache entries
A lost session is a forced logout, so sessions get a cache of their own, configured unlike the one for page fragments:
- A size_limit it never reaches. Eviction removes the least recently stored entries, and a long-lived session is exactly that. Sessions are small (a few hundred bytes each), so even 1 GiB, the default, holds millions. Watch volume() if you store more per session.
- Never a NullCache. A NullCache stores nothing, so nobody could log in. If the app’s other caches can be switched off through
Caches, register the session cache by name and leave it enabled.
- Not
durable=True. A dropped write there means a login that silently didn’t happen. Let set() raise and show the user an error.
- Run expire() from a scheduled job, claimed by one worker, so ended sessions free their space. Expired sessions are already invisible to get(); this is housekeeping.
- One machine. Every worker on the host shares the sessions. Behind a load balancer, use sticky sessions or wait for the network server.