Learning pathsPricingChecking sign-in…
← LLD problem library

LLD-014 · LOW LEVEL DESIGN

In-Memory Cache

Coordinate recency, expiry and in-flight loads without stale resurrection or lock-held I/O.

Local Java 21 reference verified: 321 checks. Learner drafts are not executed. Author self-review; independent review pending.

Existing introductory lesson · Version 1

Understand the problem

Build a generic local cache with count capacity, LRU eviction, expire-after-write TTL, invalidation and single-flight loading. Explain the difference between evicting entries, expiring values, cancelling callers and fencing old load completion.

Before you start

  • Hash maps and access-order linked maps
  • Monotonic elapsed time
  • Futures and single-flight coordination
  • Linearization and version/identity fencing

Scope and explicit decisions

  • One JVM instance; no persistence, distributed coherence, write-through, refresh-ahead, weak references or byte-weighted eviction. Capacity counts retained entries, not total bytes. Values and keys must satisfy the caller ownership contract.
  • Null keys/values are rejected; absence is Optional.empty. Keys must have stable equality/hash behavior. Values are returned by reference, so callers should use immutable V or provide a separate defensive-copy wrapper; the generic cache does not deep-copy arbitrary objects.
  • TTL is positive expire-after-write duration in trusted elapsed units. get does not extend TTL. A value is expired when expiresAt<=current time; cached reads are authoritative for that expiry check.
  • Explicit get/put/keys calls receive nonnegative nondecreasing time. getOrLoad samples an injected clock while holding the cache monitor at start and completion. This avoids callers sampling timestamps outside the serialization boundary.
  • LRU uses LinkedHashMap access ordering under one monitor. get hits move to newest. Before put/store, expired entries are purged so a dead entry does not evict a live entry needlessly.
  • One future per currently attached key shares load work. The loader runs outside the monitor. Same-thread recursive same-key load is rejected instead of joining its own future. Cross-thread cyclic loader dependencies are prohibited by contract, not detected by a full dependency graph.
  • Max active owner loads is explicit (default max(1,entryCapacity)). Invalidating a load detaches its cache insertion right but does not free that active-load slot until it finishes. Followers do not consume an extra owner slot.
  • invalidate and put detach any old attached future. Completion inserts only if the map still points to that same future identity. An old waiter may still receive the old loader result; this is write fencing, not cancellation or a guarantee that all readers see the new value.
  • Load TTL starts at successful completion. A failed/null/overflowed load completes its future exceptionally and releases owner capacity. Owner and joined caller exception wrappers may differ. Capacity zero permits loading but stores no entries.

Worked examples

Input: Capacity2: put A,B; get A; put C.

Outcome: B is LRU and evicted. A can still expire at its write deadline despite recent access.

Eviction and freshness answer different questions.

Input: Load k blocks; invalidate k; put k=new; old loader later returns old.

Outcome: Original waiter may receive old, but retained cache stays new.

Fencing controls publication, not cancellation or all-reader linearizability across invalidation.

Try before revealing

Hint 1

Which operations update recency even when they look like reads?

Hint 2

How can a late loader prove it still owns the right to publish?

Hint 3

Which resources remain active after invalidation removes a map entry?

Requirements and invariants

R1 — Retained live entries never exceed count capacity and hits update recency atomically. store protects I1.

R2 — Expired values are not returned as hits. get protects I2.

R3 — Only the attached future can publish its load result into the cache. getOrLoad protects I3.

R4 — Detached loads remain counted until completion, bounding active owner work. inFlight protects I4.

I1: The access-order map holds at most capacity entries; its oldest key is the next LRU eviction candidate after expiry cleanup.

I2: A hit is returned only when expiresAt>observed time; recency changes do not alter expiry.

I3: A completing loader stores only when flights.get(key) is exactly its own future identity.

I4: Active owner loaders, including detached ones, never exceed maxLoads; each releases its slot in finally.

Derive the design

A map plus LRU is only the starting point

A hash map with an access-order list can implement count-bounded LRU, but that does not define freshness or asynchronous loading. The reference uses Java’s LinkedHashMap for one consistent recency structure, adds deadline checks and a single-flight future map, then fences result publication by future identity. This keeps the most subtle state transitions explicit rather than pretending a concurrent map solves the whole cache.

Alternative: A handwritten hash-map+doubly-linked-list is useful for understanding LRU mechanics, as in the DSA lesson, but production component behavior also includes expiry, ownership and load failure. Sharding or a mature caching library may be practical after contracts and measurements are clear.

Define key/value ownership

Stable key equality is essential for maps. Mutable values are returned by reference here; immutability or a copy adapter is a caller requirement, not magically supplied by generics.

Establish one owner for recency and expiry

Even a get mutates access ordering. A thread-safe hash lookup does not protect recency, expiry removal and capacity decisions as a compound operation.

Release ownership during loading

Publish a future before leaving the monitor so followers can attach. The external Supplier runs without the cache lock, then re-enters for a short conditional publish.

Fence identity rather than assume cancellation

Removing a future does not stop its worker. Compare exact identity on completion and retain active-work accounting until it actually ends.

Make failure paths release the same resources

A thrown/null result or deadline overflow must complete waiting futures exceptionally, detach only its own identity and release active-load capacity in finally.

Responsibilities and ownership

Cache

Owns: Entry recency, expiry clock, attached futures and active-owner admission

Methods: get, put, invalidate, getOrLoad, keys

One monitor coordinates every compound cache mutation.

Entry

Owns: Value reference and immutable expiry deadline

Methods: Private record

Does not deep-copy or make mutable V thread-safe.

CompletableFuture per flight

Owns: One load result for existing waiters and temporary insertion identity

Methods: join / complete / completeExceptionally

Detachment removes write authority, not existing waiter ownership.

Loader / clock ports

Owns: External load work / trusted time observations

Methods: Supplier and LongSupplier

Load work runs outside the monitor; clock sampling stays inside.

Miss and load ownership

Keys and values have explicit caller ownership requirements; the cache is local and non-durable.

  1. Lookup under cache monitor
  2. ↓ Validate TTL and recency

    Hit or attach one future
  3. ↓ Followers join stable result

    Owner loads outside monitor
  4. ↓ Release active slot in finally

    Identity-checked publish
Invalidation fence

Fencing stale publication does not cancel the worker or retroactively change results already awaited.

  1. Old load future attached
  2. ↓ Revoke publication authority

    invalidate / put detaches it
  3. ↓ Worker still counts as active

    Old worker finishes
  4. ↓ Only current identity may store

    Complete old waiter without cache write

Data model, schema and relationships

Cache entries, key generations and in-flight loaders are different lifecycles. A removed entry can still have an old loader running; generation/identity fencing stops stale publication.

Implementation boundary: This is an in-memory LRU/TTL cache. The entity view documents local object ownership; optional persistence would store values/metadata, while live loader threads/futures must never be serialized as resumable work.

These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.

cache_config: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

cache_config relationshipscache_config 1 to cache_key 0..*: scopes keys. cache_key.cache_id → cache_config.id.scopes keys1 parent · 0..* childrencache_keyid [PK]cache_id [FK]generationcache_configid [PK]capacityttl
cache_key: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

cache_key relationshipscache_key 1 to cache_entry 0..1: has resident value. cache_entry.key_id → cache_key.id. cache_key 1 to load_attempt 0..*: owns load attempts. load_attempt.key_id → cache_key.id.has resident value1 parent · 0..1 childrencache_entrykey_id [PK]valueexpires_atowns load attempts1 parent · 0..* childrenload_attemptid [PK]key_id [FK]generationcache_keyid [PK]cache_id [FK]generation

Entity definitions and field contracts

cache_config

Local cache namespace and bounds.

FieldType / keyMeaning and reference
idtext · PKCache identity
capacityinteger · VALUEMaximum entry count
ttlbigint · VALUEWrite TTL duration

Code mapping: Cache constructor settings.

  • Count limit is not a byte quota.

cache_key

Namespace-scoped key identity.

FieldType / keyMeaning and reference
idtext · PKOpaque key identity
cache_idtext · FKCache namespace · cache_config.id
generationbigint · VALUEInvalidation/publication epoch
logical_keytext · VALUESerialized namespace-local lookup key; encoding/version must be stable

Code mapping: Key/identity map.

  • Unique(cache_id,logical_key); generation increases when replacing ownership.

cache_entry

Current resident value.

FieldType / keyMeaning and reference
key_idtext · PK,FKOwned key · cache_key.id
valuebytes · VALUECaller-owned immutable value
expires_atbigint · VALUETrusted TTL deadline
access_rankbigint · VALUELRU projection

Code mapping: LinkedHashMap entry; optional bytes encoding extension.

  • At most one current entry per key; expiry checked on read.

load_attempt

Local in-flight owner observation.

FieldType / keyMeaning and reference
iduuid · PKLoad identity
key_idtext · FKRequested key · cache_key.id
generationbigint · VALUECaptured publication epoch
stateenum · VALUERunning or completed

Code mapping: Flight/future owner.

  • One attached owner per key, with detached work still counted against active-owner bound. This is conceptual local metadata, not a durable loader table.

Relationship cardinalities and their reasons

Parent → childMultiplicityFK / why
cache_config → cache_key

scopes keys

1 parent per child; 0..* children per parentcache_key.cache_id → cache_config.id

Namespaces isolate policy and identity.

cache_key → cache_entry

has resident value

1 parent per child; 0..1 children per parentcache_entry.key_id → cache_key.id

FK is also PK, so this relationship is 1 to 0..1, refined below by unique key.

cache_key → load_attempt

owns load attempts

1 parent per child; 0..* children per parentload_attempt.key_id → cache_key.id

Historical/detached attempts may coexist, but only current identity can publish.

Transaction boundaries

  1. Hit: under local owner validate TTL and update LRU observation.
  2. Miss: attach one load identity under lock, execute loader outside lock, then publish only if it still owns the key; invalidation cannot be undone by old completion.

Indexes and uniqueness

  1. Hash key lookup plus linked LRU order; these are local structures, not a recommendation to run every hit through SQL.
  2. If entries are persisted, unique key plus expiry index supports recovery; discard/recreate process-local flight state.

Schema traps and failure cases

  1. A stale loader can overwrite an explicit put unless publication checks current ownership.
  2. Invalidating an in-flight load must not free its active-work permit while it still consumes resources.

Trace the records through one operation

  1. Two misses for K join the same attached future.
  2. Explicit put replaces K and detaches old load ownership.
  3. Old loader finishes; its value is fenced out, and the active owner slot is finally released.

Method contracts

Cache(capacity[,maxLoads])

Nonnegative retained-entry count; positive owner-load bound. Default maxLoads is at least one, including capacity zero.

get(key,now) → Optional<V>

Advance trusted time, remove an expired match, and update recency only for a live hit. No read-based TTL extension.

put(key,value,ttl,now)

Validate nonnull/positive TTL and checked deadline, detach old future, purge expired entries, update recency and evict oldest entries until within capacity.

invalidate(key)

Remove retained entry and detach current future. Does not interrupt a loader or cancel existing waiters.

getOrLoad(key,ttl,clock,loader) → V

Use a live hit or share one attached future. Admit a bounded owner, execute outside lock, recheck future identity before storage, and release capacity on every completion path.

keys(now); inFlight()

keys purges expiry and returns oldest-to-newest immutable key snapshot. inFlight counts active owner loads, including invalidated/detached work.

What, when, why and how

Each concept is attached to the requirement it protects, its implementation, an alternative and a test.

LRU recency versus TTL validity

what
Recency chooses eviction; expiry decides whether a value may be used.
when
A cache needs both bounded entry count and freshness.
why
A recently accessed value can still be expired; refreshing recency should not silently refresh a write TTL.
how
Access-order LinkedHashMap changes order on a hit. Entry stores immutable expiresAt; existing removes deadline<=time before returning. store purges expired records then evicts oldest live entries.

Code: existing, store, Entry · Invariants: I1, I2

Alternatives, cost and failure example

Alternative: Expire-after-access is another valid policy, but it changes freshness and must be a different declared contract.

Cost: get uses expected O(1) lookup; store/purge scans C entries before count eviction, so put is O(C) here.

Counterexample: Read an old value repeatedly and assume its TTL keeps extending even though policy says expire-after-write.

Test: Test A/B hit order, capacity eviction and exact expiry boundary separately.

Single-flight loading

what
Share one in-flight result for concurrent misses on the same attached key.
when
Many callers miss a hot key while external loading is slow.
why
Uncoordinated loads can stampede the backend and waste capacity.
how
Under the monitor create or reuse a CompletableFuture, then run the owning Supplier outside it. Followers join the same future instead of calling the loader.

Code: getOrLoad, flights · Invariants: I4

Alternatives, cost and failure example

Alternative: Do not coalesce if requests with the same key can legitimately require different results; encode context into stable key identity.

Cost: Followers may block, and a loader dependency cycle across threads can deadlock without a stronger scheduling/dependency policy.

Counterexample: Hold the monitor while calling the loader and block unrelated cache operations behind slow I/O.

Test: Block one owner with latches, start a follower, and assert one Supplier invocation and equal results.

Identity fencing on completion

what
An old worker may return, but it must prove it still owns insertion authority.
when
invalidate or put occurs while a load is running.
why
A late load can resurrect invalidated data or overwrite a fresh manually stored value.
how
put/invalidate remove the attached flight. Completion checks flights.get(key)==flight before storing; conditional removal does not detach a newer flight.

Code: getOrLoad, put, invalidate · Invariants: I3

Alternatives, cost and failure example

Alternative: Per-key generation counters can provide the same fence, but retention/cleanup of generations must not reset old authority accidentally.

Cost: Old waiters still get the result they joined. Stronger read consistency or cancellation requires a distinct API and version-aware caller behavior.

Counterexample: Invalidate key and store new value; old slow load returns and overwrites it.

Test: Release an old loader after invalidate+put; old waiter sees old, but get returns new.

Resource admission includes detached work

what
Bound actual active owner loads, not just the visible flight-map size.
when
Callers invalidate slow keys and start replacements repeatedly.
why
Removing a future from the map does not stop the underlying work; counting only attached entries permits unbounded detached loads.
how
activeLoads increments for admitted owners and decrements in finally. A detached owner holds its slot until return; new distinct owners reject at maxLoads.

Code: activeLoads, getOrLoad, inFlight · Invariants: I4

Alternatives, cost and failure example

Alternative: A cancellation-aware executor can reclaim resources only after work actually stops; a requested cancellation is not confirmation.

Cost: Capacity is count-based. Loaded values and waiting callers can still consume large memory; byte quotas and queueing are separate concerns.

Counterexample: Invalidate a slow key in a loop and claim load capacity is free because flights.size is zero.

Test: While an invalidated owner remains blocked, a new owner at maxLoads=1 must reject; cleanup after failure restores capacity.

Read and run the Java reference

Reveal complete Solution.java

Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.

Solution.java Java 21
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
/** Local count-bounded LRU + expire-after-write cache with externally supplied trusted elapsed time. */
public final class Solution {
    private Solution() {}
    private record Entry<V>(V value,long expiresAt){}
    public static final class Cache<K,V> {
        private final int capacity,maxLoads;
        private int activeLoads;
        private final ThreadLocal<Set<K>> loading=ThreadLocal.withInitial(HashSet::new);
        private final LinkedHashMap<K,Entry<V>> entries=new LinkedHashMap<>(16,0.75f,true);
        private final Map<K,CompletableFuture<V>> flights=new HashMap<>();
        private long time;
        public Cache(int capacity){this(capacity,Math.max(1,capacity));}
        public Cache(int capacity,int maxLoads){if(capacity<0||maxLoads<1)throw new IllegalArgumentException("capacity");this.capacity=capacity;this.maxLoads=maxLoads;}
        private void at(long now){if(now<time)throw new IllegalArgumentException("time");time=now;}
        private void purge(){entries.entrySet().removeIf(e->e.getValue().expiresAt()<=time);}
        private Optional<V> existing(K key){var e=entries.get(key);if(e==null)return Optional.empty();if(e.expiresAt()<=time){entries.remove(key);return Optional.empty();}return Optional.of(e.value());}
        public synchronized Optional<V> get(K key,long now){Objects.requireNonNull(key);at(now);return existing(key);}
        private void store(K key,V value,long expiry){if(capacity==0)return;purge();entries.put(key,new Entry<>(value,expiry));while(entries.size()>capacity)entries.remove(entries.keySet().iterator().next());}
        public synchronized void put(K key,V value,long ttl,long now){Objects.requireNonNull(key);Objects.requireNonNull(value);if(ttl<1)throw new IllegalArgumentException("ttl");long expiry=Math.addExact(now,ttl);at(now);flights.remove(key);store(key,value,expiry);}
        public synchronized void invalidate(K key){Objects.requireNonNull(key);entries.remove(key);flights.remove(key);}
        public V getOrLoad(K key,long ttl,LongSupplier clock,Supplier<V> loader){
            Objects.requireNonNull(key);Objects.requireNonNull(clock);Objects.requireNonNull(loader);if(ttl<1)throw new IllegalArgumentException("ttl");
            final CompletableFuture<V> flight;final boolean owner;
            synchronized(this){at(clock.getAsLong());var hit=existing(key);if(hit.isPresent())return hit.get();var current=flights.get(key);owner=current==null;if(owner){if(activeLoads>=maxLoads)throw new IllegalStateException("load capacity");flight=new CompletableFuture<>();flights.put(key,flight);activeLoads++;}else flight=current;}
            if(!owner){if(loading.get().contains(key))throw new IllegalStateException("recursive same-key load");return flight.join();}
            loading.get().add(key);
            try{
                V value=Objects.requireNonNull(loader.get());
                synchronized(this){
                    at(clock.getAsLong());long expiry=Math.addExact(time,ttl);
                    // Identity fencing: invalidation/newer put or load detaches this old future.
                    if(flights.get(key)==flight){flights.remove(key);store(key,value,expiry);}
                }
                flight.complete(value);return value;
            }catch(Throwable error){
                synchronized(this){flights.remove(key,flight);}flight.completeExceptionally(error);
                if(error instanceof Error fatal)throw fatal;if(error instanceof RuntimeException runtime)throw runtime;throw new CompletionException(error);
            }finally{synchronized(this){activeLoads--;}var owned=loading.get();owned.remove(key);if(owned.isEmpty())loading.remove();}
        }
        public synchronized List<K> keys(long now){at(now);purge();return List.copyOf(entries.keySet());}
        public synchronized int inFlight(){return activeLoads;}
    }
}

existing mutates recency under the monitor

LinkedHashMap.get changes access order; synchronized callers also check deadline and remove expired matches before exposing values.

getOrLoad publishes a future before external work

The first miss owns loading and increments admission. Later same-key misses join. Recursive same-thread join detects its own unfinished work and rejects instead of hanging.

identity check precedes storage

Only the still-attached future may publish. Invalidated owners complete their original future but cannot reinsert data.

finally releases actual owner capacity

Success, null result, arithmetic error, RuntimeException and Error all release activeLoads and recursion ownership. Futures propagate failures to followers.

Complexity and limits

  • Expected O(1) get/invalidate over hash lookup and access ordering, with fixed-entry checks. Hash quality and key behavior remain assumptions.
  • put and successful stored load perform an O(C) expiry purge for retained capacity C, plus O(1) expected recency/eviction operations. keys also scans/copies O(C). This is deliberately not advertised as universally O(1).
  • Retained space is O(C×value size); active owner loads are bounded by maxLoads, with attached futures and thread-local recursion guards. Followers and payload bytes are not globally bounded by entry count.
  • Loader runtime and blocking are external. Joined callers may wait indefinitely for a nonreturning loader; timeout/cancellation/backpressure policy is a separate extension.

Walk through the actual reference

These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.

LRU and expiry are different policies

Step 1 of 3: Put A,B then read A

Recency oldest B, newest A

Invariant: Hit updates recency atomically

Read the complete trace table
ActionState after actionInvariant
Put A,B then read ARecency oldest B, newest AHit updates recency atomically
Put C at capacity twoB evicted; A,C retainedEntry count bounded
A reaches its write TTLA is a miss even if recently readRead recency does not extend expiry

Invalidation fences stale load completion

Step 1 of 3: Load k starts and blocks

One active future owns prospective insertion

Invariant: Load runs outside cache monitor

Read the complete trace table
ActionState after actionInvariant
Load k starts and blocksOne active future owns prospective insertionLoad runs outside cache monitor
Invalidate k and put new valueOld future detached; cache holds newNew ownership supersedes old future identity
Old loader returnsOriginal waiter sees old result; cache remains newNo stale resurrection; invalidation is not waiter cancellation

Mistakes and counterexamples

Call every cache operation O(1)

Failing case: This implementation scans all entries for expiry on put.

Repair: Account for the actual purge and snapshot work.

Let old loads write after invalidation

Failing case: An old result overwrites a new put.

Repair: Fence completion by generation or exact future identity.

Assume invalidate cancels waiters

Failing case: A caller already joined the old future.

Repair: Document publication fencing separately from cancellation/reader consistency.

Count only attached flights for resource bounds

Failing case: Invalidated workers still run.

Repair: Retain active-work admission until actual completion.

Recursively join the same load future

Failing case: Loader for k calls getOrLoad(k).

Repair: Reject same-thread recursion and prohibit cross-thread dependency cycles or introduce a fuller dependency policy.

Engineer → Senior → Staff

Choose a deep dive

Implement correct recency and expiry.

Executed: LRU, exact TTL, update, invalidation, null rejection and capacity zero.

Change exercise

Compare a slow explicit-order-list LRU oracle against random cache operations.

Worked answer guidance

Keep the oracle structurally different from access-order LinkedHashMap and compare both returned values and oldest-to-newest order.

Interview follow-ups

Can a recently used value be expired?

Yes. Recency and write TTL are independent in this contract.

Does V imply thread-safe values?

No. The cache coordinates references, not arbitrary object mutation. Use immutable values or explicit copy ownership.

Your design attempt

Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.

Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.

Solution.java
Run program · Java 21

No unsaved changes · 208/20000

Self-review

Checks apply to this visit. Self-review is not a passed submission or verified mastery.

Open focused practice →

Provenance and scope of evidence

Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.

Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.