Himanshu Kukreja
0%
LearnSystem DesignWeek 1Session Store Design
Day 05

Week 1 — Day 5: Session Store Design

System Design Mastery Series


Preface

This is the capstone session for Week 1. Over the past four days, you've learned:

  • Day 1: Partitioning — how to split data across machines
  • Day 2: Replication — how to copy data for availability and scale
  • Day 3: Rate Limiting — how to protect systems from overload
  • Day 4: Hot Keys — how to handle skewed traffic patterns

Today, we tie it all together by designing a complete system from scratch: a session store for 10 million concurrent users. This is a real system that every large-scale application needs, and it exercises every concept from this week.

By the end of this session, you'll not only have a complete session store design—you'll have a template for approaching any storage system design.

Let's begin.


Part I: Foundations

Chapter 1: Understanding Session Management

1.1 What Is a Session?

A session is a temporary, server-side storage for user-specific data that persists across multiple requests. When a user logs into a web application, the server creates a session containing:

  • Authentication state (user ID, roles, permissions)
  • User preferences (language, timezone)
  • Shopping cart contents
  • CSRF tokens
  • Temporary workflow state

The client receives a session ID (usually in a cookie), which it sends with every request. The server uses this ID to retrieve the session data.

Request 1 (Login):
  Client → Server: Credentials
  Server: Create session {user_id: 123, roles: ['user']}
  Server → Client: Set-Cookie: session_id=abc123

Request 2 (Protected page):
  Client → Server: Cookie: session_id=abc123
  Server: Look up abc123 → {user_id: 123, roles: ['user']}
  Server: User is authenticated, serve page

1.2 Session Store Requirements

A production session store must satisfy:

Requirement Target Why It Matters
Latency < 5ms P99 Every request reads session; can't add latency
Availability 99.99% Session unavailable = user logged out
Durability Best effort Lost session = user re-authenticates (inconvenient, not catastrophic)
Consistency Read-your-writes User must see their own changes
Scale 10M concurrent Enterprise SaaS, consumer apps, gaming

1.3 Scale Analysis

Let's understand what "10M concurrent users" means:

Session characteristics:

  • Average session size: 2 KB (JSON with user data, preferences, tokens)
  • Session TTL: 24 hours (extended on activity)
  • Active sessions: 10 million

Storage requirement:

10M sessions × 2 KB = 20 GB

Not huge—fits in memory of a few servers.

Request pattern:

  • Each active user makes ~1 request per second (average)
  • Peak: 10M requests/second (unlikely—users aren't perfectly synchronized)
  • Realistic peak: 2-3M requests/second (20-30% concurrently active)

Operation breakdown:

  • 80% reads (session validation on each request)
  • 15% updates (refresh TTL, update preferences)
  • 5% creates/deletes (login/logout)

This is a read-heavy workload with moderate writes.


Chapter 2: Architecture Approaches

There are three fundamental approaches to session management. Each has distinct trade-offs.

2.1 Approach 1: Sticky Sessions (Server Affinity)

Route all requests from one user to the same server. Store sessions in that server's memory.

                    Load Balancer
                    (sticky routing)
                         │
         ┌───────────────┼───────────────┐
         │               │               │
         ▼               ▼               ▼
    ┌─────────┐     ┌─────────┐     ┌─────────┐
    │Server 1 │     │Server 2 │     │Server 3 │
    │Sessions:│     │Sessions:│     │Sessions:│
    │ A, D, G │     │ B, E, H │     │ C, F, I │
    └─────────┘     └─────────┘     └─────────┘

How it works:

  • Load balancer hashes user identifier (IP, cookie) to server
  • All requests from that user go to same server
  • Session stored in server's local memory

Strengths:

  • ✅ No external dependencies
  • ✅ Fastest reads (local memory)
  • ✅ Simplest implementation

Weaknesses:

  • ❌ Server failure loses all its sessions (users logged out)
  • ❌ Uneven load distribution (some users heavier than others)
  • ❌ Scaling requires session migration
  • ❌ Can't do rolling deployments without session loss

When to use: Small scale, simple applications, sessions are easily recreated.

2.2 Approach 2: Distributed Session Store

Store sessions in a dedicated, distributed storage system (Redis, Memcached, DynamoDB).

                    Load Balancer
                    (any server)
                         │
         ┌───────────────┼───────────────┐
         │               │               │
         ▼               ▼               ▼
    ┌─────────┐     ┌─────────┐     ┌─────────┐
    │Server 1 │     │Server 2 │     │Server 3 │
    └────┬────┘     └────┬────┘     └────┬────┘
         │               │               │
         └───────────────┼───────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │   Session Store     │
              │   (Redis Cluster)   │
              └─────────────────────┘

How it works:

  • Sessions stored in external distributed store
  • Any server can handle any request
  • Session ID → lookup in store

Strengths:

  • ✅ Server failures don't lose sessions
  • ✅ Easy horizontal scaling
  • ✅ Rolling deployments are trivial
  • ✅ Load balancing is simple (round-robin)

Weaknesses:

  • ❌ Network hop for every request (+1-2ms)
  • ❌ Session store is a new dependency (availability concerns)
  • ❌ More complex infrastructure

When to use: Most production applications. This is the standard approach.

2.3 Approach 3: Client-Side Sessions (JWT/Encrypted Cookies)

Store session data in signed/encrypted cookies. No server-side storage.

Request:
  Cookie: session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.signature
  
Server decodes: {user_id: 123, roles: ['user'], exp: 1234567890}

How it works:

  • Session data encoded in JWT or encrypted cookie
  • Server validates signature/decrypts on each request
  • No storage needed

Strengths:

  • ✅ No storage infrastructure
  • ✅ Infinite horizontal scaling
  • ✅ Works across datacenters without replication

Weaknesses:

  • ❌ Can't invalidate sessions (until expiry)
  • ❌ Session size limited by cookie size (4KB)
  • ❌ Every request sends full session (bandwidth)
  • ❌ Secret key rotation is complex

When to use: Stateless APIs, microservices auth, when you can't do server-side storage.

2.4 Hybrid: Distributed Store + Client Cache

Combine approaches for the best of both worlds:

Request flow:
1. Check encrypted cookie for basic auth (user_id, roles)
2. If more data needed, fetch from distributed store
3. Cache frequently accessed session data locally (short TTL)

Strengths:

  • Most requests don't hit the session store (cookie has basics)
  • Full session data available when needed
  • Can invalidate by marking session invalid in store

This is what we'll design today.


Chapter 3: Sticky Sessions Deep Dive

Before moving to distributed sessions, let's understand sticky sessions thoroughly—you may need them as a fallback or for specific use cases.

3.1 Implementation Options

Option 1: Cookie-based routing

Load balancer sets a cookie indicating which server should handle requests:

First request:
  LB → Server 2 (random assignment)
  Server 2 → Client: Set-Cookie: SERVERID=server2

Subsequent requests:
  Client → LB: Cookie: SERVERID=server2
  LB → Server 2 (based on cookie)

Option 2: IP hash

Hash client IP to determine server:

server_index = hash(client_ip) % num_servers

Problem: Users behind NAT share IP → same server.

Option 3: Consistent hashing

Use consistent hashing for more stable routing during scale events:

class StickyLoadBalancer:
    def __init__(self, servers: list[str]):
        self.ring = ConsistentHashRing(servers)
    
    def get_server(self, user_id: str) -> str:
        return self.ring.get_node(user_id)
    
    def add_server(self, server: str):
        # Only 1/N of sessions need to move
        self.ring.add_node(server)
    
    def remove_server(self, server: str):
        # Sessions for this server will be redistributed
        self.ring.remove_node(server)

3.2 Handling Server Failures

When a sticky server dies, its users lose sessions. Mitigations:

Mitigation 1: Session replication

Replicate sessions to a backup server:

Primary: Server 2 handles User A
Backup:  Server 5 has copy of User A's session

Server 2 dies:
  LB detects failure
  LB routes User A to Server 5
  User A's session still exists

But this adds complexity—at this point, just use a distributed store.

Mitigation 2: Graceful degradation

Accept that server failure = session loss. Make session recreation easy:

@app.route('/dashboard')
def dashboard():
    session = get_session()
    if not session:
        # Session lost - redirect to login
        return redirect('/login?reason=session_expired')
    return render_dashboard(session)

If login is fast (SSO, remember-me cookies), this is acceptable.

3.3 When Sticky Sessions Make Sense

  • WebSocket connections: Must maintain connection to same server
  • In-memory caching: Server caches user-specific data
  • Legacy systems: Application stores state in memory
  • Cost constraints: Can't afford session store infrastructure

Part II: The Design Challenge

Chapter 4: Designing a Distributed Session Store

4.1 Requirements Recap

  • Capacity: 10M concurrent sessions, 2KB each = 20GB
  • Throughput: 2-3M reads/second peak, 500K writes/second
  • Latency: <5ms P99
  • Availability: 99.99% (52 minutes downtime/year)
  • Durability: Best-effort (session loss is inconvenient, not catastrophic)
  • Consistency: Read-your-writes

4.2 High-Level Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                           Application Tier                               │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐         ┌──────────┐           │
│  │ App Srv  │ │ App Srv  │ │ App Srv  │   ...   │ App Srv  │           │
│  │   + LC   │ │   + LC   │ │   + LC   │         │   + LC   │           │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘         └────┬─────┘           │
│       │            │            │                    │                  │
│       └────────────┴────────────┴────────────────────┘                  │
│                                 │                                        │
│                    LC = Local Cache (optional)                          │
└─────────────────────────────────┼───────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                          Session Store Tier                              │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐ │
│  │                      Redis Cluster                                  │ │
│  │                                                                     │ │
│  │   ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐        │ │
│  │   │ Shard 0 │    │ Shard 1 │    │ Shard 2 │    │ Shard N │        │ │
│  │   │Primary  │    │Primary  │    │Primary  │    │Primary  │        │ │
│  │   │+Replica │    │+Replica │    │+Replica │    │+Replica │        │ │
│  │   └─────────┘    └─────────┘    └─────────┘    └─────────┘        │ │
│  │                                                                     │ │
│  └────────────────────────────────────────────────────────────────────┘ │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4.3 Data Model

Session structure:

@dataclass
class Session:
    session_id: str          # Unique identifier (UUID or secure random)
    user_id: str             # Authenticated user
    created_at: int          # Unix timestamp
    last_accessed: int       # Unix timestamp (for TTL extension)
    expires_at: int          # Unix timestamp
    
    # Authentication data
    roles: list[str]
    permissions: list[str]
    
    # User preferences
    locale: str
    timezone: str
    
    # CSRF protection
    csrf_token: str
    
    # Application-specific data
    data: dict               # Flexible storage for app needs

# Example session
session = Session(
    session_id="sess_a1b2c3d4e5f6",
    user_id="user_123",
    created_at=1700000000,
    last_accessed=1700003600,
    expires_at=1700086400,
    roles=["user", "premium"],
    permissions=["read", "write", "delete"],
    locale="en-US",
    timezone="America/New_York",
    csrf_token="csrf_xyz789",
    data={
        "cart_items": ["item_1", "item_2"],
        "last_page": "/products/shoes"
    }
)

Redis storage:

Key:   session:{session_id}
Value: JSON-encoded session data
TTL:   24 hours (extended on access)

4.4 Partitioning Strategy

With 10M sessions across a Redis cluster, we need to partition effectively.

Hash partitioning by session_id:

def get_partition(session_id: str, num_partitions: int) -> int:
    return crc16(session_id) % num_partitions

Redis Cluster does this automatically using hash slots (16384 slots).

Capacity planning:

Total data: 20 GB
Target per shard: 5 GB (leave headroom)
Number of shards: 20 GB / 5 GB = 4 shards minimum

For redundancy and performance:
  6 shards × 2 (primary + replica) = 12 nodes
  
Each shard:
  ~1.7M sessions
  ~3.3 GB memory
  ~500K reads/second capacity (plenty of headroom)

4.5 Replication Strategy

For 99.99% availability, we need replication with automatic failover.

Redis Cluster configuration:

Each shard:
  1 primary (handles reads + writes)
  1 replica (handles reads, ready for promotion)

Replication: Asynchronous (default)
  - Writes acknowledged after primary persists
  - Replica catches up in background
  - Failover may lose last few writes (acceptable for sessions)

Failover behavior:

Normal operation:
  Primary handles all writes
  Reads distributed to primary + replica

Primary fails:
  1. Cluster detects failure (1-2 seconds)
  2. Replica promoted to primary (automatic)
  3. Brief write unavailability (seconds)
  4. Old primary rejoins as replica when recovered

4.6 Session Operations

import redis
import json
import secrets
import time
from typing import Optional
from dataclasses import asdict

class SessionStore:
    def __init__(
        self,
        redis_cluster: redis.RedisCluster,
        default_ttl: int = 86400,  # 24 hours
        extend_on_access: bool = True
    ):
        self.redis = redis_cluster
        self.default_ttl = default_ttl
        self.extend_on_access = extend_on_access
    
    def create(self, user_id: str, **kwargs) -> Session:
        """Create a new session for a user."""
        now = int(time.time())
        
        session = Session(
            session_id=f"sess_{secrets.token_urlsafe(32)}",
            user_id=user_id,
            created_at=now,
            last_accessed=now,
            expires_at=now + self.default_ttl,
            roles=kwargs.get('roles', []),
            permissions=kwargs.get('permissions', []),
            locale=kwargs.get('locale', 'en-US'),
            timezone=kwargs.get('timezone', 'UTC'),
            csrf_token=secrets.token_urlsafe(32),
            data=kwargs.get('data', {})
        )
        
        key = f"session:{session.session_id}"
        self.redis.setex(key, self.default_ttl, json.dumps(asdict(session)))
        
        # Index by user_id for "logout all devices"
        self.redis.sadd(f"user_sessions:{user_id}", session.session_id)
        self.redis.expire(f"user_sessions:{user_id}", self.default_ttl)
        
        return session
    
    def get(self, session_id: str) -> Optional[Session]:
        """Retrieve a session by ID."""
        key = f"session:{session_id}"
        data = self.redis.get(key)
        
        if not data:
            return None
        
        session_dict = json.loads(data)
        session = Session(**session_dict)
        
        # Check expiration
        if session.expires_at < time.time():
            self.delete(session_id)
            return None
        
        # Extend TTL on access
        if self.extend_on_access:
            self._extend_ttl(session)
        
        return session
    
    def _extend_ttl(self, session: Session):
        """Extend session TTL on access."""
        now = int(time.time())
        session.last_accessed = now
        session.expires_at = now + self.default_ttl
        
        key = f"session:{session.session_id}"
        self.redis.setex(key, self.default_ttl, json.dumps(asdict(session)))
    
    def update(self, session_id: str, **updates) -> Optional[Session]:
        """Update session data."""
        session = self.get(session_id)
        if not session:
            return None
        
        # Apply updates
        for key, value in updates.items():
            if hasattr(session, key):
                setattr(session, key, value)
            else:
                session.data[key] = value
        
        session.last_accessed = int(time.time())
        
        key = f"session:{session_id}"
        ttl = self.redis.ttl(key)
        self.redis.setex(key, max(ttl, 60), json.dumps(asdict(session)))
        
        return session
    
    def delete(self, session_id: str) -> bool:
        """Delete a session (logout)."""
        session = self.get(session_id)
        if not session:
            return False
        
        # Remove from user index
        self.redis.srem(f"user_sessions:{session.user_id}", session_id)
        
        # Delete session
        key = f"session:{session_id}"
        return self.redis.delete(key) > 0
    
    def delete_all_for_user(self, user_id: str) -> int:
        """Delete all sessions for a user (logout all devices)."""
        session_ids = self.redis.smembers(f"user_sessions:{user_id}")
        
        if not session_ids:
            return 0
        
        # Delete all sessions
        pipe = self.redis.pipeline()
        for sid in session_ids:
            pipe.delete(f"session:{sid}")
        pipe.delete(f"user_sessions:{user_id}")
        results = pipe.execute()
        
        return sum(1 for r in results[:-1] if r > 0)
    
    def validate(self, session_id: str, csrf_token: Optional[str] = None) -> bool:
        """Validate a session exists and optionally verify CSRF token."""
        session = self.get(session_id)
        if not session:
            return False
        
        if csrf_token and session.csrf_token != csrf_token:
            return False
        
        return True

4.7 Performance Optimizations

Optimization 1: Connection Pooling

Reuse connections instead of creating new ones per request:

# Redis cluster with connection pooling
redis_cluster = redis.RedisCluster(
    host='redis-cluster.example.com',
    port=6379,
    max_connections=100,
    socket_timeout=1.0,
    socket_connect_timeout=1.0
)

Optimization 2: Local Caching

Cache hot sessions locally to avoid network round-trips:

from cachetools import TTLCache
import threading

class CachedSessionStore:
    def __init__(self, session_store: SessionStore, local_ttl: int = 5):
        self.store = session_store
        self.cache = TTLCache(maxsize=10000, ttl=local_ttl)
        self.lock = threading.Lock()
    
    def get(self, session_id: str) -> Optional[Session]:
        # Check local cache
        with self.lock:
            if session_id in self.cache:
                return self.cache[session_id]
        
        # Cache miss - fetch from Redis
        session = self.store.get(session_id)
        
        if session:
            with self.lock:
                self.cache[session_id] = session
        
        return session
    
    def invalidate(self, session_id: str):
        """Invalidate local cache entry."""
        with self.lock:
            self.cache.pop(session_id, None)
    
    # Write operations bypass cache and invalidate
    def update(self, session_id: str, **updates) -> Optional[Session]:
        self.invalidate(session_id)
        return self.store.update(session_id, **updates)
    
    def delete(self, session_id: str) -> bool:
        self.invalidate(session_id)
        return self.store.delete(session_id)

Trade-off: Local cache may serve stale data for up to local_ttl seconds. For sessions, this is usually acceptable—permissions changes can tolerate a few seconds delay.

Optimization 3: Batch Operations

For endpoints that need multiple sessions (admin dashboards):

def get_sessions_batch(self, session_ids: list[str]) -> dict[str, Session]:
    """Fetch multiple sessions in one round-trip."""
    pipe = self.redis.pipeline()
    
    for sid in session_ids:
        pipe.get(f"session:{sid}")
    
    results = pipe.execute()
    
    sessions = {}
    for sid, data in zip(session_ids, results):
        if data:
            sessions[sid] = Session(**json.loads(data))
    
    return sessions

Optimization 4: Compression for Large Sessions

If sessions grow large (>10KB), compress them:

import zlib

class CompressedSessionStore:
    COMPRESSION_THRESHOLD = 1024  # bytes
    
    def _serialize(self, session: Session) -> bytes:
        data = json.dumps(asdict(session)).encode()
        if len(data) > self.COMPRESSION_THRESHOLD:
            return b'Z' + zlib.compress(data)
        return b'R' + data
    
    def _deserialize(self, data: bytes) -> Session:
        if data[0:1] == b'Z':
            data = zlib.decompress(data[1:])
        else:
            data = data[1:]
        return Session(**json.loads(data))

Chapter 5: Multi-Datacenter Design

5.1 The Challenge

You have users globally. A single-region session store means:

  • High latency for distant users
  • Single point of failure
  • Compliance issues (data residency)

5.2 Architecture Options

Option 1: Active-Passive (DR)

One primary region, one standby:

US-East (Primary)           US-West (Standby)
┌─────────────────┐         ┌─────────────────┐
│  Redis Cluster  │────────▶│  Redis Cluster  │
│   (Read/Write)  │  async  │   (Read-only)   │
└─────────────────┘  repl   └─────────────────┘
        │
        ▼
   All traffic

Failover: Promote US-West to primary, redirect traffic.

Problems:

  • US-West users always have high latency
  • Async replication = data loss on failover

Option 2: Active-Active (Multi-Master)

Both regions accept writes:

US-East                     US-West
┌─────────────────┐         ┌─────────────────┐
│  Redis Cluster  │◀───────▶│  Redis Cluster  │
│  (Read/Write)   │  bi-dir │  (Read/Write)   │
└─────────────────┘  repl   └─────────────────┘
        │                           │
        ▼                           ▼
   US-East users              US-West users

Problems:

  • Conflict resolution for same session updated in both regions
  • More complex replication

Option 3: Session Affinity by Region

Sessions are created in and served from one region:

US-East                     US-West
┌─────────────────┐         ┌─────────────────┐
│  Redis Cluster  │         │  Redis Cluster  │
│ Sessions: A,B,C │         │ Sessions: X,Y,Z │
└─────────────────┘         └─────────────────┘
        │                           │
        ▼                           ▼
   Users A,B,C                Users X,Y,Z
   (created here)             (created here)

How it works:

  • Session ID encodes region: sess_east_abc123
  • Requests routed to session's home region
  • User always goes to same region for that session

This is the recommended approach for most applications.

5.3 Implementing Region-Aware Sessions

from enum import Enum
from typing import Optional
import re

class Region(Enum):
    US_EAST = "use1"
    US_WEST = "usw2"
    EU_WEST = "euw1"
    AP_SOUTH = "aps1"

class MultiRegionSessionStore:
    def __init__(
        self,
        local_region: Region,
        redis_clients: dict[Region, redis.RedisCluster]
    ):
        self.local_region = local_region
        self.clients = redis_clients
        self.local_store = SessionStore(redis_clients[local_region])
    
    def create(self, user_id: str, **kwargs) -> Session:
        """Create session in local region."""
        session = self.local_store.create(user_id, **kwargs)
        
        # Encode region in session ID
        session.session_id = f"sess_{self.local_region.value}_{session.session_id[5:]}"
        
        # Re-save with region-prefixed ID
        key = f"session:{session.session_id}"
        self.clients[self.local_region].setex(
            key, 
            self.local_store.default_ttl, 
            json.dumps(asdict(session))
        )
        
        return session
    
    def get(self, session_id: str) -> Optional[Session]:
        """Get session from appropriate region."""
        region = self._extract_region(session_id)
        
        if region == self.local_region:
            return self.local_store.get(session_id)
        
        # Remote region - need cross-region call
        remote_client = self.clients.get(region)
        if not remote_client:
            return None
        
        key = f"session:{session_id}"
        data = remote_client.get(key)
        
        if data:
            return Session(**json.loads(data))
        return None
    
    def _extract_region(self, session_id: str) -> Region:
        """Extract region from session ID."""
        # Format: sess_{region}_{random}
        match = re.match(r'sess_([a-z0-9]+)_', session_id)
        if match:
            region_code = match.group(1)
            for region in Region:
                if region.value == region_code:
                    return region
        return self.local_region  # Default to local
    
    def should_redirect(self, session_id: str) -> Optional[str]:
        """
        Check if request should be redirected to session's home region.
        Returns redirect URL or None.
        """
        region = self._extract_region(session_id)
        if region != self.local_region:
            return self._get_region_url(region)
        return None
    
    def _get_region_url(self, region: Region) -> str:
        return {
            Region.US_EAST: "https://us-east.example.com",
            Region.US_WEST: "https://us-west.example.com",
            Region.EU_WEST: "https://eu-west.example.com",
            Region.AP_SOUTH: "https://ap-south.example.com"
        }[region]

5.4 The Datacenter Failover Question

Scenario: US-East datacenter goes completely offline. What happens to sessions?

Analysis:

  1. Sessions created in US-East are inaccessible

    • Session data is in US-East Redis
    • Other regions can't read it
  2. Users from US-East need to re-authenticate

    • Their sessions are gone (or inaccessible)
    • This is acceptable for most applications
  3. Options for mitigation:

    Option A: Accept session loss

    • Users log in again
    • Simplest approach
    • Acceptable if login is easy (SSO, remember-me)

    Option B: Cross-region replication

    • Replicate sessions to another region
    • On failover, sessions available in backup
    • More complex, higher cost

    Option C: Session reconstruction

    • Store minimal auth in encrypted cookie
    • Reconstruct session from user database
    • Balance between A and B

My recommendation for 99.99% availability target:

class ResilientSessionStore:
    """
    Session store with cross-region backup for critical sessions.
    """
    
    def __init__(
        self,
        local_region: Region,
        backup_region: Region,
        redis_clients: dict[Region, redis.RedisCluster]
    ):
        self.local = redis_clients[local_region]
        self.backup = redis_clients[backup_region]
        self.local_region = local_region
        self.backup_region = backup_region
    
    def create(self, user_id: str, **kwargs) -> Session:
        session = self._create_session(user_id, **kwargs)
        
        # Write to local
        self._save(self.local, session)
        
        # Async replicate to backup
        threading.Thread(
            target=self._save,
            args=(self.backup, session),
            daemon=True
        ).start()
        
        return session
    
    def get(self, session_id: str) -> Optional[Session]:
        # Try local first
        session = self._load(self.local, session_id)
        if session:
            return session
        
        # Try backup (maybe we're recovering from local failure)
        session = self._load(self.backup, session_id)
        if session:
            # Restore to local
            self._save(self.local, session)
        
        return session
    
    def _save(self, client, session: Session):
        key = f"session:{session.session_id}"
        client.setex(key, 86400, json.dumps(asdict(session)))
    
    def _load(self, client, session_id: str) -> Optional[Session]:
        try:
            key = f"session:{session_id}"
            data = client.get(key)
            return Session(**json.loads(data)) if data else None
        except redis.RedisError:
            return None

Part III: Technology Comparison

Chapter 6: Redis Cluster vs DynamoDB vs Custom

You need to choose a technology. Let's compare the options.

6.1 Redis Cluster

Architecture: In-memory key-value store with clustering and replication.

Strengths:

  • ✅ Sub-millisecond latency
  • ✅ Rich data structures (useful for complex sessions)
  • ✅ Built-in clustering and replication
  • ✅ TTL support (perfect for sessions)
  • ✅ Pub/Sub for session invalidation

Weaknesses:

  • ❌ Memory-only (data loss on restart without persistence)
  • ❌ Single-threaded per shard (CPU-bound at extreme scale)
  • ❌ Operational complexity (managing cluster)
  • ❌ Cross-region replication requires additional tooling

Cost estimate (AWS ElastiCache):

6 shards × 2 nodes × cache.r6g.large = 12 nodes
12 × $0.126/hour × 730 hours/month = ~$1,100/month

6.2 Amazon DynamoDB

Architecture: Managed NoSQL database with automatic scaling.

Strengths:

  • ✅ Fully managed (no operational burden)
  • ✅ Automatic scaling
  • ✅ Global Tables for multi-region
  • ✅ Built-in TTL
  • ✅ Highly durable (replicated across AZs)

Weaknesses:

  • ❌ Higher latency than Redis (5-10ms vs 1ms)
  • ❌ Cost can spike with high throughput
  • ❌ Less flexible than Redis (no complex data structures)
  • ❌ Consistency model complexity (eventual by default)

Cost estimate (DynamoDB):

10M sessions, 2KB each = 20GB storage
3M reads/sec peak, 500K writes/sec peak

On-demand pricing:
  Reads: 3M × $0.25/million = $0.75/sec (peak)
  Writes: 500K × $1.25/million = $0.625/sec (peak)
  
Provisioned (more predictable):
  Read capacity: 3M RCU × $0.00013/hour = ~$390/hour (peak)
  
This gets expensive fast. Use DAX caching.

6.3 Custom Solution (PostgreSQL + Caching)

Architecture: Sessions in PostgreSQL with Redis/Memcached caching layer.

Strengths:

  • ✅ Use existing infrastructure
  • ✅ Full SQL capabilities (complex queries)
  • ✅ Strong durability
  • ✅ Familiar operational model

Weaknesses:

  • ❌ More complex architecture (two systems)
  • ❌ Cache invalidation complexity
  • ❌ Higher latency without cache
  • ❌ PostgreSQL not designed for this access pattern

Best for: When you already have PostgreSQL and don't want another system.

6.4 Decision Matrix

Factor Redis DynamoDB Custom (PG+Cache)
Latency ⭐⭐⭐ ⭐⭐ ⭐⭐ (with cache)
Operational burden ⭐⭐ ⭐⭐⭐
Cost (at scale) ⭐⭐ ⭐⭐
Multi-region ⭐⭐ ⭐⭐⭐
Durability ⭐⭐⭐ ⭐⭐⭐
Flexibility ⭐⭐⭐ ⭐⭐ ⭐⭐⭐

6.5 My Recommendation

For most applications: Redis Cluster

Reasons:

  1. Sessions are ephemeral—durability is nice but not critical
  2. Sub-millisecond latency matters for every request
  3. TTL support is exactly what sessions need
  4. Operational complexity is manageable with managed services (ElastiCache, Redis Cloud)

Exception: Use DynamoDB Global Tables when:

  • Multi-region with write availability in each region is critical
  • You want zero operational burden
  • Slightly higher latency is acceptable

Exception: Use Custom when:

  • You have strict data residency requirements
  • You already have PostgreSQL and Redis
  • Sessions need complex querying (admin features)

Chapter 7: Decision Document Template

Here's a template for documenting your technology choice:

# Session Store Technology Decision

## Context
We need a session store for 10M concurrent users with <5ms P99 latency
and 99.99% availability.

## Decision
We will use **Redis Cluster** via AWS ElastiCache.

## Rationale

### Why Redis
1. **Latency**: Sub-millisecond reads meet our <5ms requirement with margin
2. **TTL**: Native TTL support matches session lifecycle
3. **Proven**: Battle-tested for sessions at companies like GitHub, Twitter

### Why Not DynamoDB
1. **Latency**: 5-10ms baseline doesn't leave margin for our 5ms target
2. **Cost**: At 3M reads/sec, on-demand pricing is prohibitive
3. **Complexity**: DAX required to match Redis latency, adding another layer

### Why Not Custom
1. **Operational burden**: Managing PostgreSQL + Redis is more work than managed Redis
2. **Not needed**: We don't require SQL capabilities for sessions

## Architecture
- 6 shards with 1 replica each (12 nodes total)
- ElastiCache for Redis 7.0 (latest stable)
- Multi-AZ deployment for high availability
- Local application caching (5-second TTL) to reduce load

## Multi-Region Strategy
- Primary region: US-East
- Sessions are region-affine (encoded in session ID)
- Cross-region reads for mobile users who travel
- Async backup replication to US-West for DR

## Failure Handling
- Redis node failure: Automatic failover to replica (<30 seconds)
- Datacenter failure: Users re-authenticate (acceptable UX)
- Full region failure: DR failover to US-West (manual, 15-minute RTO)

## Cost Estimate
- ElastiCache: $1,100/month
- Data transfer: ~$200/month
- Total: ~$1,300/month

## Monitoring
- Latency: Alert if P99 > 3ms
- Memory: Alert if > 80% utilization
- Replication lag: Alert if > 1 second
- Connection count: Alert if > 80% of max

## Review Date
Re-evaluate in 6 months or when reaching 50M concurrent sessions.

Part IV: Advanced Topics

Chapter 8: Security Considerations

8.1 Session ID Security

Session IDs must be unpredictable to prevent session hijacking:

import secrets

def generate_session_id() -> str:
    # 256 bits of randomness = practically unguessable
    return f"sess_{secrets.token_urlsafe(32)}"

# Good: sess_7Hj2kL9mNpQrStUvWxYz1234567890abcdef
# Bad:  sess_1, sess_2, sess_3 (sequential = guessable)

8.2 Session Fixation Prevention

Don't accept session IDs from clients before authentication:

def login(username: str, password: str, old_session_id: Optional[str]) -> Session:
    user = authenticate(username, password)
    
    if old_session_id:
        # Destroy old session to prevent fixation
        session_store.delete(old_session_id)
    
    # Create new session with new ID
    return session_store.create(user.id)
response.set_cookie(
    'session_id',
    session.session_id,
    httponly=True,      # JavaScript can't access
    secure=True,        # HTTPS only
    samesite='Lax',     # CSRF protection
    max_age=86400,      # 24 hours
    domain='.example.com'  # Subdomain sharing if needed
)

8.4 Session Data Encryption

For sensitive data in sessions:

from cryptography.fernet import Fernet

class EncryptedSessionStore:
    def __init__(self, session_store: SessionStore, encryption_key: bytes):
        self.store = session_store
        self.cipher = Fernet(encryption_key)
    
    def create(self, user_id: str, **kwargs) -> Session:
        session = self.store.create(user_id, **kwargs)
        
        # Encrypt sensitive fields
        if 'data' in kwargs:
            encrypted = self.cipher.encrypt(json.dumps(kwargs['data']).encode())
            session.data = {'_encrypted': encrypted.decode()}
            self.store.update(session.session_id, data=session.data)
        
        return session
    
    def get(self, session_id: str) -> Optional[Session]:
        session = self.store.get(session_id)
        if session and '_encrypted' in session.data:
            decrypted = self.cipher.decrypt(session.data['_encrypted'].encode())
            session.data = json.loads(decrypted)
        return session

Chapter 9: Monitoring and Observability

9.1 Key Metrics

from prometheus_client import Counter, Histogram, Gauge

# Operation counts
session_operations = Counter(
    'session_operations_total',
    'Session store operations',
    ['operation', 'status']  # operation: create/get/update/delete, status: success/failure
)

# Latency
session_latency = Histogram(
    'session_operation_seconds',
    'Session operation latency',
    ['operation'],
    buckets=[.001, .0025, .005, .01, .025, .05, .1, .25, .5, 1]
)

# Active sessions
active_sessions = Gauge(
    'active_sessions_total',
    'Number of active sessions'
)

# Cache hit rate
cache_hits = Counter(
    'session_cache_hits_total',
    'Local cache hits'
)
cache_misses = Counter(
    'session_cache_misses_total',
    'Local cache misses'
)

9.2 Health Checks

async def session_store_health() -> dict:
    """Health check for session store."""
    results = {
        'status': 'healthy',
        'checks': {}
    }
    
    # Check Redis connectivity
    try:
        start = time.time()
        await redis.ping()
        latency = (time.time() - start) * 1000
        
        results['checks']['redis_ping'] = {
            'status': 'pass',
            'latency_ms': latency
        }
    except Exception as e:
        results['status'] = 'unhealthy'
        results['checks']['redis_ping'] = {
            'status': 'fail',
            'error': str(e)
        }
    
    # Check read/write
    try:
        test_key = f"health_check_{uuid.uuid4()}"
        await redis.setex(test_key, 10, 'test')
        value = await redis.get(test_key)
        await redis.delete(test_key)
        
        results['checks']['redis_readwrite'] = {'status': 'pass'}
    except Exception as e:
        results['status'] = 'unhealthy'
        results['checks']['redis_readwrite'] = {
            'status': 'fail',
            'error': str(e)
        }
    
    return results

9.3 Alerting Rules

groups:
  - name: session_store
    rules:
      - alert: SessionStoreHighLatency
        expr: histogram_quantile(0.99, rate(session_operation_seconds_bucket[5m])) > 0.005
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Session store P99 latency > 5ms"
      
      - alert: SessionStoreErrors
        expr: rate(session_operations_total{status="failure"}[5m]) > 0.01
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Session store error rate > 1%"
      
      - alert: SessionStoreCacheLow
        expr: rate(session_cache_hits_total[5m]) / (rate(session_cache_hits_total[5m]) + rate(session_cache_misses_total[5m])) < 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Session cache hit rate below 80%"

Part V: Interview Questions and Answers

Chapter 10: Real-World Interview Scenarios

10.1 Conceptual Questions

Question 1: "Compare sticky sessions vs distributed sessions."

Interviewer's Intent: Testing understanding of trade-offs.

Strong Answer:

"Sticky sessions route all requests from one user to the same server, storing sessions in local memory. Distributed sessions store sessions in an external system accessible by all servers.

Sticky sessions are simpler and faster (no network hop), but have critical weaknesses: server failure loses all its sessions, scaling is harder, and load can be uneven.

Distributed sessions require infrastructure (Redis, DynamoDB) but provide resilience—server failures don't affect sessions, scaling is straightforward, and load balancing is simple.

For production at scale, I'd almost always choose distributed sessions. The operational benefits far outweigh the small latency cost. The exception is WebSocket applications where connection affinity is required—but even then, I'd store session data externally and only keep connection state locally."


Question 2: "How do you handle session invalidation across multiple servers?"

Interviewer's Intent: Testing understanding of consistency challenges.

Strong Answer:

"With distributed sessions in Redis, invalidation is straightforward—delete the key, and all servers will see the session is gone on their next request.

The challenge comes with local caching. If each app server caches sessions locally for performance, deleting from Redis doesn't immediately invalidate local caches.

Three solutions:

  1. Short TTL on local cache: 5-10 seconds. Stale sessions expire quickly. Simple but not immediate.

  2. Pub/Sub invalidation: Publish invalidation message when session deleted. All servers subscribe and clear local cache. Near-immediate but requires reliable pub/sub.

  3. Versioning: Store version in Redis. Local cache checks version on each read. If mismatched, refetch. Accurate but adds a Redis call.

I'd typically use short TTL (5 seconds) because it's simple and good enough. For security-critical scenarios (user revokes access), I'd add pub/sub for immediate invalidation."


Question 3: "What happens to sessions during a datacenter failover?"

Interviewer's Intent: Testing disaster recovery thinking.

Strong Answer:

"It depends on the architecture. Let me walk through options:

Single-region with no replication: All sessions are lost. Users must re-authenticate. This is often acceptable—inconvenient but not catastrophic.

Active-passive with replication: Sessions replicated to standby region. On failover, sessions are available. But there's a window (replication lag) where recent sessions might be lost.

Active-active with region-affine sessions: Each session lives in one region. If US-East fails, US-East sessions are gone, but US-West sessions are fine. Users can re-authenticate against US-West and get new sessions.

For 99.99% availability, I'd use region-affine sessions with optional cross-region backup for critical sessions. The backup is async—we accept potential data loss for simplicity. We make re-authentication fast (SSO, remember-me cookies) so session loss isn't painful.

Key insight: Sessions are ephemeral by nature. Designing for zero session loss adds significant complexity. It's often better to make session recreation seamless than to guarantee session survival."


10.2 Design Questions

Question 4: "Design a session store for 10M concurrent users."

Interviewer's Intent: Testing end-to-end system design.

Strong Answer:

"Let me start with requirements and scale analysis.

Scale analysis:

  • 10M sessions × 2KB = 20GB storage
  • 2-3M reads/sec (each user makes ~1 req/sec when active)
  • 500K writes/sec (TTL extensions, updates)
  • <5ms P99 latency target

Storage choice: Redis Cluster. It provides sub-millisecond latency, native TTL, and can handle this load comfortably. DynamoDB would work but has higher latency.

Architecture:

App Servers (with local cache)
         │
         ▼
    Redis Cluster
    (6 shards × 2 replicas)

Partitioning: Hash by session_id across 6 shards. Each shard handles ~3.3GB and ~400K reads/sec—well within Redis capacity.

Replication: Each shard has one replica. Async replication for performance. Automatic failover if primary dies.

Local caching: 5-second TTL cache on app servers. Reduces Redis load by 80%+ for read-heavy traffic. Accept stale reads for that window.

Session operations:

  • Create: Generate secure random ID, store in Redis with TTL
  • Get: Check local cache → Redis → return or null
  • Update: Update Redis, invalidate local cache
  • Delete: Delete from Redis, broadcast invalidation

Multi-region (if needed): Region-encoded session IDs. Sessions served from home region. Cross-region backup for DR.

Failure handling:

  • Redis node fails: Automatic replica promotion (~30 sec)
  • App server fails: No impact (sessions in Redis)
  • Region fails: Users re-authenticate (acceptable)

Monitoring: Latency percentiles, cache hit rate, error rate, memory utilization."


Question 5: "How would you implement 'logout from all devices'?"

Interviewer's Intent: Testing detailed implementation thinking.

Strong Answer:

"This requires tracking which sessions belong to which user. Here's my approach:

Data model:

  • session:{session_id} → session data
  • user_sessions:{user_id} → set of session IDs

On session create:

redis.setex(f'session:{session_id}', TTL, session_data)
redis.sadd(f'user_sessions:{user_id}', session_id)
redis.expire(f'user_sessions:{user_id}', TTL)

On logout all devices:

session_ids = redis.smembers(f'user_sessions:{user_id}')
pipe = redis.pipeline()
for sid in session_ids:
    pipe.delete(f'session:{sid}')
pipe.delete(f'user_sessions:{user_id}')
pipe.execute()

Considerations:

  1. Atomicity: Use a transaction or Lua script if needed, but for sessions, eventual consistency is fine.

  2. Performance: If a user has 100+ sessions (many devices over time), this is still fast—Redis pipeline handles it efficiently.

  3. TTL sync: The user_sessions set should have same TTL as sessions. Otherwise, it accumulates expired session IDs.

  4. Alternative approach: Instead of deleting sessions, we could increment a 'session_version' for the user. Session validation checks if session's version matches current. Cheaper for many sessions but requires extra lookup.

For most applications, the straightforward delete approach works fine. The version approach is for extreme cases (users with thousands of sessions)."


10.3 Scenario-Based Questions

Question 6: "Your Redis session store is running out of memory. What do you do?"

Interviewer's Intent: Testing operational problem-solving.

Strong Answer:

"Immediate triage:

  1. Check memory metrics: Is it a sudden spike or gradual growth? Spike suggests a specific cause (bug, attack); gradual suggests capacity planning issue.

  2. Check eviction policy: If maxmemory-policy is noeviction, Redis will reject writes. If it's volatile-lru, old sessions should be evicting. Check eviction stats.

Immediate mitigations:

  1. Enable eviction if disabled: Set maxmemory-policy volatile-lru to evict sessions by LRU. Sessions have TTL, so this is safe.

  2. Reduce session TTL temporarily: If sessions are 24 hours, reduce to 12 hours. Forces logout but buys time.

  3. Flush expired keys: Run SCAN with TTL check to delete any zombie sessions that aren't expiring properly.

  4. Add capacity: Add more shards if this is a capacity issue.

Root cause investigation:

  1. Session size growth: Are sessions getting bigger? Check average size over time.

  2. Session count growth: More users, or sessions not expiring?

  3. Memory fragmentation: Redis can have fragmentation issues. Check INFO memory for fragmentation ratio.

  4. Memory leak: Application bug storing too much in sessions?

Long-term fixes:

  1. Right-size sessions: Review what's stored. Move large data (carts, preferences) to dedicated storage.

  2. Compression: Compress sessions over 1KB.

  3. Capacity planning: Monitor growth rate, plan scaling 3-6 months ahead.

  4. Alerting: Alert at 70% memory to catch before it's critical."


Question 7: "How would you migrate from sticky sessions to distributed sessions without downtime?"

Interviewer's Intent: Testing migration planning.

Strong Answer:

"This is a stateful migration requiring careful orchestration. Here's my approach:

Phase 1: Dual-write (1-2 weeks)

Modify session handling to write to both locations:

def create_session(user_id):
    # Write to local (existing)
    local_session = create_local_session(user_id)
    
    # Also write to Redis (new)
    redis_session = copy_to_redis(local_session)
    
    return local_session

All new sessions exist in both systems.

Phase 2: Read-through (1 week)

On session read, try local first, then Redis:

def get_session(session_id):
    # Try local first (existing sessions)
    session = get_local_session(session_id)
    if session:
        # Ensure it's in Redis too
        if not redis_has_session(session_id):
            copy_to_redis(session)
        return session
    
    # Fall back to Redis (new sessions)
    return get_redis_session(session_id)

Existing sessions migrate to Redis on access.

Phase 3: Remove sticky routing (traffic switch)

Gradually shift traffic:

  1. 10% of new users get non-sticky routing
  2. Monitor for issues
  3. Increase to 50%, then 100%

Old sticky users still work—their sessions are in Redis now.

Phase 4: Cleanup (after old sessions expire)

Remove local session code. Full cutover complete.

Rollback plan:

At any phase, we can roll back:

  • Phase 1-2: Stop writing to Redis, continue local
  • Phase 3: Re-enable sticky routing

Key insight: The dual-write phase means we never lose sessions. The gradual rollout catches issues early."


10.4 Deep-Dive Questions

Question 8: "Compare Redis Cluster vs DynamoDB for session storage."

Interviewer's Intent: Testing technology depth.

Strong Answer:

"Both can work. Let me compare on key dimensions:

Latency:

  • Redis: Sub-millisecond (0.2-0.5ms typical)
  • DynamoDB: 5-10ms (single-digit millisecond)
  • Winner: Redis, by 10-20x

Scalability:

  • Redis: Manual sharding, can handle millions of ops/sec with enough shards
  • DynamoDB: Automatic scaling, essentially unlimited
  • Winner: DynamoDB for simplicity

Durability:

  • Redis: Memory-only by default. AOF persistence adds durability but increases latency
  • DynamoDB: Fully durable, synchronously replicated across AZs
  • Winner: DynamoDB

Multi-region:

  • Redis: Requires manual setup (Redis Enterprise or custom)
  • DynamoDB: Global Tables with built-in replication
  • Winner: DynamoDB

Cost:

  • Redis: Predictable (pay per node)
  • DynamoDB: Variable (pay per operation). Can spike with traffic
  • Winner: Redis for predictability at scale

Operational burden:

  • Redis: Cluster management, failover monitoring
  • DynamoDB: Fully managed
  • Winner: DynamoDB

My recommendation:

For sessions, I'd choose Redis because:

  1. Latency is critical (every request reads session)
  2. Sessions are ephemeral—durability is nice but not essential
  3. Cost is predictable at high scale

I'd choose DynamoDB when:

  1. Multi-region write availability is required
  2. Operations team is small (want fully managed)
  3. Latency SLA is 10ms+ (more relaxed)

For truly global applications needing writes in multiple regions, DynamoDB Global Tables is hard to beat. For single-region or read-heavy multi-region, Redis is faster and cheaper."


Question 9: "How would you implement session encryption at rest and in transit?"

Interviewer's Intent: Testing security knowledge.

Strong Answer:

"Let me address both separately:

In transit (network encryption):

For Redis:

  • Use TLS connections between app servers and Redis
  • ElastiCache supports in-transit encryption natively
  • Performance impact: ~10-20% latency increase, worth it for security
redis_client = redis.RedisCluster(
    host='redis.example.com',
    port=6379,
    ssl=True,
    ssl_cert_reqs='required',
    ssl_ca_certs='/path/to/ca.pem'
)

At rest (storage encryption):

Option 1: Redis encryption at rest (infrastructure level)

  • ElastiCache supports encryption at rest
  • Transparent to application
  • Keys managed by AWS KMS

Option 2: Application-level encryption

  • Encrypt session data before storing in Redis
  • More control, works with any Redis
from cryptography.fernet import Fernet

class EncryptedSessionStore:
    def __init__(self, redis_client, key: bytes):
        self.redis = redis_client
        self.cipher = Fernet(key)
    
    def set(self, session_id: str, data: dict, ttl: int):
        plaintext = json.dumps(data).encode()
        ciphertext = self.cipher.encrypt(plaintext)
        self.redis.setex(f'session:{session_id}', ttl, ciphertext)
    
    def get(self, session_id: str) -> dict:
        ciphertext = self.redis.get(f'session:{session_id}')
        if not ciphertext:
            return None
        plaintext = self.cipher.decrypt(ciphertext)
        return json.loads(plaintext)

Key management:

  • Store encryption key in secrets manager (AWS Secrets Manager, Vault)
  • Rotate keys periodically
  • For rotation: support decrypting with old key, encrypt with new key

Recommendation:

Use infrastructure-level encryption for both transit and rest—simpler, no code changes, managed key rotation. Add application-level encryption only for extremely sensitive data that shouldn't be visible even to infrastructure admins."


Chapter 11: Week 1 Summary

11.1 What You've Learned This Week

Day Topic Key Concepts
1 Partitioning Hash vs Range vs Directory, Consistent Hashing, Hot Partition mitigation
2 Replication Leader-follower vs Multi-leader, Sync vs Async, Failover handling
3 Rate Limiting Sliding window, Token bucket, Distributed limiting, Failure modes
4 Hot Keys Detection (CMS), Mitigation strategies, Fan-out patterns
5 Session Store Sticky vs Distributed, Multi-region, Technology comparison

11.2 How They Connect

The session store design uses everything:

  • Partitioning: Sessions distributed across Redis shards by session_id hash
  • Replication: Each shard has replicas for availability
  • Rate Limiting: Protect session store from abuse (many session creates)
  • Hot Keys: Handle celebrity users with many concurrent sessions

11.3 Interview Preparation Checklist

Before your interview, make sure you can:

Partitioning:

  • Explain hash vs range partitioning trade-offs
  • Implement consistent hashing
  • Handle partition rebalancing

Replication:

  • Compare sync vs async replication
  • Design failover procedures
  • Handle replication lag anomalies

Rate Limiting:

  • Implement sliding window algorithm
  • Design distributed rate limiting
  • Handle rate limiter failures

Hot Keys:

  • Detect hot keys with streaming algorithms
  • Apply caching, splitting, replication strategies
  • Design for skewed traffic patterns

Session Store:

  • Compare sticky vs distributed sessions
  • Design for multi-datacenter
  • Choose appropriate technology with justification

Exercises

Exercise 1: Session Store Implementation

Implement a complete session store with:

  • Creation, retrieval, update, deletion
  • TTL extension on access
  • User session index (for logout all devices)
  • Local caching with invalidation
  • Metrics collection

Exercise 2: Multi-Region Design

Design the session management for a global application:

  • Users in US, EU, and Asia
  • 99.99% availability requirement
  • <10ms P99 latency in each region
  • Compliance with GDPR (EU data stays in EU)

Document your architecture, data flow, and failure handling.

Exercise 3: Migration Plan

You're migrating from PHP sessions (file-based) to Redis Cluster:

  • 5M active sessions
  • Zero downtime requirement
  • Rollback capability

Write a detailed migration plan with phases, rollback procedures, and success criteria.


Further Reading

  • Redis Cluster specification: Official documentation on Redis Cluster
  • "Scaling to 100M users" by Alex Xu: Chapter on session management
  • DynamoDB Global Tables: AWS documentation on multi-region
  • Netflix session management: Blog posts on their approach
  • Shopify session architecture: How they handle massive scale

Appendix: Complete Session Store Implementation

A.1 Production-Ready Session Store

"""
Production session store implementation tying together all Week 1 concepts.
"""

import json
import secrets
import time
import threading
import logging
from dataclasses import dataclass, asdict, field
from typing import Optional, Dict, Set, Any
from enum import Enum
import redis
from cachetools import TTLCache
from prometheus_client import Counter, Histogram, Gauge

# ============================================================================
# Metrics
# ============================================================================

session_ops = Counter(
    'session_operations_total',
    'Session store operations',
    ['operation', 'status']
)

session_latency = Histogram(
    'session_operation_seconds',
    'Session operation latency',
    ['operation'],
    buckets=[.0005, .001, .0025, .005, .01, .025, .05, .1]
)

cache_stats = Counter(
    'session_cache_total',
    'Session cache statistics',
    ['result']  # hit, miss
)

active_sessions = Gauge(
    'active_sessions_total',
    'Estimated active sessions'
)

# ============================================================================
# Data Models
# ============================================================================

@dataclass
class Session:
    session_id: str
    user_id: str
    created_at: int
    last_accessed: int
    expires_at: int
    roles: list = field(default_factory=list)
    permissions: list = field(default_factory=list)
    locale: str = 'en-US'
    timezone: str = 'UTC'
    csrf_token: str = ''
    data: dict = field(default_factory=dict)
    
    def is_expired(self) -> bool:
        return time.time() > self.expires_at
    
    def to_dict(self) -> dict:
        return asdict(self)
    
    @classmethod
    def from_dict(cls, data: dict) -> 'Session':
        return cls(**data)

class Region(Enum):
    US_EAST = "use1"
    US_WEST = "usw2"
    EU_WEST = "euw1"

# ============================================================================
# Session Store Implementation
# ============================================================================

class ProductionSessionStore:
    """
    Production session store with:
    - Redis Cluster backend
    - Local caching
    - Multi-region support
    - Comprehensive metrics
    - Security features
    """
    
    def __init__(
        self,
        redis_cluster: redis.RedisCluster,
        region: Region = Region.US_EAST,
        default_ttl: int = 86400,
        local_cache_ttl: int = 5,
        local_cache_size: int = 10000,
        extend_on_access: bool = True
    ):
        self.redis = redis_cluster
        self.region = region
        self.default_ttl = default_ttl
        self.extend_on_access = extend_on_access
        
        # Local cache
        self.local_cache = TTLCache(maxsize=local_cache_size, ttl=local_cache_ttl)
        self.cache_lock = threading.Lock()
        
        # Invalidation via pub/sub
        self._start_invalidation_listener()
        
        self.logger = logging.getLogger('session_store')
    
    # -------------------------------------------------------------------------
    # Core Operations
    # -------------------------------------------------------------------------
    
    def create(
        self,
        user_id: str,
        roles: list = None,
        permissions: list = None,
        **kwargs
    ) -> Session:
        """Create a new session."""
        start = time.time()
        
        try:
            now = int(time.time())
            session = Session(
                session_id=self._generate_session_id(),
                user_id=user_id,
                created_at=now,
                last_accessed=now,
                expires_at=now + self.default_ttl,
                roles=roles or [],
                permissions=permissions or [],
                csrf_token=secrets.token_urlsafe(32),
                **kwargs
            )
            
            # Store in Redis
            self._store_session(session)
            
            # Add to user index
            self._add_to_user_index(session)
            
            session_ops.labels(operation='create', status='success').inc()
            return session
            
        except Exception as e:
            session_ops.labels(operation='create', status='failure').inc()
            self.logger.error(f"Failed to create session: {e}")
            raise
        
        finally:
            session_latency.labels(operation='create').observe(time.time() - start)
    
    def get(self, session_id: str) -> Optional[Session]:
        """Retrieve a session by ID."""
        start = time.time()
        
        try:
            # Check local cache first
            session = self._check_local_cache(session_id)
            if session:
                cache_stats.labels(result='hit').inc()
                session_ops.labels(operation='get', status='success').inc()
                return session
            
            cache_stats.labels(result='miss').inc()
            
            # Fetch from Redis
            session = self._fetch_from_redis(session_id)
            
            if session:
                if session.is_expired():
                    self.delete(session_id)
                    return None
                
                # Extend TTL if configured
                if self.extend_on_access:
                    self._extend_ttl(session)
                
                # Update local cache
                self._update_local_cache(session)
                
                session_ops.labels(operation='get', status='success').inc()
            else:
                session_ops.labels(operation='get', status='not_found').inc()
            
            return session
            
        except Exception as e:
            session_ops.labels(operation='get', status='failure').inc()
            self.logger.error(f"Failed to get session: {e}")
            return None
        
        finally:
            session_latency.labels(operation='get').observe(time.time() - start)
    
    def update(self, session_id: str, **updates) -> Optional[Session]:
        """Update session data."""
        start = time.time()
        
        try:
            session = self.get(session_id)
            if not session:
                return None
            
            # Apply updates
            for key, value in updates.items():
                if key == 'data':
                    session.data.update(value)
                elif hasattr(session, key):
                    setattr(session, key, value)
            
            session.last_accessed = int(time.time())
            
            # Store and invalidate cache
            self._store_session(session)
            self._invalidate_cache(session_id)
            
            session_ops.labels(operation='update', status='success').inc()
            return session
            
        except Exception as e:
            session_ops.labels(operation='update', status='failure').inc()
            self.logger.error(f"Failed to update session: {e}")
            raise
        
        finally:
            session_latency.labels(operation='update').observe(time.time() - start)
    
    def delete(self, session_id: str) -> bool:
        """Delete a session."""
        start = time.time()
        
        try:
            # Get session to find user_id
            session = self._fetch_from_redis(session_id)
            
            if session:
                # Remove from user index
                self.redis.srem(f"user_sessions:{session.user_id}", session_id)
            
            # Delete session
            result = self.redis.delete(f"session:{session_id}") > 0
            
            # Invalidate cache
            self._invalidate_cache(session_id)
            
            session_ops.labels(operation='delete', status='success').inc()
            return result
            
        except Exception as e:
            session_ops.labels(operation='delete', status='failure').inc()
            self.logger.error(f"Failed to delete session: {e}")
            return False
        
        finally:
            session_latency.labels(operation='delete').observe(time.time() - start)
    
    def delete_all_for_user(self, user_id: str) -> int:
        """Delete all sessions for a user (logout all devices)."""
        session_ids = self.redis.smembers(f"user_sessions:{user_id}")
        
        if not session_ids:
            return 0
        
        pipe = self.redis.pipeline()
        for sid in session_ids:
            sid_str = sid.decode() if isinstance(sid, bytes) else sid
            pipe.delete(f"session:{sid_str}")
            self._invalidate_cache(sid_str)
        
        pipe.delete(f"user_sessions:{user_id}")
        results = pipe.execute()
        
        return sum(1 for r in results[:-1] if r > 0)
    
    # -------------------------------------------------------------------------
    # Validation
    # -------------------------------------------------------------------------
    
    def validate(
        self,
        session_id: str,
        csrf_token: Optional[str] = None,
        required_roles: Optional[list] = None
    ) -> tuple[bool, Optional[str]]:
        """
        Validate a session.
        Returns (is_valid, error_message).
        """
        session = self.get(session_id)
        
        if not session:
            return False, "Session not found"
        
        if session.is_expired():
            return False, "Session expired"
        
        if csrf_token and session.csrf_token != csrf_token:
            return False, "Invalid CSRF token"
        
        if required_roles:
            if not set(required_roles).issubset(set(session.roles)):
                return False, "Insufficient permissions"
        
        return True, None
    
    # -------------------------------------------------------------------------
    # Internal Methods
    # -------------------------------------------------------------------------
    
    def _generate_session_id(self) -> str:
        """Generate a secure session ID with region prefix."""
        random_part = secrets.token_urlsafe(32)
        return f"sess_{self.region.value}_{random_part}"
    
    def _store_session(self, session: Session):
        """Store session in Redis."""
        key = f"session:{session.session_id}"
        ttl = session.expires_at - int(time.time())
        self.redis.setex(key, max(ttl, 1), json.dumps(session.to_dict()))
    
    def _fetch_from_redis(self, session_id: str) -> Optional[Session]:
        """Fetch session from Redis."""
        key = f"session:{session_id}"
        data = self.redis.get(key)
        
        if data:
            return Session.from_dict(json.loads(data))
        return None
    
    def _add_to_user_index(self, session: Session):
        """Add session to user's session index."""
        key = f"user_sessions:{session.user_id}"
        self.redis.sadd(key, session.session_id)
        self.redis.expire(key, self.default_ttl)
    
    def _extend_ttl(self, session: Session):
        """Extend session TTL."""
        now = int(time.time())
        session.last_accessed = now
        session.expires_at = now + self.default_ttl
        self._store_session(session)
    
    def _check_local_cache(self, session_id: str) -> Optional[Session]:
        """Check local cache for session."""
        with self.cache_lock:
            return self.local_cache.get(session_id)
    
    def _update_local_cache(self, session: Session):
        """Update local cache."""
        with self.cache_lock:
            self.local_cache[session.session_id] = session
    
    def _invalidate_cache(self, session_id: str):
        """Invalidate local cache and broadcast to other servers."""
        with self.cache_lock:
            self.local_cache.pop(session_id, None)
        
        # Broadcast invalidation
        try:
            self.redis.publish('session_invalidation', session_id)
        except Exception as e:
            self.logger.warning(f"Failed to broadcast invalidation: {e}")
    
    def _start_invalidation_listener(self):
        """Start pub/sub listener for cache invalidation."""
        def listen():
            try:
                pubsub = self.redis.pubsub()
                pubsub.subscribe('session_invalidation')
                
                for message in pubsub.listen():
                    if message['type'] == 'message':
                        session_id = message['data']
                        if isinstance(session_id, bytes):
                            session_id = session_id.decode()
                        
                        with self.cache_lock:
                            self.local_cache.pop(session_id, None)
            
            except Exception as e:
                self.logger.error(f"Invalidation listener error: {e}")
                # Reconnect after delay
                time.sleep(5)
                self._start_invalidation_listener()
        
        thread = threading.Thread(target=listen, daemon=True)
        thread.start()
    
    # -------------------------------------------------------------------------
    # Health Check
    # -------------------------------------------------------------------------
    
    def health_check(self) -> dict:
        """Check session store health."""
        result = {
            'status': 'healthy',
            'checks': {}
        }
        
        # Redis ping
        try:
            start = time.time()
            self.redis.ping()
            latency = (time.time() - start) * 1000
            
            result['checks']['redis_ping'] = {
                'status': 'pass',
                'latency_ms': round(latency, 2)
            }
        except Exception as e:
            result['status'] = 'unhealthy'
            result['checks']['redis_ping'] = {
                'status': 'fail',
                'error': str(e)
            }
        
        # Read/write test
        try:
            test_id = f"health_{secrets.token_hex(8)}"
            test_session = self.create(user_id='health_check')
            retrieved = self.get(test_session.session_id)
            self.delete(test_session.session_id)
            
            if retrieved and retrieved.user_id == 'health_check':
                result['checks']['read_write'] = {'status': 'pass'}
            else:
                result['status'] = 'unhealthy'
                result['checks']['read_write'] = {'status': 'fail'}
        except Exception as e:
            result['status'] = 'unhealthy'
            result['checks']['read_write'] = {
                'status': 'fail',
                'error': str(e)
            }
        
        return result


# ============================================================================
# Multi-Region Wrapper
# ============================================================================

class MultiRegionSessionStore:
    """
    Session store supporting multiple regions with session affinity.
    """
    
    def __init__(
        self,
        local_region: Region,
        stores: Dict[Region, ProductionSessionStore]
    ):
        self.local_region = local_region
        self.stores = stores
        self.local_store = stores[local_region]
    
    def create(self, user_id: str, **kwargs) -> Session:
        """Create session in local region."""
        return self.local_store.create(user_id, **kwargs)
    
    def get(self, session_id: str) -> Optional[Session]:
        """Get session from appropriate region."""
        region = self._extract_region(session_id)
        store = self.stores.get(region, self.local_store)
        return store.get(session_id)
    
    def _extract_region(self, session_id: str) -> Region:
        """Extract region from session ID."""
        # Format: sess_{region}_{random}
        parts = session_id.split('_')
        if len(parts) >= 2:
            region_code = parts[1]
            for region in Region:
                if region.value == region_code:
                    return region
        return self.local_region
    
    def get_redirect_url(self, session_id: str) -> Optional[str]:
        """Get redirect URL if session is in different region."""
        region = self._extract_region(session_id)
        if region != self.local_region:
            return self._region_urls.get(region)
        return None
    
    _region_urls = {
        Region.US_EAST: "https://us-east.example.com",
        Region.US_WEST: "https://us-west.example.com",
        Region.EU_WEST: "https://eu-west.example.com"
    }

End of Week 1: Foundations of Scale

Congratulations! You've completed Week 1. You now have a solid foundation in:

  • Data partitioning and distribution
  • Replication and consistency
  • Protection mechanisms (rate limiting)
  • Handling real-world traffic patterns (hot keys)
  • Designing complete storage systems

Next Week: Week 2 — Building Blocks. We'll dive into messaging systems, caching patterns, and coordination services that tie distributed systems together.