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.
Keys and values have explicit caller ownership requirements; the cache is local and non-durable.
- Lookup under cache monitor
↓ Validate TTL and recency
Hit or attach one future↓ Followers join stable result
Owner loads outside monitor↓ Release active slot in finally
Identity-checked publish
Fencing stale publication does not cancel the worker or retroactively change results already awaited.
- Old load future attached
↓ Revoke publication authority
invalidate / put detaches it↓ Worker still counts as active
Old worker finishes↓ 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.
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.
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.
Entity definitions and field contracts
cache_config
Local cache namespace and bounds.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Cache identity |
capacity | integer · VALUE | Maximum entry count |
ttl | bigint · VALUE | Write TTL duration |
Code mapping: Cache constructor settings.
- Count limit is not a byte quota.
cache_key
Namespace-scoped key identity.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Opaque key identity |
cache_id | text · FK | Cache namespace · cache_config.id |
generation | bigint · VALUE | Invalidation/publication epoch |
logical_key | text · VALUE | Serialized 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.
| Field | Type / key | Meaning and reference |
|---|---|---|
key_id | text · PK,FK | Owned key · cache_key.id |
value | bytes · VALUE | Caller-owned immutable value |
expires_at | bigint · VALUE | Trusted TTL deadline |
access_rank | bigint · VALUE | LRU 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.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Load identity |
key_id | text · FK | Requested key · cache_key.id |
generation | bigint · VALUE | Captured publication epoch |
state | enum · VALUE | Running 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 → child | Multiplicity | FK / why |
|---|---|---|
| cache_config → cache_key scopes keys | 1 parent per child; 0..* children per parent | cache_key.cache_id → cache_config.idNamespaces isolate policy and identity. |
| cache_key → cache_entry has resident value | 1 parent per child; 0..1 children per parent | cache_entry.key_id → cache_key.idFK 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 parent | load_attempt.key_id → cache_key.idHistorical/detached attempts may coexist, but only current identity can publish. |
Transaction boundaries
- Hit: under local owner validate TTL and update LRU observation.
- 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
- Hash key lookup plus linked LRU order; these are local structures, not a recommendation to run every hit through SQL.
- If entries are persisted, unique key plus expiry index supports recovery; discard/recreate process-local flight state.
Schema traps and failure cases
- A stale loader can overwrite an explicit put unless publication checks current ownership.
- Invalidating an in-flight load must not free its active-work permit while it still consumes resources.
Trace the records through one operation
- Two misses for K join the same attached future.
- Explicit put replaces K and detaches old load ownership.
- 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.
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
| Action | State after action | Invariant |
|---|---|---|
| Put A,B then read A | Recency oldest B, newest A | Hit updates recency atomically |
| Put C at capacity two | B evicted; A,C retained | Entry count bounded |
| A reaches its write TTL | A is a miss even if recently read | Read 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
| Action | State after action | Invariant |
|---|---|---|
| Load k starts and blocks | One active future owns prospective insertion | Load runs outside cache monitor |
| Invalidate k and put new value | Old future detached; cache holds new | New ownership supersedes old future identity |
| Old loader returns | Original waiter sees old result; cache remains new | No 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
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.
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.
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.