~/systems/time-travel-kv
Time-travel key-value store
Keep every version of every key, sorted by time, and answer "what was the value at time t" with one binary search.
Per key, a sorted list of write times and a parallel list of values. A read finds the last write at or before t with bisect_right minus one; deletes are tombstones; equal times go to the later call.
Versioned config, snapshots, "value as of" queries, audit history, and any OA level that asks about the past.
O(log v) per read, O(log v) to O(v) per write
O(total writes)
You’ll recognise it when
- Each
setcarries a timestamp, andget(key, t)asks for the value as of timet. - The answer is “the latest write at or before
t”, and before the first write there is no value. - Old versions must stay readable after newer writes and after deletes.
- Follow-ups ask about out-of-order timestamps, two writes at the same time, an injectable clock, or many threads.
It’s often confused with a plain dict that overwrites. The overwrite loses the past; this store keeps every version and turns each read into a binary search.
The idea
Think of the dated stamps in a passport. To know which visa was valid on a given day, you find the last stamp on or before that day. You never erase an old stamp; a cancellation is just another stamp.
Per key, keep two parallel lists in time order: times and values. A read is a floor lookup: the last position whose time is <= t. Python’s bisect_right(times, t) returns the position just after every time <= t, so subtracting one gives exactly that write, or -1 if there’s none. A delete appends a tombstone (a value of None) instead of erasing, so reads before the delete still see the old value.
How it works
- Find the slot. For
set(key, value, t), computei = bisect_right(times, t). It’s the position after every existing write at a time<= t, including any at exactlyt. - Insert at the slot, in both
timesandvalues. When writes arrive in time order,iis the end and this is an append. When an older timestamp shows up late, it slides into its place. Becauseiis after equal times, a second write at the same time lands after the first: the later call wins. - Delete is a write.
delete(key, t)writesNoneattthe same way. - Read.
get(key, t)computesbisect_right(times, t) - 1. A negative index means no write by then; otherwise return the value there, which isNoneafter a delete.
Follow one key, door:
| call | times after |
answer |
|---|---|---|
set shut at 10, set open at 30 |
10, 30 | |
| get at 20 | shut |
|
set ajar at 20 (arrives late) |
10, 20, 30 | |
| get at 25, get at 5 | ajar, none |
|
| delete at 40 | 10, 20, 30, 40 | |
| get at 45, get at 35 | none, open |
|
set stuck at 30, get at 30 |
10, 20, 30, 30, 40 | stuck |
Why it’s correct: times stays sorted after every insert, and equal times keep the order of the calls. So for any t, the entry just before bisect_right(times, t) is the write with the largest time <= t, latest call first among equals. That’s the definition of the value at t.
from bisect import bisect_right
class TimeMap:
"""Every version of every key. Writes may arrive out of time order."""
def __init__(self):
self.times = {} # key -> sorted write times
self.values = {} # key -> value written at the same position (None = deleted)
def _write(self, key, value, t):
times = self.times.setdefault(key, [])
values = self.values.setdefault(key, [])
i = bisect_right(times, t) # after any equal time: the later call wins
times.insert(i, t)
values.insert(i, value)
def set(self, key, value, t):
self._write(key, value, t)
def delete(self, key, t):
self._write(key, None, t) # a tombstone keeps older versions readable
def get(self, key, t):
times = self.times.get(key, [])
i = bisect_right(times, t) - 1 # the last write at or before t
return self.values[key][i] if i >= 0 else None#include <algorithm>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
using namespace std;
// Every version of every key. Writes may arrive out of time order.
class TimeMap {
unordered_map<string, vector<long long>> times; // key -> sorted write times
unordered_map<string, vector<optional<string>>> values; // same positions; nullopt = deleted
void write(const string& key, optional<string> value, long long t) {
auto& ts = times[key];
auto& vs = values[key];
size_t i = upper_bound(ts.begin(), ts.end(), t) - ts.begin(); // after equal times: the later call wins
ts.insert(ts.begin() + i, t);
vs.insert(vs.begin() + i, move(value));
}
public:
void set(const string& key, const string& value, long long t) { write(key, value, t); }
void erase(const string& key, long long t) { write(key, nullopt, t); } // a tombstone
optional<string> get(const string& key, long long t) const {
auto it = times.find(key);
if (it == times.end()) return nullopt;
const auto& ts = it->second;
long i = upper_bound(ts.begin(), ts.end(), t) - ts.begin() - 1; // the last write at or before t
return i >= 0 ? values.at(key)[i] : nullopt;
}
};import java.util.*;
// Every version of every key. Writes may arrive out of time order.
class TimeMap {
private final Map<String, List<Long>> times = new HashMap<>(); // key -> sorted write times
private final Map<String, List<String>> values = new HashMap<>(); // same positions; null = deleted
// The first position whose time is greater than t.
private static int upperBound(List<Long> ts, long t) {
int lo = 0, hi = ts.size();
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (ts.get(mid) <= t) lo = mid + 1;
else hi = mid;
}
return lo;
}
private void write(String key, String value, long t) {
List<Long> ts = times.computeIfAbsent(key, k -> new ArrayList<>());
List<String> vs = values.computeIfAbsent(key, k -> new ArrayList<>());
int i = upperBound(ts, t); // after equal times: the later call wins
ts.add(i, t);
vs.add(i, value);
}
void set(String key, String value, long t) { write(key, value, t); }
void delete(String key, long t) { write(key, null, t); } // a tombstone
String get(String key, long t) {
List<Long> ts = times.get(key);
if (ts == null) return null;
int i = upperBound(ts, t) - 1; // the last write at or before t
return i >= 0 ? values.get(key).get(i) : null;
}
}Real clocks, tests and threads
- Inject the clock. Code that calls
time.time()inside is hard to test. Take aclockargument that defaults totime.time, and tests pass a fake that returns whatever time they want. - Clocks go backwards. Wall clocks get corrected and can jump back. If versions must be strictly increasing per key, either reject a write that isn’t newer than the last one, or nudge it to just after it (
last + epsilon) and say which policy you chose. - Threads. Two threads inserting into the same lists can corrupt them, and a reader can see
timesupdated but notvalues. Guard each key’s lists with a lock (or one lock for the whole map to start with), and make the read and the insert each happen entirely under it.
Why it’s O(log v)
A read is one binary search over the key’s v versions: O(log v). A write finds its slot in O(log v), but inserting into the middle of a Python list or a vector shifts everything after it: O(v) in the worst case. When timestamps arrive in order, which is the common case, the insert is at the end and costs O(1) amortized.
| case | write | read |
|---|---|---|
| times arrive in order | O(1) amortized | O(log v) |
| times arrive out of order | O(v) shift | O(log v) |
a balanced tree (TreeMap) per key |
O(log v) | O(log v) |
In Java, a TreeMap<Long, String> per key with floorEntry(t) does the whole job in O(log v) either way. Space is one entry per write: O(total writes).
Common mistakes
Using bisect_left for the read
bisect_left(times, t) stops before a write at exactly t, so subtracting one returns the version before it. A write at time t must be visible at time t.
i = bisect_left(times, t) - 1 # ✗ misses a write at exactly t
i = bisect_right(times, t) - 1 # ✓ includes it
Appending when times can arrive out of order
times.append(t) only keeps the list sorted if every write is newer than the last one. One late write breaks the binary search for every later read.
times.append(t) # ✗ unsorted after a late write
times.insert(bisect_right(times, t), t) # ✓ stays sorted
Erasing on delete
Removing the key or its versions makes reads before the delete wrong. A delete is just a version whose value is “nothing”.
del self.times[key] # ✗ the past disappears
self._write(key, None, t) # ✓ a tombstone at time t
Wrapping index -1
When every write is newer than t, the index is -1, and Python’s values[-1] silently returns the newest value instead of failing.
return values[bisect_right(times, t) - 1] # ✗ -1 means the last one
return values[i] if i >= 0 else None # ✓ nothing written yet
Variations
- Range queries. “Every version between
t1andt2” is a slice between two bisects:bisect_left(times, t1)andbisect_right(times, t2). - Whole-store snapshots. “The whole store as of
t” can be one floor lookup per key, or a global version counter where a snapshot just remembers the counter. - Fields and TTLs. Store
(value, expires_at)per version and check expiry in the same lookup. The in-memory database guide builds that. - Trimming history. If only the last k versions or the last N days matter, drop old entries from the front when writing, and say what reads before that window return.
- Persistence. Append each write to a log on disk and rebuild the lists on startup; see serialization.
Climb the ladder
Our time-travel key-value problems in ladder order.
- The value as of time t: the floor lookup on its own.
- Library display shelves over time: versions and clears per key.
- Time-Based Key-Value Store: the classic store with sorted timestamps.
- Versioned store with real clocks and threads: out-of-order writes, an injectable clock, ordering policies and thread safety.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
A key was written at times 10, 20, 20 and 30. What does this print?
from bisect import bisect_left, bisect_righttimes = [10, 20, 20, 30]print(bisect_left(times, 20) - 1, bisect_right(times, 20) - 1)bisect_leftgives 1, the first 20, so minus one is index 0: the write at 10, which wrongly ignores the writes at exactly 20.bisect_rightgives 3, after both 20s, so minus one is index 2: the later write at 20. For “latest write at or before t” you want the right one. -
2
Both writes happened after time 5. What does this read print?
from bisect import bisect_righttimes, values = [10, 20], ["a", "b"]i = bisect_right(times, 5) - 1print(values[i])Nothing was written by time 5, so
iis -1, and Python treatsvalues[-1]as the last element instead of raising. The read silently returns the newest value. Checki >= 0and returnNoneotherwise. -
3
In Java, each key’s versions are in a
TreeMap<Long, String>from time to value. Which call returns the version in force at timet?floorEntryreturns the entry with the greatest key<= t: the last write at or beforet, in O(log v).lowerEntryis strictly less, so it skips a write at exactlyt. The ceiling and higher variants look forward in time, which would read the future. -
4
Many threads share one versioned store.
setdoes abisect_righton the key’stimes, then inserts intotimesand intovalues. What can go wrong without a lock?Each single insert may be atomic, but the bisect and the two inserts are three steps. Another thread can insert between them, so a position computed a moment ago is stale, and a reader can see
timesupdated beforevalues. Hold one lock across the whole read-modify-write. There’s no lock to deadlock on yet. -
5
get(key)with no timestamp should read the value as of the current time. How should a test check that, quickly and reliably?An injected clock (
TimeMap(clock=fake), defaulting totime.time) lets the test decide what “now” is, so it runs instantly and the same way every time. Sleeping makes tests slow, and tolerances and retries hide flakiness instead of removing it.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.