What L5 looks like
- Routes untimed methods through the timed ones so Level 3 adds no second code path.
- Snapshots by copy and stores remaining TTL, so restored fields expire relative to the restore time.
- Writes a few tests per level before submitting rather than relying on the hidden suite.
Approach
What the assessment is. A timed online assessment (about 90 minutes) where one class grows over four levels and a hidden test suite grades each level. Later levels are only unlocked once earlier tests pass, and the earlier tests keep running. The skill being graded is less "clever algorithm" and more "pick a data model on Level 1 that survives Level 4", plus careful reading of edge cases.
The spec used here (the real one differs in names and small details; the shape is the same):
| Level | Methods | Rules |
|---|---|---|
| 1 |
set(key, field, value), get(key, field), delete(key, field)
|
get returns None when missing. delete returns whether something was removed. A record with no fields left disappears. |
| 2 |
scan(key), scan_by_prefix(key, prefix)
|
Return ["field(value)", ...] sorted by field name. Missing record gives []. |
| 3 |
set_at, set_at_with_ttl, get_at, delete_at, scan_at, scan_by_prefix_at
|
Each takes a timestamp. A field set with ttl is alive for timestamp <= t < timestamp + ttl. Timestamps across calls only grow. |
| 4 |
backup(timestamp), restore(timestamp, timestamp_to_restore)
|
backup saves the live state and returns how many records have at least one live field. restore rolls back to the latest backup taken at or before timestamp_to_restore. A field keeps the TTL it had left at backup time, counted again from the restore time. |
Clarify first (each one is a hidden test somewhere): Is set on an existing field an overwrite (yes)? Does deleting an expired field count as a delete (no, it was already gone)? Is the TTL window half-open (yes: at timestamp + ttl the field is dead)? Does a non-timestamped set mean "never expires" (yes)? What does restore do when no backup qualifies (assume the tests guarantee one; return None and change nothing)?
Data model, chosen for Level 4 on Level 1. One dict: records: key -> {field: (value, expires_at)} with expires_at = None for no TTL. Every read goes through one helper, _live_fields(key, now), that filters out expired fields. The untimed Level 1 and 2 methods are just the timed ones called with now = None, meaning "no expiry check". So Level 3 adds a parameter, not a second code path.
Expiry: lazy, not eager. Never run a timer. A field with expires_at <= now is treated as absent by every read. Timestamps only increase, so a field that is dead now stays dead. Optionally purge it when touched so memory does not grow forever.
Backup and restore. A backup stores, for each live field, its value and its remaining TTL (expires_at - backup_ts, or None). Restore rebuilds with expires_at = restore_ts + remaining. Backups go in a list in timestamp order (they are taken in time order), so bisect finds "latest backup at or before t" in O(log B). The snapshot must be a deep copy: if it shares the inner dicts with live state, the next set silently edits the backup. This is the most common Level 4 bug.
Worked example.
-
set_at_with_ttl("u1", "name", "ana", 10, 50)makes the field live on [10, 60). -
backup(30)recordsnamewith 30 left and returns1. -
set_at("u1", "name", "bo", 40)overwrites it with no TTL. -
restore(100, 35)picks the backup from time 30.nameis"ana"again with 30 left, so it expires at 130. -
get_at("u1", "name", 129)gives"ana", and at 130 it givesNone.
Code
from bisect import bisect_right
class RecordStore:
"""In-memory records of string fields, with TTL and point-in-time restore.
records: key -> {field: (value, expires_at)}; expires_at None = forever.
now=None in the helpers means "untimed call": no expiry check.
"""
def __init__(self):
self.records = {}
self.backups = [] # [(timestamp, {key: {field: (value, remaining)}})]
# -- helpers -----------------------------------------------------------
@staticmethod
def _alive(entry, now):
_, expires_at = entry
return now is None or expires_at is None or now < expires_at
def _live_fields(self, key, now):
fields = self.records.get(key, {})
return {f: e for f, e in fields.items() if self._alive(e, now)}
def _put(self, key, field, value, expires_at):
self.records.setdefault(key, {})[field] = (value, expires_at)
def _format(self, fields, prefix=""):
return [f"{f}({v})" for f, (v, _) in sorted(fields.items()) if f.startswith(prefix)]
# -- level 1 -----------------------------------------------------------
def set(self, key, field, value):
self._put(key, field, value, None)
def get(self, key, field):
return self.get_at(key, field, None)
def delete(self, key, field):
return self.delete_at(key, field, None)
# -- level 2 -----------------------------------------------------------
def scan(self, key):
return self.scan_at(key, None)
def scan_by_prefix(self, key, prefix):
return self.scan_by_prefix_at(key, prefix, None)
# -- level 3 -----------------------------------------------------------
def set_at(self, key, field, value, timestamp):
self._put(key, field, value, None)
def set_at_with_ttl(self, key, field, value, timestamp, ttl):
self._put(key, field, value, timestamp + ttl)
def get_at(self, key, field, timestamp):
entry = self.records.get(key, {}).get(field)
if entry is None or not self._alive(entry, timestamp):
return None
return entry[0]
def delete_at(self, key, field, timestamp):
fields = self.records.get(key)
if not fields or field not in fields:
return False
alive = self._alive(fields[field], timestamp)
del fields[field] # an expired field is purged either way
if not fields:
del self.records[key]
return alive
def scan_at(self, key, timestamp):
return self._format(self._live_fields(key, timestamp))
def scan_by_prefix_at(self, key, prefix, timestamp):
return self._format(self._live_fields(key, timestamp), prefix)
# -- level 4 -----------------------------------------------------------
def backup(self, timestamp):
snapshot = {}
for key in self.records:
live = self._live_fields(key, timestamp)
if live:
snapshot[key] = {
f: (v, None if exp is None else exp - timestamp)
for f, (v, exp) in live.items()
} # a new dict: later writes must not reach the backup
self.backups.append((timestamp, snapshot))
return len(snapshot)
def restore(self, timestamp, timestamp_to_restore):
i = bisect_right([t for t, _ in self.backups], timestamp_to_restore)
if i == 0:
return None
_, snapshot = self.backups[i - 1]
self.records = {
key: {
f: (v, None if left is None else timestamp + left)
for f, (v, left) in fields.items()
}
for key, fields in snapshot.items()
}
return None
The list comprehension in restore makes it O(B) to find the backup. Keep a parallel backup_times list if restores are frequent; the tests here call it a handful of times, so clarity wins.
Complexity
Let F be the number of fields in one record, R the number of records, N the total number of fields, and B the number of backups.
| Operation | Time | Notes |
|---|---|---|
set*, get*, delete*
|
O(1) | dict operations |
scan* |
O(F log F) | filter, then sort by field name |
backup |
O(N) | copies every live field |
restore |
O(B + N) | O(log B + N) with a kept list of backup times |
Space is O(N) for live state plus O(N) per backup. If backups are frequent and most data is unchanged between them, a persistent (copy-on-write) map makes each backup O(changes since the last one) instead.
Test cases
from solution import RecordStore
def test_set_get_overwrite():
s = RecordStore()
s.set("u1", "name", "ana")
assert s.get("u1", "name") == "ana"
s.set("u1", "name", "bo")
assert s.get("u1", "name") == "bo"
def test_missing_returns_none():
s = RecordStore()
assert s.get("nobody", "name") is None
s.set("u1", "name", "ana")
assert s.get("u1", "age") is None
def test_delete():
s = RecordStore()
s.set("u1", "name", "ana")
assert s.delete("u1", "name") is True
assert s.delete("u1", "name") is False
assert s.get("u1", "name") is None
assert s.delete("ghost", "x") is False
from solution import RecordStore
def test_scan_sorted_by_field():
s = RecordStore()
s.set("u1", "name", "ana")
s.set("u1", "age", "31")
s.set("u1", "city", "sf")
assert s.scan("u1") == ["age(31)", "city(sf)", "name(ana)"]
assert s.scan("u2") == []
def test_scan_by_prefix():
s = RecordStore()
for f, v in [("email_work", "a@w"), ("email_home", "a@h"), ("phone", "1")]:
s.set("u1", f, v)
assert s.scan_by_prefix("u1", "email") == ["email_home(a@h)", "email_work(a@w)"]
assert s.scan_by_prefix("u1", "zz") == []
assert s.scan_by_prefix("u1", "") == s.scan("u1")
def test_scan_after_delete_of_last_field():
s = RecordStore()
s.set("u1", "a", "1")
s.delete("u1", "a")
assert s.scan("u1") == []
from solution import RecordStore
def test_ttl_window_is_half_open():
s = RecordStore()
s.set_at_with_ttl("k", "f", "v", 10, 5)
assert s.get_at("k", "f", 10) == "v"
assert s.get_at("k", "f", 14) == "v"
assert s.get_at("k", "f", 15) is None
def test_overwrite_without_ttl_clears_expiry():
s = RecordStore()
s.set_at_with_ttl("k", "f", "old", 1, 3)
s.set_at("k", "f", "new", 2)
assert s.get_at("k", "f", 100) == "new"
def test_delete_expired_is_false():
s = RecordStore()
s.set_at_with_ttl("k", "f", "v", 1, 2)
assert s.delete_at("k", "f", 3) is False
assert s.scan_at("k", 4) == []
def test_scans_skip_expired_fields():
s = RecordStore()
s.set_at("k", "a", "1", 1)
s.set_at_with_ttl("k", "ab", "2", 1, 5)
s.set_at_with_ttl("k", "b", "3", 1, 50)
assert s.scan_at("k", 3) == ["a(1)", "ab(2)", "b(3)"]
assert s.scan_at("k", 6) == ["a(1)", "b(3)"]
assert s.scan_by_prefix_at("k", "a", 6) == ["a(1)"]
from solution import RecordStore
def test_backup_counts_records_with_live_fields():
s = RecordStore()
s.set_at("a", "f", "1", 1)
s.set_at_with_ttl("b", "f", "2", 1, 3)
assert s.backup(2) == 2
assert s.backup(10) == 1
def test_restore_rolls_back_and_recomputes_ttl():
s = RecordStore()
s.set_at_with_ttl("u1", "name", "ana", 10, 50) # alive [10, 60)
assert s.backup(30) == 1 # 30 left
s.set_at("u1", "name", "bo", 40)
s.set_at("u2", "x", "y", 41)
s.restore(100, 35)
assert s.get_at("u1", "name", 129) == "ana"
assert s.get_at("u1", "name", 130) is None
assert s.get_at("u2", "x", 101) is None
def test_backup_is_not_aliased_to_live_state():
s = RecordStore()
s.set_at("k", "f", "before", 1)
s.backup(2)
s.set_at("k", "f", "after", 3)
s.set_at("k", "g", "new", 4)
s.restore(5, 2)
assert s.scan_at("k", 6) == ["f(before)"]
def test_restore_picks_latest_backup_not_after_target():
s = RecordStore()
s.set_at("k", "v", "1", 1)
s.backup(5)
s.set_at("k", "v", "2", 6)
s.backup(10)
s.set_at("k", "v", "3", 11)
s.restore(20, 9)
assert s.get_at("k", "v", 21) == "1"
s.restore(22, 10)
assert s.get_at("k", "v", 23) == "2"
Follow-ups
-
Make
backupcheap. Store a version log per field (field -> [(ts, value, expires_at)]) instead of copying everything. Restoring to time t is then a binary search per field. Better still is a persistent hash map (HAMT), where a backup is just keeping a pointer to the current root. -
Expiry at scale. Lazy expiry leaks memory for keys nobody reads. Add a min-heap of
(expires_at, key, field)and pop due entries on each call, skipping stale heap entries whose expiry no longer matches. Redis does a mix of the two: lazy on access plus a sampled sweep. -
Concurrency. A single lock around the store is correct and simple. For more throughput, shard by key with one lock per shard.
backupthen needs either a global pause or per-shard snapshots taken at the same logical time. -
Durability. Append every mutation to a write-ahead log, fsync in batches, and treat
backupas a checkpoint so recovery replays only the log after it. - Out-of-order timestamps. If calls can arrive with older timestamps, lazy expiry stays correct for reads, but the "dead stays dead" argument breaks for purging. Purge only fields that expired before the oldest timestamp still allowed.
Interviewer expectations
Mid-level (L4)
- Passes Levels 1 and 2 quickly, including empty records, missing keys and sorted output.
- Gets the TTL boundary right (dead at exactly
timestamp + ttl) and treats expired fields as absent in every read. - Reaches Level 4 within the time limit, even if
restoreis not fully right.
Senior (L5)
- Routes untimed methods through the timed ones so Level 3 adds no second code path.
- Snapshots by copy and stores remaining TTL, so restored fields expire relative to the restore time.
- Writes a few tests per level before submitting rather than relying on the hidden suite.
Staff (L6+)
- Finishes all four levels with time left and code another engineer could extend.
- Names the trade-off between eager and lazy expiry and between full-copy and versioned backups, with their costs.
- Explains how the same model becomes a durable store: write-ahead log, checkpoints, sharded locking.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 4-level tests, run in your browser.




