Coding problem · Medium · Design & Data Structures

Cache with Version-Checked Sets

Medium Practical tasks

In cache-aside, a reader can load version 1 from the database, a writer can commit version 2 and invalidate, and the reader can then cache version 1 — a stale set. Prevent it by attaching the database row version to every write into the cache.

  • set(key, value, version) — store the value if the key has no entry, or if version is greater than the stored entry's version, or (after a delete) if version is at least the delete's version. Return whether it was stored.
  • delete(key, version) — invalidate after a writer committed version: remove the value but remember a tombstone with that version.
  • get(key) — the cached value, or null if missing or deleted.

See The Stale-Set Race.

Example 1

Input: ["VersionedCache","set","get","delete","set","get","set","get","set","set"]
[[],["p","100",1],["p"],["p",2],["p","100",1],["p"],["p","80",2],["p"],["p","80",2],["p","70",3]]
Output: [null,true,"100",null,false,null,true,"80",false,true]
Explanation: The slow reader's version-1 set after the version-2 delete is rejected; a fresh reader's version-2 set is accepted.

Hints

Hint 1

Store (value, version, deleted?) per key.

Hint 2

A live entry accepts strictly newer versions; a tombstone accepts versions ≥ its own.

Write and run your solution

Loading the editor… (JavaScript is required to run code in your browser.)

Solution and explanation

Show the solution

Time O(1) per operation · Space O(keys)

Versions turn "last write wins" into "newest write wins". A live entry only accepts strictly newer versions, so a slow reader can't replace newer data. A delete leaves a tombstone carrying the version that caused it, so a reader that loaded an older version before the delete is rejected, while a reader that loaded the committed version is accepted. In Redis the comparison and write must be one atomic Lua script; memcache's leases solve the same race.

Reference solution

class VersionedCache {
  constructor() {
    this.entries = new Map(); // key -> { value, version, deleted }
  }

  set(key, value, version) {
    const e = this.entries.get(key);
    const ok = !e || (e.deleted ? version >= e.version : version > e.version);
    if (ok) this.entries.set(key, { value, version, deleted: false });
    return ok;
  }

  delete(key, version) {
    this.entries.set(key, { value: null, version, deleted: true });
  }

  get(key) {
    const e = this.entries.get(key);
    return e && !e.deleted ? e.value : null;
  }
}

Common mistakes

  • Forgetting the tombstone: after a plain delete any old version could be cached again.
  • Accepting equal versions on live entries (pointless writes).
  • Rejecting the committed version after a delete (must use ≥).

Follow-up questions

  • Expire tombstones after the maximum read duration.
  • Implement set-if-newer as a Redis Lua script.