Q1 - MongoDB Point-in-Time Versioned Store (~3 Parts)
We believe this interview question and close variations of it have frequently appeared in MongoDB's software engineering interviews, both in the phone screen and the onsite practical round. Candidates have reported it under names like the "Versioned Datastore" or "point-in-time key/value store."
This is a quintessential MongoDB problem, and it is not a disguised LeetCode problem. Here is what makes it so characteristic of MongoDB:
-
You are building a tiny version of MongoDB itself. The task mirrors how MongoDB's storage engine (WiredTiger) serves point-in-time reads, the ability to ask "what did this document look like as of timestamp T?" This powers real features like snapshot isolation, the oplog, and change streams.
-
Correctness and clean design over cleverness. MongoDB interviewers care that your code runs, handles edge cases, and reads like something they'd accept in a pull request, not that you found a one-line trick.
-
Expect a concurrency twist. As with most MongoDB interviews, a "now make it thread-safe" follow-up is common. That's the focus of Part 3 below.
-
Progressive parts. Like Stripe, MongoDB often hands you the next part only once the current one is solid. Each part below builds on the last.
Note: Read the Final Notes section at the end for MongoDB-specific tips and the deeper systems insight behind this question.
In the distributed core of MongoDB, a single document can be written many times. Yet the database can still answer a powerful question: "What was the value of this key as of a particular moment in time?" This is the foundation of point-in-time reads, reads that see a consistent snapshot of the data, even while new writes are streaming in.
Your task is to build a simplified version of this engine: a versioned key/value store. Every write is stamped with a timestamp, and every read is also parameterized by a timestamp, returning the value the key held at that point in its history.
Assumption (just like MongoDB's real oplog): for any single key, writes arrive with strictly increasing timestamps. MongoDB's oplog guarantees monotonic timestamps per write, so you may treat each key's version history as an append-only, time-ordered log.
Part 1: Point-in-Time Reads (~10 minutes)
Implement a VersionedStore supporting two operations:
set(key, value, timestamp), records thatkeyheldvaluestarting at timetimestamp.get(key, timestamp), returns the value associated with the version whose timestamp is the largest one less than or equal to the querytimestamp. If no such version exists (the key was never written at or before that time), returnnull/None.
Example 1
Operations:
set("user:1", "Alice", 5)
set("user:1", "Alphonse", 12)
get("user:1", 5) -> "Alice"
get("user:1", 9) -> "Alice" # newest version at or before t=9 is the one at t=5
get("user:1", 12) -> "Alphonse"
get("user:1", 100) -> "Alphonse" # the latest version persists into the future
get("user:1", 3) -> null # no version existed at or before t=3
get("user:2", 5) -> null # key never written
Solution - Part 1
The key insight: store, per key, a list of (timestamp, value) versions ordered by time. Because timestamps for a key are strictly increasing, every set is a simple append, the list stays sorted for free.
For get, we need the rightmost version whose timestamp is ≤ the query time. A sorted list plus this "find the last element ≤ target" requirement is the textbook setup for binary search.
This is the same "floor in a sorted array" binary search you'd use anywhere, but notice we return the value at that index, not the index itself. Getting the boundary condition right (<=, and remembering the last valid candidate) is what interviewers watch for here.
class VersionedStore:
def __init__(self):
# key -> list of (timestamp, value), kept sorted by timestamp
self.store = {}
def set(self, key: str, value: str, timestamp: int) -> None:
# Timestamps per key strictly increase, so append keeps it sorted.
self.store.setdefault(key, []).append((timestamp, value))
def get(self, key: str, timestamp: int):
versions = self.store.get(key)
if not versions:
return None
# Binary search for the rightmost version with version_ts <= timestamp.
lo, hi, result = 0, len(versions) - 1, None
while lo <= hi:
mid = (lo + hi) // 2
if versions[mid][0] <= timestamp:
result = versions[mid][1] # candidate; keep searching to the right
lo = mid + 1
else:
hi = mid - 1
return result
Complexity Analysis
- set: O(1) amortized, a single append to the key's version list.
- get: O(log n), where n is the number of versions for that key (binary search).
- Space: O(m), where m is the total number of writes across all keys, every version is retained.
Part 2: Deletes and Tombstones (~15 minutes)
Now the interviewer adds a delete(key, timestamp) operation, and this is where the problem stops being a standard exercise and starts behaving like a real database.
The naive instinct is to remove the key's data. Resist it. If you physically delete the history, you destroy the ability to answer point-in-time reads from before the deletion. A read at an earlier timestamp must still see the value that was live back then.
The correct model, the one MongoDB and virtually every log-structured database use, is a tombstone: a delete is just another version, a marker that says "as of this timestamp, the key is gone." Reads then resolve against tombstones like any other version.
So get(key, timestamp) now works like this:
- Find the newest version with timestamp
≤the query time (same binary search as Part 1). - If there is no such version → return
null. - If that version is a tombstone → the key was deleted as of that time → return
null. - Otherwise → return its value.
Example 2
Operations:
set("user:1", "Alice", 5)
delete("user:1", 10)
set("user:1", "Bob", 15)
get("user:1", 3) -> null # nothing existed yet at t=3
get("user:1", 7) -> "Alice" # newest version at or before t=7 is set@5
get("user:1", 12) -> null # newest version at or before t=12 is the tombstone@10
get("user:1", 20) -> "Bob" # the key was "resurrected" by set@15
Visualizing the Version Timeline
The magic of this model is that the same key answers different values depending on when you ask, and a delete in the future never erases the past:
key = "user:1"
t=5 t=10 t=15
┌───────────┐ ┌───────────┐ ┌───────────┐
│ SET │ │ DELETE │ │ SET │
│ "Alice" │ │ (tombstone)│ │ "Bob" │
└───────────┘ └───────────┘ └───────────┘
──────●─────────────────────●───────────────────●──────────────▶ time
│ │ │
get(7)="Alice" get(12)=null get(20)="Bob"
get(3)=null (deleted as of t=10) (resurrected at t=15)
Notice get("user:1", 7) still returns "Alice" even though the key was deleted at t=10 and rewritten at t=15. A read in the past is immune to the future. This is exactly how MongoDB serves a snapshot read at an older cluster time while newer writes, including deletes, keep flowing in.
Solution - Part 2
We extend each version with a boolean deleted flag. set appends a normal version; delete appends a tombstone (a version with deleted = true and no meaningful value). The get binary search is unchanged. We simply inspect the deleted flag on the winning version before returning.
class VersionedStore:
def __init__(self):
# key -> list of (timestamp, value, deleted), sorted by timestamp
self.store = {}
def set(self, key: str, value: str, timestamp: int) -> None:
self.store.setdefault(key, []).append((timestamp, value, False))
def delete(self, key: str, timestamp: int) -> None:
# A delete is a tombstone version, NOT a physical removal.
self.store.setdefault(key, []).append((timestamp, None, True))
def get(self, key: str, timestamp: int):
versions = self.store.get(key)
if not versions:
return None
lo, hi, found = 0, len(versions) - 1, None
while lo <= hi:
mid = (lo + hi) // 2
if versions[mid][0] <= timestamp:
found = versions[mid] # candidate; keep searching to the right
lo = mid + 1
else:
hi = mid - 1
# No version, or the winning version is a tombstone.
if found is None or found[2]:
return None
return found[1]
Complexity Analysis
- set / delete: O(1) amortized, both are appends; a delete is just a tombstone version.
- get: O(log n), the binary search is unchanged; the tombstone check is O(1).
- Space: O(m), tombstones count as versions, so deleting does not reclaim space (real engines reclaim it later via background compaction).
Part 3: Concurrent Access (~25 minutes)
Here comes the part MongoDB is famous for. The interviewer leans in: "Good. Now imagine many threads are hitting this store at the same time, multiple writers calling set/delete, and many readers calling get. Make it thread-safe."
This is the single most common MongoDB extension, and it separates candidates who use data structures from those who understand what happens underneath them. The danger: two threads appending to the same key's list simultaneously can corrupt it (lost writes, resized-array races, partially-constructed objects). A reader binary-searching a list while another thread mutates it can read garbage or crash.
The clean, correct answer is a read–write lock:
setanddeletetake the write lock (exclusive, only one writer, and no readers, at a time).gettakes the read lock (shared, many readers may proceed in parallel).
This is the right call because this workload, like most database workloads, is read-heavy. A plain mutex would needlessly serialize readers; a read–write lock lets them run concurrently and only blocks them while a writer is mid-append.
The insight interviewers are really probing for: why is this even mostly safe to begin with? Because we never mutate an existing version. We only ever append new ones. Historical versions are immutable. A reader doing a point-in-time read at an old timestamp is looking at data that, by construction, can never change. The lock isn't protecting the data; it's only protecting the structure of the list while it's being appended to and possibly reallocated. State that out loud in the interview. It's the MVCC idea, and it's exactly how MongoDB's WiredTiger engine lets reads run without blocking writes.
Example 3, interleaved threads
Threads (any interleaving):
Thread A: set("config", "v1", 1)
Thread B: set("config", "v2", 2)
Thread C: get("config", 1) -> "v1" (never a crash, never a torn read)
Thread D: get("config", 2) -> "v2"
Thread E: get("config", 5) -> "v2"
Writers are serialized with each other; readers run concurrently
and always observe a consistent version list.
Solution - Part 3
We wrap the Part 2 logic in a read–write lock. The algorithm is identical, only the synchronization is new. Below are the languages where true OS-thread parallelism applies; see the note afterward on JavaScript/TypeScript.
import threading
class VersionedStore:
def __init__(self):
self.store = {}
# Python's stdlib has no read-write lock, and the GIL already serializes
# bytecode, but it does NOT make compound append+read sequences atomic.
# A single mutex is the correct, simple guarantee here.
self._lock = threading.Lock()
def set(self, key: str, value: str, timestamp: int) -> None:
with self._lock:
self.store.setdefault(key, []).append((timestamp, value, False))
def delete(self, key: str, timestamp: int) -> None:
with self._lock:
self.store.setdefault(key, []).append((timestamp, None, True))
def get(self, key: str, timestamp: int):
with self._lock:
versions = self.store.get(key)
if not versions:
return None
lo, hi, found = 0, len(versions) - 1, None
while lo <= hi:
mid = (lo + hi) // 2
if versions[mid][0] <= timestamp:
found = versions[mid]
lo = mid + 1
else:
hi = mid - 1
if found is None or found[2]:
return None
return found[1]
What about JavaScript / TypeScript? This is a great thing to raise proactively in the interview. Node.js runs your code on a single-threaded event loop, so two get/set calls can never execute in parallel on the same memory. Our synchronous Part 2 methods are therefore already race-free. There's no torn read to defend against. The concurrency you do have to reason about in JS is interleaving across await points: if a method awaits in the middle of a read-modify-write, another task can run in between. Since our operations are fully synchronous, no lock is needed. Saying this out loud signals that you understand why a lock is required in Java/Go/C++ and why it isn't in Node, not just that you can sprinkle lock() everywhere.
Complexity Analysis
- set / delete: O(1) amortized work, plus exclusive lock acquisition.
- get: O(log n) work under a shared lock, multiple readers proceed concurrently.
- Contention: writers briefly block everyone; in a read-heavy workload the read–write lock keeps reader throughput high. A finer-grained design uses one lock per key so writes to different keys never contend.
Final Notes
We believe this three-part question, or a close cousin of it, has been asked repeatedly in MongoDB interviews, including the practical/onsite rounds. It is the kind of problem MongoDB loves precisely because it isn't a party trick: it's a tiny, honest slice of how their database actually works.
Here's the real-talk on why MongoDB reaches for this question and how to shine on it:
1. You're rebuilding MongoDB's storage engine in miniature. Point-in-time reads, immutable versions, tombstones, snapshot isolation, these aren't interview contrivances. They are the literal mechanics of WiredTiger (MongoDB's storage engine) and the oplog. When you connect your solution back to "this is how a snapshot read at an older cluster time works," you stop sounding like a candidate grinding LeetCode and start sounding like a future MongoDB engineer. Interviewers notice that instantly.
2. The tombstone is the "aha" they're waiting for. Many candidates physically delete data in Part 2 and silently break point-in-time reads from before the deletion. The ones who pause and say "a delete should be a version, not a removal, or I'll lose history" have demonstrated database intuition that's hard to fake. That single decision is often the difference between a "hire" and a "leaning hire."
3. Concurrency is where MongoDB interviews live. As we covered on the prep guide, the "now make it thread-safe" follow-up shows up more at MongoDB than almost anywhere else. Don't just slap on a mutex, articulate the read-heavy → read–write lock reasoning, mention per-key locking to reduce contention, and land the immutability/MVCC insight that explains why reads don't fundamentally need to block writes. That progression (correct → concurrent → lock-free-in-principle) is what a senior answer walks through out loud.
4. Make it run, and test the boundaries. Remember MongoDB uses a real compiler (Coderpad), your code is expected to execute. Before you call it done, walk these edge cases out loud and verify them: a get before any write, a get exactly on a version's timestamp, a get between two versions, a key that is set → deleted → set again, and a get far in the future. These are precisely the cases the interviewer's hidden tests will hit.
Where this can go next: strong candidates sometimes get a bonus extension, a range(key, startTime, endTime) that returns every version in a window (this is essentially a change stream / oplog scan), or a compact() that garbage-collects versions older than the oldest active snapshot (this is history reclamation). If you finish early, offering one of these unprompted is a fantastic signal. We'll explore these in later questions.
The bottom line: this problem looks like "Time-Based Key-Value Store" at first glance, but MongoDB pushes it well past the textbook version into deletes, snapshots, and concurrency, the exact terrain their engineers walk every day. Nail the systems reasoning, not just the binary search, and you'll stand out.