Chapter 10 · Key-Value Databases and Data-Structure Stores
TTL, Expiration, Eviction, Counters, Conditional Writes, and Compare-and-Set
Make TTL, lazy/active expiration, memory-pressure eviction, atomic counters, set-if-absent, versioned conditional updates, and compare-and-set behavior observable in one deterministic AtlasMart store.
Learning outcomes
AtlasMart uses short-lived sessions, request-deduplication records, view counters, and product caches. Four mechanisms are easily conflated: expiration removes a key because its lifetime ended; eviction removes a key because capacity policy needs space; an atomic counter changes a number without exposing an intermediate read; and compare-and-set commits only if the observed version is still current.
Define TTL, expiration, lazy expiration, active expiration, eviction, LRU/LFU-style policy, atomic increment, conditional create, version, and CAS.
Demonstrate that a logically expired key and a memory-evicted unexpired key disappear for different reasons.
Use atomic increment and set-if-absent patterns without claiming they solve arbitrary distributed invariants.
Produce a stale conditional update and verify that CAS rejects it.
Reason about TTL refresh, clock/failover semantics, replicas, memory pressure, and observability.
The mandatory labs use Python 3.13+ standard library only in one deterministic process with a logical clock; there is no database server, client driver, container, cloud account, network fault injection, or paid feature. Optional implementation references were rechecked against Redis Open Source 8.10.0 (GA July 2026; Redis 8+ is offered under the user's choice of AGPLv3, RSALv2, or SSPLv1) and Valkey 9.1.1 (released 21 July 2026; predominantly BSD-3-Clause). Product commands/defaults are examples only; later Stage 03 product courses teach product-specific operations in depth.
1. TTL is a lifetime contract; eviction is a capacity decision
Time to live (TTL) associates a logical expiry with a key. Implementations may delete expired keys lazily when accessed, proactively through background sampling/scanning, or both. Therefore logical invisibility and physical memory reclamation need not occur at the same instant. A cache may also evict a still-valid key before TTL because memory pressure triggers an eviction policy.
This distinction determines whether loss is acceptable. If a session may expire after 30 minutes but memory policy can evict it after 2 minutes, the application must either tolerate forced logout/reconstruction or use a placement/persistence policy where eviction cannot remove authoritative session state.
2. TTL refresh changes semantics
Sliding sessions often refresh TTL on activity; idempotency records usually need a fixed deduplication window; a cache may use jitter to avoid synchronized expiry stampedes. Refreshing every read can accidentally turn “retain for at most one hour” into “retain forever while active.” Record whether TTL is absolute, sliding, conditional, or derived from an external event.
Chapter 04 showed that real products may persist absolute expiry timestamps and depend on system clocks. The mandatory lab uses a logical clock so it is reproducible; do not interpret it as evidence that production TTL is immune to wall-clock behavior.
3. Atomic counters and conditional creation close specific races
A native increment can turn
read 10 → compute 11 → write 11 into one atomic
state transition. A “set if absent” primitive can claim an
idempotency or initialization key without two clients both
believing they created it. These operations are powerful
precisely because they narrow the race window to the store's
documented atomic boundary.
They are not magic distributed transactions. A token counter and a separate payment record can still diverge. Under replication/failover, the product's acknowledgement and consistency semantics determine whether the successful mutation survives and when replicas observe it.
4. CAS protects an observed version, not wall-clock truth
Compare-and-set (CAS) reads a version/token and commits a new value only if that token still matches. If another writer wins first, the stale writer receives a conflict and must reload/recompute or surface the conflict. This is optimistic concurrency: avoid locking when conflicts are uncommon, but detect them reliably rather than comparing timestamps.
5. AtlasMart lab: expiration, eviction, counters, and CAS
The toy store has a deterministic logical clock and fixed capacity. It shows lazy expiration, active cleanup, LRU-like eviction of an unexpired key, an atomic counter, conditional creation, and a stale CAS rejection. It intentionally models semantics, not a vendor's precise eviction/expiry algorithm.
from dataclasses import dataclass
@dataclass
class Entry:
value: object
version: int
expires_at: int | None = None
last_access: int = 0
class ToyKV:
def __init__(self, capacity=3):
self.now = 0
self.capacity = capacity
self.data = {}
def tick(self, seconds=1): self.now += seconds
def _expired(self, e): return e.expires_at is not None and self.now >= e.expires_at
def get(self, key):
e = self.data.get(key)
if e is None: return None
if self._expired(e):
del self.data[key] # lazy expiration on access
return None
e.last_access = self.now
return e.value
def set(self, key, value, ttl=None):
old = self.data.get(key)
version = 1 if old is None else old.version + 1
self.data[key] = Entry(value, version, None if ttl is None else self.now + ttl, self.now)
self._evict_if_needed()
return version
def set_if_absent(self, key, value, ttl=None):
if self.get(key) is not None: return False
self.set(key, value, ttl)
return True
def incr(self, key, delta=1):
current = self.get(key)
if current is None: current = 0
return self.set(key, int(current) + delta)
def cas(self, key, expected_version, new_value):
e = self.data.get(key)
if e is None or self._expired(e) or e.version != expected_version:
return False
self.set(key, new_value, None if e.expires_at is None else e.expires_at - self.now)
return True
def active_expire(self):
expired = [k for k,e in self.data.items() if self._expired(e)]
for k in expired: del self.data[k]
return expired
def _evict_if_needed(self):
while len(self.data) > self.capacity:
victim = min(self.data.items(), key=lambda kv: kv[1].last_access)[0]
del self.data[victim]
print("EVICT", victim, "because capacity exceeded")
kv = ToyKV(capacity=3)
print("EXPIRATION")
kv.set("session:s1", "alice", ttl=5)
kv.tick(6)
print("expired session returns", kv.get("session:s1"), "remaining keys", list(kv.data))
kv.set("temp:a", 1, ttl=2); kv.set("temp:b", 2, ttl=20)
kv.tick(3)
print("active expiration removed", kv.active_expire())
print("\nEVICTION IS DIFFERENT FROM EXPIRATION")
kv = ToyKV(capacity=3)
kv.set("cache:a", "A", ttl=100)
kv.tick(); kv.set("cache:b", "B", ttl=100)
kv.tick(); kv.set("cache:c", "C", ttl=100)
kv.get("cache:c")
kv.tick(); kv.set("cache:d", "D", ttl=100)
print("all four TTLs valid, but memory pressure kept", sorted(kv.data))
print("\nATOMIC COUNTER + SET-IF-ABSENT")
kv = ToyKV(capacity=10)
kv.set("views:sku-7", 0)
for _ in range(3): kv.incr("views:sku-7")
print("counter", kv.get("views:sku-7"))
print("claim idempotency key first", kv.set_if_absent("idem:req-99", "processing", ttl=30))
print("claim idempotency key second", kv.set_if_absent("idem:req-99", "processing", ttl=30))
print("\nCOMPARE-AND-SET PREVENTS LOST UPDATE")
kv.set("profile:user-42", {"name": "Ada"})
version = kv.data["profile:user-42"].version
print("initial version", version)
print("client A CAS", kv.cas("profile:user-42", version, {"name": "Ada Lovelace"}))
print("client B stale CAS", kv.cas("profile:user-42", version, {"name": "A. Lovelace"}))
print("final", kv.get("profile:user-42"), "version", kv.data["profile:user-42"].version)
EXPIRATION
expired session returns None remaining keys []
active expiration removed ['temp:a']
EVICTION IS DIFFERENT FROM EXPIRATION
EVICT cache:a because capacity exceeded
all four TTLs valid, but memory pressure kept ['cache:b', 'cache:c', 'cache:d']
ATOMIC COUNTER + SET-IF-ABSENT
counter 3
claim idempotency key first True
claim idempotency key second False
COMPARE-AND-SET PREVENTS LOST UPDATE
initial version 1
client A CAS True
client B stale CAS False
final {'name': 'Ada Lovelace'} version 2
Failure diagnosis
The wrong mental model is “TTL means the key stays until TTL.” Capacity eviction disproves that for an evicting cache. Another wrong model is “two clients can safely update if writes are fast.” CAS disproves that speed is irrelevant to correctness—the stale version is rejected regardless of how quickly the race happened.
Check your understanding
- What is the difference between expiration and eviction?
- Why can logical expiration precede physical reclamation?
- What race does set-if-absent close?
- What does CAS prove?
- What should be monitored for an ephemeral store?
Review the answers
1. Expiration is driven by the key lifetime; eviction is driven by capacity/policy and can remove an otherwise unexpired key.
2. Implementations may discover/remove expired keys lazily or incrementally rather than synchronously scanning every key at the exact deadline.
3. Competing creators can atomically claim one key instead of both observing absence and then writing.
4. Only that the version/token compared at commit still matched; it does not prove broad transaction isolation or cross-key invariants.
5. Expiry/eviction counts, memory pressure, hit rate, hot keys, rejected conditionals, replica/failover behavior, and application-level reconstruction/duplicate rates.
6. Production judgment
| Production dimension | Questions to record before using the pattern |
|---|---|
| Correctness / atomicity | What is the atomic boundary: one key, one structure, one shard, one transaction, or a broader invariant? Which races remain possible around reads, retries, expiration, failover, and multiple keys? |
| Consistency / topology | Which node owns or coordinates the key, how are replicas acknowledged, what stale reads are allowed, and what changes during partition/failover? |
| Durability / recovery | Is the state disposable, reconstructable, or authoritative? What persistence, replication, backup, restore, and reconciliation evidence supports that claim? |
| Latency / hot keys | Measure p50/p95/p99, queueing, value size, key distribution, per-key operation rate, serialization cost, fan-in/fan-out, and hot-key concentration. |
| TTL / memory | Differentiate logical expiration from physical reclamation and memory-pressure eviction. Define refresh rules, jitter, capacity headroom, and acceptable disappearance. |
| Security / tenancy | Namespace tenant data, authorize keys/operations, protect secrets, prevent cross-tenant scans/collisions, limit abusive large values/structures, and audit privileged coordination actions. |
| Operations / testing | Test ambiguous retries, duplicate requests, eviction, restart, replica lag, failover, clock shifts where relevant, hot keys, schema evolution, restore, and cache rebuild. |
| Version / license / cost | Pin product/client versions when used; verify license/edition/feature status; price memory, replicas, persistence I/O, network egress, backup retention, and operational skill. |
Bridge to Lesson 4: TTL and eviction force a deeper architecture question: which values may disappear? The next lesson separates a disposable cache from a durable system of record.
Authoritative references
- Redis — Keys and expiration — current implementation example of TTL and absolute expiry metadata
- Redis EXPIRE — current command semantics and accuracy notes
- Redis key eviction — current implementation example of memory-pressure eviction policies
- Redis transactions / WATCH — concrete CAS/check-and-set example