Skip to content

Step 1 of 3 · Reading · ~3 min

Lazy Deletion

Key Expiry

Passive Expiry — Lazy Deletion

You can now set TTLs and query them, but so far nothing actually enforces expiry — a key with a lapsed TTL just sits in store forever unless something checks it. This lesson closes that gap by making every read-path command consult the expiry table before answering.

Two strategies Redis actually uses

Real Redis combines two expiry mechanisms:

  1. Active expiry — a background cycle periodically samples random keys with TTLs and deletes the ones that have lapsed, so memory isn't held by dead keys indefinitely.
  2. Passive (lazy) expiry — whenever a key is accessed by any command, the server first checks whether its TTL has lapsed and, if so, deletes it right then, before doing anything else, as if the key were never there.

This lesson implements only the second, simpler mechanism — good enough for a single-machine clone, and the same trick every cache library uses when it doesn't want a dedicated background sweep thread.

A single chokepoint: is_expired / get_live

The key design move is to not scatter now() >= expires[key] checks inside every command handler. Instead, write one helper that every command that touches store calls first:

python

Then, at the top of GET, EXISTS, and anywhere else that reads a key:

python

Because expire_if_needed actually deletes the entry from both store and expires, the key is genuinely gone after this call — not just hidden. This matters for DBSIZE.

DBSIZE must reflect only live keys

DBSIZE naively returning len(store) breaks the moment a key expires but hasn't been touched since — it'll still be counted even though GET would say it doesn't exist. Two ways to fix this:

  • Eager sweep: before counting, iterate all keys with TTLs and call expire_if_needed on each. Simple, but O(n) per DBSIZE call.
  • Filtered count: count keys minus those currently expired, without mutating state: sum(1 for k in store if not is_expired(k)).

Either is acceptable for this project's scale; the important invariant is that DBSIZE never counts a key whose TTL has already lapsed, even if lazy cleanup hasn't physically removed it yet.

TTL and EXISTS on expired keys

  • EXISTS on an expired key must trigger the same lazy check and return :0\r\n, same as if the key were absent.
  • TTL on an expired key returns :-2\r\n — the same code used for "never existed" — because from the client's perspective, an expired key and a nonexistent key are indistinguishable.

Why this ordering discipline matters

The pattern — check expiry, clean up if needed, then answer — needs to run as the very first step of every command that reads a key, before any other logic (including NX/XX checks in SET, which need to see an expired key as absent, not present). Get this chokepoint wrong in even one command and you'll have subtle test failures where SET key val NX incorrectly refuses to write because it saw a technically-still-present-but-logically-expired key.

Up nextLPUSH & RPUSH — Building ListsLists

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…

Passive Expiry — Lazy Deletion — Build Redis from Scratch