Anthropic OA: leveled in-memory record store

0
Remix

A timed, multi-level exercise building an in-memory store of records with fields: create and read first, then scans and filters, then timestamps with expiry, then backup and restore to an earlier point. Each level builds on the previous one, and earlier tests keep running.

  • Level 1: set(key, field, value), get(key, field) (None when missing), delete(key, field) (returns whether a live field was removed).
  • Level 2: scan(key) and scan_by_prefix(key, prefix) return ["field(value)", ...] sorted by field.
  • Level 3: timed versions of every call (set_at, set_at_with_ttl, get_at, delete_at, scan_at, scan_by_prefix_at). A field with a TTL is alive for timestamp <= t < timestamp + ttl. Timestamps only grow.
  • Level 4: backup(timestamp) saves the live state and returns the number of records with a live field. restore(timestamp, timestamp_to_restore) rolls back to the latest backup at or before timestamp_to_restore; each field keeps the TTL it had left at backup time, counted from the restore.
class RecordStore:
    def set(self, key, field, value): ...
    def get(self, key, field): ...
    def delete(self, key, field): ...

    def scan(self, key): ...
    def scan_by_prefix(self, key, prefix): ...

    def set_at(self, key, field, value, timestamp): ...
    def set_at_with_ttl(self, key, field, value, timestamp, ttl): ...
    def get_at(self, key, field, timestamp): ...
    def delete_at(self, key, field, timestamp): ...
    def scan_at(self, key, timestamp): ...
    def scan_by_prefix_at(self, key, prefix, timestamp): ...

    def backup(self, timestamp): ...
    def restore(self, timestamp, timestamp_to_restore): ...
AnthropicMicrosoft AIAirbnbMetaxAIOnline assessmentMediumLast asked Sep 2026

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.
Interviewer expectations for every level ↓

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) records name with 30 left and returns 1.
  • set_at("u1", "name", "bo", 40) overwrites it with no TTL.
  • restore(100, 35) picks the backup from time 30. name is "ana" again with 30 left, so it expires at 130.
  • get_at("u1", "name", 129) gives "ana", and at 130 it gives None.

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

  1. Make backup cheap. 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.
  2. 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.
  3. Concurrency. A single lock around the store is correct and simple. For more throughput, shard by key with one lock per shard. backup then needs either a global pause or per-shard snapshots taken at the same logical time.
  4. Durability. Append every mutation to a write-ahead log, fsync in batches, and treat backup as a checkpoint so recovery replays only the log after it.
  5. 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 restore is 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.