Skip to content
intermediate Phase 11 · Memory and Conversation State

Session Persistence Patterns

Back sessions with Redis or a database and handle expiry, resumes, and concurrency.

50m
0 problems
Topic Progress 0%

Two Stores, One Session

A session kept only in Redis vanishes when the key expires or the node is evicted under memory pressure. A session kept only in Postgres answers every append with a disk round trip and every resume with a table scan. The split exists because the two stores are answering different questions: what is happening right now versus what happened at all.

Schema first, because every key below follows from it:

CREATE TABLE turns (
  user_id         text   NOT NULL,
  conversation_id text   NOT NULL,
  seq             int    NOT NULL,
  role            text   NOT NULL CHECK (role IN ('system','user','assistant')),
  content         text   NOT NULL,
  tokens          int    NOT NULL,
  version         bigint NOT NULL DEFAULT 1,
  created_at      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, conversation_id, seq)
);
CREATE INDEX turns_recent ON turns (user_id, conversation_id, created_at DESC);

The composite primary key encodes the ownership rule from Managing Conversation History: a lookup that carries both identifiers cannot return another user's rows.

Redis holds the working set under keys built from the same two identifiers:

sess:{user}:{conv}:turns   LIST    JSON turn objects, RPUSH on append
sess:{user}:{conv}:ver     STRING  write version, incremented per append
sess:{user}:{conv}:meta    HASH    summary_version, last_active, model

The write order is the non-obvious part:

def append_turn(pool, r, user_id, conv, turn):
    with pool.cursor() as cur:                       # 1. durable write first
        cur.execute(
            "INSERT INTO turns (user_id, conversation_id, seq, role, content, tokens)"
            " VALUES (%s,%s,%s,%s,%s,%s)",
            (user_id, conv, turn.seq, turn.role, turn.content, turn.tokens))
    key = f"sess:{user_id}:{conv}:turns"
    r.rpush(key, json.dumps(turn))                   # 2. then the fast copy
    r.expire(key, TTL_SECONDS)                       # 3. and its clock
    return turn.seq

Input: append_turn(pool, r, "u-17", "c-9f2", Turn(seq=7, role="user", ...))

Output: 7 — row present in turns, list length 8 in Redis, key TTL reset to 1800.

Crash between the two writes and the outcomes are asymmetric. Archive-then-Redis leaves a Redis copy missing the last turn, which the reload path repairs from Postgres. The reverse order leaves a turn in Redis the archive never received — it survives until the TTL, then disappears with the conversation's history. Durable first is the ordering that fails toward the repairable side.

TTLs and the Cost of Abandonment

Most sessions are abandoned mid-sentence. Without a TTL those sessions are a bill that never stops: lists of JSON turns sitting in memory for users who closed the tab three weeks ago. Redis reclaims them for free the moment the key expires — the database has no equivalent, so the archive needs its own purge policy.

How each store behaves when a conversation goes quiet:

Concern Redis (hot) Postgres (archive)
Read path sub-ms list read of the window index scan on turns_recent, then trim
Durability snapshots and replicas; key can still expire or be evicted WAL; survives restart and failover
Abandonment free — TTL reclaims it needs a scheduled purge by created_at
Query power whole window, no predicate point replay, analytics, ownership joins
Failure mode eviction under memory pressure drops the working set none that loses history

Refresh the clock on activity, not on creation:

TTL_SECONDS = 1800          # 30 minutes of silence ends a session

def touch(r, user_id, conv):
    key = f"sess:{user_id}:{conv}:turns"
    if not r.exists(key):
        reload_from_archive(pool, r, user_id, conv)   # expired or evicted: rebuild
    r.expire(key, TTL_SECONDS)                        # refreshed by every write
    return r.ttl(key)

Input: a user returns after 12 minutes of inactivity and sends a message.

Output: 1796 — the key still existed, so no reload happened and the TTL restarts from 30 minutes.

The exists check before expire is the line that matters: calling expire on a missing key silently creates a clock on nothing, and the reload from the archive — Context Window Budgeting tells you how many of those reloaded turns the next request may actually send — never runs. Two failure modes meet here: a TTL shorter than a user's thinking pause returns them to an empty chat, and an eviction under memory pressure does the same thing with no elapsed time at all. Both are answered by the same reload path, which is why the archive write cannot be optional.

Two Tabs, One Conversation

Open the same conversation in two browser tabs and send from both within a second. Each handler reads seq = 11, appends its turn as 12, and writes. One message is now unreachable in replay, the role alternation is broken, and nothing raised an error. Serializing every append with a row lock would fix it and make every write wait on every other write, so the cheaper answer is optimistic: detect the conflict at commit time and retry.

In Redis that is WATCH on the version key:

from redis.exceptions import WatchError

def append_lockfree(r, user_id, conv, turn):
    ver_key = f"sess:{user_id}:{conv}:ver"
    while True:                                   # bounded retries in real code
        r.watch(ver_key)
        current = int(r.get(ver_key) or 0)
        if turn.seq != current + 1:               # caller's seq must continue the chain
            r.unwatch()
            raise SeqConflict(current + 1)        # reload, renumber, resend

        pipe = r.pipeline()
        pipe.multi()                              # everything below is one transaction
        pipe.rpush(f"sess:{user_id}:{conv}:turns", json.dumps(turn))
        pipe.set(ver_key, current + 1)
        try:
            pipe.execute()                        # aborts if ver_key changed since WATCH
            return current + 1
        except WatchError:
            continue                              # the other tab won the race; re-read

Input: tab A and tab B both read ver = 11 and both propose turn 12.

Output: tab A returns 12; tab B's pipe.execute() raises WatchError, the loop re-reads ver = 12, finds its seq stale, and raises SeqConflict(13) so the client can renumber.

WATCH is not a lock — it is a conflict detector: the transaction commits only if the watched key is untouched, so the interleave that produced two seq-12 turns cannot commit. The database side needs the same guarantee for the durable copy, and version bigint on the turns table exists so a conditional UPDATE ... WHERE version = %s can reject the stale writer instead of overwriting it. Failures here are silent by default, which is what makes them worth the extra lines: the corrupted replay surfaces days later as an answer that skips a message.

Resume Without Shipping the Archive

Returning to a conversation is not a request for everything that ever happened in it. A user who resumes after two days needs the model to know the decisions and the last few exchanges — not 400 turns of preamble that blow the window and the bill.

Resume = summary + the newest turns that fit, assembled from the archive:

def resume(pool, r, user_id, conv, budget=1500):
    meta = r.hgetall(f"sess:{user_id}:{conv}:meta") or reload_meta(pool, user_id, conv)
    summary = load_summary(pool, user_id, conv, meta.get("summary_version", 0))

    rows = fetch_recent(pool, user_id, conv, limit=50)   # newest first from archive
    kept, used = [], sum(t.tokens for t in summary)
    for t in reversed(rows):                             # same oldest-first sacrifice
        if used + t.tokens > budget:
            break
        kept.insert(0, t); used += t.tokens

    history = ([summary_note(summary)] if summary else []) + kept
    return pinned_system_turns() + ensure_user_first(history)

Input: a 640-turn conversation resuming with a 140-token summary and a 1,500-token budget.

Output: system prompt + summary note + the last 9 turns — under 1,500 tokens, instead of the 61,000 the full archive would cost.

Failure modes to design against:

  • TTL shorter than the pause — the user returns to an empty window. Refresh on write, and fall back to the archive rather than to nothing.
  • Eviction with time left on the key — memory pressure drops the list while TTL still reads 900. exists before expire forces the reload path.
  • Lost write in the gap — archive-first leaves Redis short a turn; reload repairs it. Redis-first loses the turn permanently on expiry.
  • Two writers, no version check — duplicate seq values corrupt replay silently; both stores need the conflict guard from the previous chapter.
  • Full-log resume — assembling all turns works in development and returns a 400 in production; the budget applies on resume exactly as it does per turn.
  • Summary version skew — resuming with a stale summary_version re-states a decision the user already overrode; load the version named in meta.

The archive is only a source of truth if something checks it against live behavior — Evaluating Memory Quality covers the recall tests that catch a resume path quietly dropping turns, and the store that outlives the session entirely is Semantic Memory with Vectors.

Quiz

1. Why write to Postgres before pushing the turn into the Redis session list?

Question 1 options

2. What is the practical difference between a TTL on a Redis session key and retention in Postgres?

Question 2 options

3. Two tabs append concurrently and both propose seq 12. What does WATCH on the version key actually guarantee?

Question 3 options

4. A user resumes a 640-turn conversation after two days. What should the request carry?

Question 4 options

Flashcards

Question

What lives in Redis versus Postgres for a chat session?

Answer

Redis holds the hot working set — the recent turn list, a write version, and session metadata — with fast reads and automatic expiry. Postgres is the durable archive keyed by (user_id, conversation_id, seq) and is the source truth for rebuilds.

Question

What is the correct write order across the two stores, and what does the wrong order cost?

Answer

Archive first, Redis second. Wrong order puts a turn in Redis that Postgres never received — it works until the TTL, then the message disappears from replay with no record to recover it.

Question

How does optimistic locking stop two tabs from interleaving writes?

Answer

WATCH the version key, run the RPUSH and version increment in one MULTI transaction, and retry on WatchError. The second writer is not blocked — its transaction simply fails to commit, so the interleaved seq never lands.

Question

When should a session TTL be refreshed?

Answer

On every write, not on creation. A 30-minute TTL that never refreshes ends a conversation the user is still typing in; one set only at creation expires mid-session regardless of activity.

Question

What does a resume request consist of?

Answer

Pinned system prompt, the summary note at its current version, and the newest turns that fit the token budget — never the full archive. A 640-turn conversation resumes as roughly a dozen entries.

Revision Notes

Key Takeaways

  • 1. Redis serves the hot window; Postgres is the archive and the rebuild source — both keyed by user and conversation
  • 2. Write durably first: the archive-then-cache order fails toward repair, the reverse fails toward data loss
  • 3. TTLs make abandonment free in Redis and must be refreshed on write; Postgres needs its own purge job
  • 4. WATCH plus MULTI turns a write race into a retry instead of an interleaved seq
  • 5. Resume reconstructs from summary plus the newest turns under budget, not from the archive

Interview Tips

  • • Draw the two-store diagram and state which one is authoritative when they disagree — that is the question interviewers ask next
  • • Explain the write-order argument concretely: name the failure each ordering produces
  • • Contrast optimistic locking with a row lock and say when each is the right call: high contention on few keys favors optimistic
  • • Quantify resume cost: 640 turns versus a summary plus nine turns, and tie the difference to the window arithmetic

Cheat Sheet

Session Persistence Cheat Sheet

Split

Redis  sess:{user}:{conv}:turns  LIST   working window, TTL 1800s
       sess:{user}:{conv}:ver    STRING write version (optimistic lock)
       sess:{user}:{conv}:meta   HASH   summary_version, last_active
Postgres turns(user_id, conversation_id, seq, role, content, tokens,
               version, created_at)   PK (user_id, conversation_id, seq)

Write order: INSERT into Postgres → RPUSH + EXPIRE in Redis. Crash in between repairs from the archive; the reverse order loses the turn at expiry.

TTL: refresh on write, exists before expire, reload from archive on miss. Purge Postgres rows by created_at with a scheduled job — TTL does not exist there.

Optimistic lock

r.watch(ver_key); current = int(r.get(ver_key) or 0)
pipe = r.pipeline(); pipe.multi()
pipe.rpush(turns_key, json.dumps(turn)); pipe.set(ver_key, current + 1)
try: pipe.execute()
except WatchError: retry          # other tab won; re-read and renumber

Resume: system prompt + summary note (current version) + newest turns under budget. Never the full log.

Failure modes: short TTL vs thinking pause, eviction with time left, lost write in the archive gap, duplicate seq from two writers, full-log resume, stale summary version.

Related: trimming → Context Window Budgeting; durable cross-session facts → Semantic Memory with Vectors.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading