Question: Temporal key-value store with point-in-time reads and restore
Companies: Perplexity, Microsoft, Together AI, Amazon, OpenAI, Uber
Difficulty: Medium
Problem statement
Build an in-memory key-value store that keeps every version of every key, so you can ask what a key held at any point in time.
Implement the TemporalKVStore class:
-
TemporalKVStore()creates an empty store. -
set(key: str, value, ts: int) -> Nonerecords thatkeyholdsvaluefrom timetson. -
remove(key: str, ts: int) -> Nonerecords thatkeyis absent from timetson, until a laterset. -
get(key: str, ts: int) -> value | Nonereturns the value of the latest write tokeywith timestamp<= ts. It returnsNoneif there is no such write or if that write was aremove. -
restore(ts: int) -> Nonerolls the whole store back to its state at timets. All writes with a timestamp greater thantsare discarded permanently. New writes after a restore are allowed at any timestamp.
Rules:
- Timestamps are integers and may arrive out of order. A late write must take effect at its own timestamp. It must not change answers for earlier times, and it must not hide newer writes.
- Two writes to the same key at the same timestamp: the later call wins.
- Removing a key must not erase its history. Reads at earlier times still see the old values.
- Expect up to about 10^5 operations, mostly in timestamp order.
getshould be faster than a scan of the key's history.
Example
s = TemporalKVStore()
s.set("flag", "off", 100)
s.set("flag", "on", 200)
s.get("flag", 150) -> "off"
s.get("flag", 200) -> "on"
s.remove("flag", 300)
s.get("flag", 350) -> None
s.set("flag", "beta", 150) # arrives late
s.get("flag", 170) -> "beta"
s.get("flag", 250) -> "on"
s.restore(160)
s.get("flag", 500) -> "beta"
Example 2
s = TemporalKVStore()
s.get("x", 10) -> None
s.set("x", 1, 10)
s.set("x", 2, 10) # same timestamp, last call wins
s.get("x", 10) -> 2
s.get("x", 9) -> None
Approach
The store never overwrites history. Each key owns a timeline: a list of
(timestamp, value) versions sorted by timestamp. A read at time t asks
"what was the last write to this key at or before t?", which is a
predecessor search on that timeline.
Brute force. Keep every write in one flat list and, on get, scan for
the matching key with the largest timestamp <= t. Writes are O(1) and
reads are O(total writes). That is fine for a first pass, and it makes the
read rule explicit before you optimize.
Per-key sorted timelines + binary search. Group writes by key and keep
two parallel lists per key, times and values, sorted by time.
-
set(key, value, ts): iftsis newer than the last version (the usual
case), append in O(1). Otherwise binary-search for the slot. An equal
timestamp replaces the value there (last write wins). A smaller one is a
late event and gets inserted in place. -
remove(key, ts): write a tombstone attsinstead of deleting
anything. Deleting entries would destroy history, so reads beforets
would break. A tombstone means "absent from here until the next set", and
it uses the same insert path, so late removes work too. -
get(key, ts):bisect_right(times, ts) - 1finds the newest version at
or beforets. ReturnNoneif there is none or if it is a tombstone. -
restore(ts): roll the whole store back to timets. Every version
newer thantsis cut off each timeline for good. Keys whose first
version is aftertsdisappear. New writes after a restore start a fresh
future.
Invariants.
-
times[key]is strictly increasing, andvalues[key][i]belongs to
times[key][i]. - The state at any time is a pure function of the stored versions, so the
order in which writes arrived does not matter. Only their timestamps do. - A tombstone hides older values only for reads at or after its timestamp.
Code
from bisect import bisect_left, bisect_right
from typing import Any, Optional
_TOMBSTONE = object() # marks "key removed at this timestamp"
class TemporalKVStore:
"""Versioned key-value store: every write is kept, reads are point-in-time."""
def __init__(self) -> None:
# key -> parallel lists, both sorted by timestamp
self._times: dict[str, list[int]] = {}
self._values: dict[str, list[Any]] = {}
def _write(self, key: str, value: Any, ts: int) -> None:
times = self._times.setdefault(key, [])
values = self._values.setdefault(key, [])
if not times or ts > times[-1]: # common case: in-order append
times.append(ts)
values.append(value)
return
i = bisect_left(times, ts)
if i < len(times) and times[i] == ts: # same timestamp: last write wins
values[i] = value
else: # late (out-of-order) event
times.insert(i, ts)
values.insert(i, value)
def set(self, key: str, value: Any, ts: int) -> None:
self._write(key, value, ts)
def remove(self, key: str, ts: int) -> None:
"""Key reads as absent from ts until a later set."""
self._write(key, _TOMBSTONE, ts)
def get(self, key: str, ts: int) -> Optional[Any]:
"""Value of the latest write at or before ts, or None."""
times = self._times.get(key)
if not times:
return None
i = bisect_right(times, ts) - 1
if i < 0:
return None
value = self._values[key][i]
return None if value is _TOMBSTONE else value
def restore(self, ts: int) -> None:
"""Roll the whole store back to its state at ts.
Every write with timestamp > ts is discarded for good; later sets may
write new history after ts.
"""
for key in list(self._times):
times = self._times[key]
cut = bisect_right(times, ts)
if cut == 0:
del self._times[key]
del self._values[key]
elif cut < len(times):
del times[cut:]
del self._values[key][cut:]
Complexity
Let n be the number of versions for one key and K the number of keys.
| Operation | Time | Notes |
|---|---|---|
set / remove, in order |
O(1) amortized | append to the end |
set / remove, out of order |
O(log n) search + O(n) shift |
list.insert is a fast memmove; a balanced tree or SortedList makes it O(log n) |
get |
O(log n) | one binary search |
restore(ts) |
O(K log n + d) |
d = versions discarded; each version is discarded at most once |
Space is O(total writes), including tombstones.
Test cases
-
set("a","x",10), thenget("a",5)returnsNone(before the first write). -
get("a",10)returns"x", because the bound is inclusive. -
set("a","y",20), thenget("a",15)returns"x"andget("a",1000)returns"y". -
remove("a",30), thenget("a",29)returns"y"andget("a",30)returnsNone. -
set("a","z",40)after the remove, thenget("a",45)returns"z". - Late write:
set("a","w",15)after the others, thenget("a",17)returns"w"andget("a",14)returns"x". - Same timestamp:
set("a","y2",20)replaces"y", soget("a",20)returns"y2". - Late remove:
remove("a",12), thenget("a",13)returnsNoneandget("a",15)still returns"w". - Unknown key:
get("nope",100)returnsNone. - Restore:
set("b","us",25),restore(20), thenget("a",100)returns"y2"andget("b",100)returnsNone. - Write after restore:
set("a","v9",50), thenget("a",35)returns"y2"andget("a",50)returns"v9". -
restore(0)before every write empties the store.
Follow-ups
-
Undoable restore / branching history. "Restore, but let me go
back." Instead of truncating, keep an append-only log of writes, each
tagged with a global sequence number. A restore records a new view
(as_of = ts, starting at sequences) rather than deleting anything.
Reads filter versions by sequence and by the active view. This is how
MVCC databases and git-style branches avoid losing data. -
O(1) restore. Restore touches every key. Make it lazy instead: store
a global list of(restore_seq, cutoff_ts)and, per key, the sequence
of each write. On access, trim a key only if a restore happened since
its last trim. That is amortized O(log n) per access, with no global
sweep. -
Range and history queries.
history(key, t1, t2)uses two
bisects and a slice: O(log n + k).snapshot(ts)for all keys costs
O(K log n). For prefix scans, keep keys in a sorted structure too (for
example aSortedDictor a B-tree). -
Memory and compaction. With a retention window
W, drop versions
older thannow - Wbut keep the newest one at or before the cutoff,
so reads at the cutoff are still correct. Drop leading tombstones. You
could also spill cold versions to disk as immutable sorted runs
(LSM-style) and keep only recent versions in memory. -
Concurrency and throughput. Many readers and few writers: shard
keys across lock stripes, or copy-on-write each key's timeline so
readers never block. With heavy out-of-order writes, swap the lists for
a skip list orSortedListso inserts stay O(log n).
Interviewer expectations
Mid-level (L4)
- Gets per-key sorted timelines and
bisect_right - 1right, including
the inclusive bound and the "before the first write" case. - Implements
removewith a tombstone, not by deleting history, and
explains why. - Walks through tests by hand, including the same-timestamp overwrite.
Senior (L5)
- Handles out-of-order writes without re-sorting, and names the O(n)
insert cost and its fix. - Defines
restoreprecisely (what happens to later writes and to keys
that did not exist yet) before coding it. Keeps the invariants in view
across parts. - Discusses the hot path (append in O(1)) and the memory growth from
keeping every version.
Staff (L6+)
- Frames the problem as MVCC: separates event time from arrival order,
and proposes lazy or non-destructive restore with sequence numbers. - Covers retention, compaction, and moving cold history to disk, and how
each one changes read semantics. - Reasons about concurrent readers and writers, sharding, and what
consistency a reader at timetgets while late events keep arriving.