Problem solving
Implement an LRU cache with per-entry TTL
The classic cache question with a time dimension added, the way AI platform teams ask it. Design choices, a tested Python solution, and the follow-ups that decide the round.
The question
Implement a cache with a fixed capacity. When it’s full, evict the least recently used entry. Each entry also expires after a time-to-live.
getandputshould be O(1).
This is a warm-up, so the bar for execution is high: clean code, tests written as you go, and crisp answers to the follow-ups. It comes up in LLM loops because caches are everywhere in LLM systems: prompt results, embeddings, tool responses, permission lookups.
Clarify first
- Does a
getrefresh the TTL, or only the recency? (I’ll say recency only; the TTL is set onput.) - When full, should an expired entry be evicted before a live LRU one? (Yes. Evicting live data while dead data sits there is wasteful.)
- Thread safety? (Not in the first version; it’s a follow-up.)
- Can I inject the clock? Always ask this. It makes the TTL testable without
sleep.
Approach
An OrderedDict gives O(1) lookup and O(1) move-to-end, which is exactly an LRU list plus a hash map. Store (value, expires_at) and check expiry lazily on read.
from collections import OrderedDict
import time
class LRUTTLCache:
"""Least-recently-used cache where each entry also expires after a TTL."""
def __init__(self, capacity: int, ttl_seconds: float, clock=time.monotonic):
if capacity <= 0:
raise ValueError("capacity must be positive")
self.capacity = capacity
self.ttl = ttl_seconds
self._clock = clock
self._data: OrderedDict = OrderedDict() # key -> (value, expires_at)
def get(self, key, default=None):
item = self._data.get(key)
if item is None:
return default
value, expires_at = item
if self._clock() >= expires_at:
del self._data[key]
return default
self._data.move_to_end(key) # mark as most recently used
return value
def put(self, key, value, ttl_seconds: float | None = None) -> None:
ttl = self.ttl if ttl_seconds is None else ttl_seconds
if key in self._data:
self._data.move_to_end(key)
self._data[key] = (value, self._clock() + ttl)
if len(self._data) > self.capacity:
self._evict()
def _evict(self) -> None:
# Prefer dropping an expired entry; otherwise drop the least recently used.
now = self._clock()
for k, (_, expires_at) in self._data.items():
if expires_at <= now:
del self._data[k]
return
self._data.popitem(last=False)
def __len__(self) -> int:
return len(self._data)
One caveat to say out loud: _evict scans for an expired entry, which is O(n) in the worst case. If the interviewer pushes on it, see the first follow-up.
Tests
The fake clock makes every timing case deterministic and instant.
import pytest
from lru_ttl_cache import LRUTTLCache
class FakeClock:
def __init__(self):
self.t = 0.0
def __call__(self):
return self.t
def make(capacity=2, ttl=10):
clock = FakeClock()
return LRUTTLCache(capacity, ttl, clock=clock), clock
def test_get_returns_value_before_expiry():
cache, clock = make()
cache.put("a", 1)
clock.t = 9.9
assert cache.get("a") == 1
def test_entry_expires_at_ttl():
cache, clock = make()
cache.put("a", 1)
clock.t = 10
assert cache.get("a") is None
assert len(cache) == 0
def test_evicts_least_recently_used():
cache, _ = make(capacity=2)
cache.put("a", 1)
cache.put("b", 2)
cache.get("a") # "b" is now least recently used
cache.put("c", 3)
assert cache.get("b") is None
assert cache.get("a") == 1 and cache.get("c") == 3
def test_prefers_evicting_expired_entry_over_lru():
cache, clock = make(capacity=2)
cache.put("a", 1)
cache.put("b", 2, ttl_seconds=1)
cache.get("b") # "a" is LRU, but "b" is about to expire
clock.t = 2
cache.put("c", 3)
assert cache.get("a") == 1
assert cache.get("b") is None
def test_put_existing_key_updates_value_and_ttl():
cache, clock = make()
cache.put("a", 1)
clock.t = 8
cache.put("a", 2)
clock.t = 15
assert cache.get("a") == 2
def test_rejects_non_positive_capacity():
with pytest.raises(ValueError):
LRUTTLCache(0, 10)
Complexity
getandput: O(1) on average, except the expired-entry scan during eviction, which is O(n) in the worst case.- Space: O(capacity).
Follow-ups the interviewer will ask
- Make eviction of expired entries O(log n). Keep a min-heap of
(expires_at, key)alongside the dict. Heap entries go stale when a key is overwritten, so on pop, check that the heap entry still matches the dict before deleting (lazy deletion). - Make it thread-safe. A single lock around
getandputis correct and usually enough. Note thatgetchanges the order, so reads need the lock too. Under high contention, shard the cache by key hash with one lock per shard. - Expired entries waste memory if nobody reads them. Add a background sweep, or check a few random entries on each
put, the way Redis samples keys for expiry. - Cache semantic results for an LLM. The key is no longer exact: you embed the query and look for a neighbour above a similarity threshold. The hard parts are choosing the threshold and making the key include tenant and permissions, so a cached answer is never served to someone who couldn’t see its sources.
- Bound by bytes, not entries. Track each value’s size and evict until under the byte budget.
Common mistakes
- Calling
time.time()directly and testing withsleep. - Forgetting that
getmust update recency. - Updating an existing key without moving it to the end.
- Returning an expired value because expiry is only checked during eviction.