Skip to content

feat(session)!: make ordinary session saves conditional - #417

Merged
allen0099 merged 2 commits into
masterfrom
feat/128-conditional-session-saves
Sep 30, 2026
Merged

allen0099 merged 2 commits into
masterfrom
feat/128-conditional-session-saves

Conversation

@allen0099

@allen0099 allen0099 commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #128

Summary

  • New backend primitive set_if_equals(key, expected, value, ttl=None) -> bool: atomic on memory (under its lock), Redis (Lua compare + SET [EX]) and Memcached (GETS + CAS), with a non-atomic fallback on BaseCacheBackend.
  • A Session remembers the backend entry it was last read from or written as (a private attribute, not serialized, ignored by equality).
  • Conditional writes: update_session(), sliding renewal in get_session(), and the middleware's save of request.session. Unconditional: create, delete, invalidate, expire — security actions always win.
  • update_session() returns True/False; a dropped save is logged at INFO. A hand-built Session is stored only if its ID is absent.
  • A renewal that loses a race re-reads the session and retries once; if it loses again, no renewed token is sent.
  • ID rotation (regenerate_session_id()) claims the old record with get_and_delete and goes ahead only if it was still valid, so a stale copy cannot come back under a new ID; of two concurrent rotations only the first succeeds. rotate_session_id() answers 401 and sends nothing; login() starts a new session for the user without the ended session's data.
  • The middleware sends no token after a dropped save, except a renewal already stored for a session that is still valid (otherwise a JWT client would keep a token expiring before its record).

Breaking

When two requests change the same session at once, the first save wins (previously the last). Merging is tracked in #376. Documented in MIGRATING_0_4.md#session-writes (en/zh-TW); BACKENDS.md and SESSION.md updated.

Tests

  • Backend tests for set_if_equals on base, memory, Redis and Memcached (including a change made between the compare and the write), plus the TTL contract.
  • tests/session/test_conditional_saves.py: stale saves after delete / invalidate / rotation, concurrent saves, renewal races, middleware drops on cookie and header transports, and the renewal-token exception.
  • Mutation-checked: breaking each compare, the retry, the middleware result handling or the stored-entry tracking fails a test.

Add set_if_equals(key, expected, value, ttl=None) to every backend: the
memory backend compares under its lock, Redis compares and re-checks in a
Lua script, Memcached uses GETS + CAS, and BaseCacheBackend gets a
non-atomic fallback.

Sessions remember the backend entry they were last read from or written
as. update_session(), sliding renewal in get_session() and the
middleware's save of request.session store only while the backend still
holds that entry, so a request cannot bring back a session that another
request deleted, invalidated or rotated. Delete, invalidate, expire and
ID rotation stay unconditional. A renewal that loses a race re-reads the
session and retries once. A dropped middleware save is logged and sends
no token, except a stored renewal for a session that is still valid.
Session equality ignores the remembered entry.

BREAKING CHANGE: update_session() returns a bool and drops a save that
lost a race; when two requests change one session at once, the first
save wins. A hand-built Session is stored only if no record exists under
its ID.

Closes #128
@allen0099 allen0099 added this to the 0.4.0 milestone Sep 30, 2026
@allen0099 allen0099 added backends Cache backends and their atomic primitives session Session management subsystem breaking-change Changes public behaviour or API; needs a minor/major release labels Sep 30, 2026
regenerate_session_id() removed the old record and wrote the in-memory
copy under a new ID unconditionally, so a request that read a session
before another request deleted or invalidated it could bring it back
under a new ID. It now claims the old record with get_and_delete and
goes ahead only if that record was still valid; of two concurrent
rotations only the first succeeds. A session built by hand rotates as
before.

rotate_session_id() answers 401 and makes the middleware send and save
nothing, since the winner of a concurrent rotation may already hold the
client's new token. login() starts a new session for the user instead,
without the ended session's data.

BREAKING CHANGE: regenerate_session_id() raises SessionNotFoundError or
SessionInvalidError when the session ended since it was read.

Refs #128
@allen0099
allen0099 merged commit 5e30935 into master Sep 30, 2026
14 checks passed
@allen0099
allen0099 deleted the feat/128-conditional-session-saves branch September 30, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backends Cache backends and their atomic primitives breaking-change Changes public behaviour or API; needs a minor/major release session Session management subsystem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0.4.0: conditional session writes so a stale request cannot restore a deleted session

1 participant