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
TTLstill reads 900.existsbeforeexpireforces 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
seqvalues 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_versionre-states a decision the user already overrode; load the version named inmeta.
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?
2. What is the practical difference between a TTL on a Redis session key and retention in Postgres?
3. Two tabs append concurrently and both propose seq 12. What does WATCH on the version key actually guarantee?
4. A user resumes a 640-turn conversation after two days. What should the request carry?
Flashcards
Question
What lives in Redis versus Postgres for a chat session?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.