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.

Intermediate100–120 minutesTTL + eviction + CAS labLogical clock · deterministicNo real clock/network changesLast reviewed: August 2026

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.

01

Define TTL, expiration, lazy expiration, active expiration, eviction, LRU/LFU-style policy, atomic increment, conditional create, version, and CAS.

02

Demonstrate that a logically expired key and a memory-evicted unexpired key disappear for different reasons.

03

Use atomic increment and set-if-absent patterns without claiming they solve arbitrary distributed invariants.

04

Produce a stale conditional update and verify that CAS rejects it.

05

Reason about TTL refresh, clock/failover semantics, replicas, memory pressure, and observability.

Tooling and version snapshot · checked 29 August 2026

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.

Physical clock caution

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.

python · ttl_eviction_cas.py
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)
text · expected output
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

  1. What is the difference between expiration and eviction?
  2. Why can logical expiration precede physical reclamation?
  3. What race does set-if-absent close?
  4. What does CAS prove?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.