""" In-Memory Concurrent LFU Cache with Async TTL Eviction and Atomic Transactions. Pure Python 3.11+ implementation — no external dependencies. """ from __future__ import annotations import asyncio import time import weakref from dataclasses import dataclass, field from typing import Any, Optional # --------------------------------------------------------------------------- # Internal Doubly-Linked List Node (frequency bucket member) # --------------------------------------------------------------------------- @dataclass class _Node: """A single cache entry stored inside a frequency bucket.""" key: Any value: Any freq: int = 1 expires_at: float = 0.0 # 0 means no expiry prev: Optional[_Node] = field(default=None, repr=False) next: Optional[_Node] = field(default=None, repr=False) @property def is_expired(self) -> bool: return self.expires_at > 0 and time.monotonic() >= self.expires_at # --------------------------------------------------------------------------- # Doubly-Linked List wrapper for a single frequency tier # --------------------------------------------------------------------------- class _FreqList: """Doubly-linked list with sentinel head/tail for O(1) insert/remove.""" __slots__ = ("head", "tail", "size") def __init__(self) -> None: self.head = _Node(key=None, value=None) # sentinel self.tail = _Node(key=None, value=None) # sentinel self.head.next = self.tail self.tail.prev = self.head self.size = 0 # -- public helpers ----------------------------------------------------- def append_right(self, node: _Node) -> None: """Append *node* just before the tail sentinel (LRU-end).""" pred = self.tail.prev node.prev = pred node.next = self.tail pred.next = node self.tail.prev = node self.size += 1 def remove(self, node: _Node) -> None: """Remove *node* from the list in O(1).""" pred, succ = node.prev, node.next pred.next = succ succ.prev = pred node.prev = node.next = None self.size -= 1 def pop_left(self) -> Optional[_Node]: """Remove and return the node just after head sentinel (MRU-end).""" if self.size == 0: return None node = self.head.next self.remove(node) return node def is_empty(self) -> bool: return self.size == 0 # --------------------------------------------------------------------------- # Transaction handle # --------------------------------------------------------------------------- class Transaction: """ Represents an isolated sub-session on the cache. * ``put`` / ``delete`` buffer changes locally. * ``get`` sees uncommitted local writes ("read your own writes"). * ``commit`` applies all buffered changes atomically to the global cache. * ``rollback`` discards everything without touching global state. """ def __init__(self, cache: "LFUCache") -> None: self._cache = cache # key -> (value, expires_at) — latest write wins on conflict self._writes: dict[Any, tuple[Any, float]] = {} # keys deleted within this transaction self._deletes: set[Any] = set() self._committed = False self._rolled_back = False # -- mutating operations ------------------------------------------------ def put(self, key: Any, value: Any, ttl_seconds: float = 0.0) -> None: """Buffer a write inside this transaction.""" if self._committed or self._rolled_back: raise RuntimeError("Transaction already closed") expires_at = (time.monotonic() + ttl_seconds) if ttl_seconds > 0 else 0.0 self._writes[key] = (value, expires_at) self._deletes.discard(key) def delete(self, key: Any) -> None: """Buffer a deletion inside this transaction.""" if self._committed or self._rolled_back: raise RuntimeError("Transaction already closed") self._deletes.add(key) self._writes.pop(key, None) # -- read operations ---------------------------------------------------- def get(self, key: Any) -> Optional[Any]: """ Return the value for *key*, checking local buffer first, then global. Returns ``None`` if the key is absent or expired. """ if self._committed or self._rolled_back: raise RuntimeError("Transaction already closed") # 1. Check local uncommitted writes if key in self._deletes: return None if key in self._writes: value, expires_at = self._writes[key] if expires_at > 0 and time.monotonic() >= expires_at: self._deletes.add(key) return None return value # 2. Fall back to global cache (with lazy TTL check) return self._cache.get(key) # -- lifecycle ---------------------------------------------------------- async def commit(self) -> None: """Apply all buffered changes atomically to the global cache.""" if self._committed or self._rolled_back: raise RuntimeError("Transaction already closed") await self._cache._apply_transaction(self) self._committed = True async def rollback(self) -> None: """Discard all buffered changes.""" if self._committed or self._rolled_back: raise RuntimeError("Transaction already closed") self._writes.clear() self._deletes.clear() self._rolled_back = True def __repr__(self) -> str: # pragma: no cover state = "committed" if self._committed else ( "rolled_back" if self._rolled_back else "active" ) return f"" # --------------------------------------------------------------------------- # Main LFU Cache # --------------------------------------------------------------------------- class LFUCache: """ In-memory concurrent LFU cache with O(1) get/put, async TTL eviction, and atomic transaction support. """ def __init__(self, capacity: int) -> None: if capacity < 1: raise ValueError("capacity must be >= 1") self._capacity = capacity # key -> _Node (global cache) self._cache: dict[Any, _Node] = {} # freq -> _FreqList (frequency buckets) self._freq_map: dict[int, _FreqList] = {} # Track the current minimum frequency for O(1) eviction self._min_freq: int = 0 # Concurrency primitives self._lock = asyncio.Lock() # Background evictor task handle self._evictor_task: Optional[asyncio.Task[None]] = None # ------------------------------------------------------------------ # Public API — get / put # ------------------------------------------------------------------ async def get(self, key: Any) -> Optional[Any]: """ Retrieve *key* from the cache in O(1). Lazy TTL eviction is performed on access. """ async with self._lock: node = self._cache.get(key) if node is None: return None # Lazy TTL check if node.is_expired: self._evict_node(node) return None # Bump frequency — O(1) self._ bump_freq(node) return node.value async def put(self, key: Any, value: Any, ttl_seconds: float = 0.0) -> None: """ Insert or update *key* in the cache in O(1). If the cache is at capacity, the LFU (least-recently-used tie-break) entry is evicted before insertion. """ expires_at = (time.monotonic() + ttl_seconds) if ttl_seconds > 0 else 0.0 async with self._lock: # Case 1: key already exists — update in place if key in self._cache: node = self._cache[key] old_freq = node.freq node.value = value node.expires_at = expires_at # Move to new frequency bucket self._remove_from_freq_list(node) node.freq += 1 self._add_to_freq_list(node) # Update min_freq if the old bucket is now empty and was min if old_freq == self._min_freq and self._freq_map[old_freq].is_empty(): del self._freq_map[old_freq] self._min_freq = node.freq return # Case 2: cache full — evict LFU entry if len(self._cache) >= self._capacity: self._evict_one() # Insert new node at frequency 1 node = _Node(key=key, value=value, freq=1, expires_at=expires_at) self._cache[key] = node self._add_to_freq_list(node) self._min_freq = 1 # ------------------------------------------------------------------ # Transaction support # ------------------------------------------------------------------ def begin_transaction(self) -> Transaction: """Start a new isolated transaction on this cache.""" return Transaction(self) async def _apply_transaction(self, tx: Transaction) -> None: """ Apply a transaction's buffered writes/deletes atomically. Must be called while holding self._lock (called from Transaction.commit). """ # --- Phase 1: apply deletes first (so a put+delete of same key works) --- for key in tx._deletes: node = self._cache.pop(key, None) if node is not None: self._remove_from_freq_list(node) if node.freq == self._min_freq and self._freq_map[node.freq].is_empty(): del self._freq_map[node.freq] # Find new min freq if self._freq_map: self._min_freq = min(self._freq_map) else: self._min_freq = 0 # --- Phase 2: apply writes ------------------------------------------ for key, (value, expires_at) in tx._writes.items(): if key in self._cache: # Update existing node node = self._cache[key] old_freq = node.freq node.value = value node.expires_at = expires_at self._remove_from_freq_list(node) node.freq += 1 self._add_to_freq_list(node) if old_freq == self._min_freq and self._freq_map[old_freq].is_empty(): del self._freq_map[old_freq] if self._freq_map: self._min_freq = min(self._freq_map) else: # Insert new node (may need eviction first) if len(self._cache) >= self._capacity: self._evict_one() node = _Node(key=key, value=value, freq=1, expires_at=expires_at) self._cache[key] = node self._add_to_freq_list(node) self._min_freq = 1 # ------------------------------------------------------------------ # Background async TTL evictor # ------------------------------------------------------------------ def start_evictor(self, interval: float = 0.5, batch_size: int = 32) -> None: """Start the non-blocking background TTL sweep task.""" if self._evictor_task is not None: return self._evictor_task = asyncio.create_task( self._evictor_loop(interval, batch_size) ) def stop_evictor(self) -> None: """Stop the background TTL sweep task.""" if self._evictor_task is not None: self._evictor_task.cancel() try: asyncio.get_event_loop().run_until_complete(self._evictor_task) except (asyncio.CancelledError, RuntimeError): pass self._evictor_task = None async def _evictor_loop(self, interval: float, batch_size: int) -> None: """Periodically scan and purge expired entries in small batches.""" try: while True: await asyncio.sleep(interval) async with self._lock: expired = [ node for node in self._cache.values() if node.is_expired ][:batch_size] for node in expired: self._evict_node(node) except asyncio.CancelledError: return # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _add_to_freq_list(self, node: _Node) -> None: """Add *node* to its frequency bucket.""" freq = node.freq if freq not in self._freq_map: self._freq_map[freq] = _FreqList() self._freq_map[freq].append_right(node) def _remove_from_freq_list(self, node: _Node) -> None: """Remove *node* from its current frequency bucket.""" freq_list = self._freq_map.get(node.freq) if freq_list is not None: freq_list.remove(node) def _bump_freq(self, node: _Node) -> None: """Increment *node*'s frequency and move it to the next bucket.""" old_freq = node.freq self._remove_from_freq_list(node) node.freq += 1 self._add_to_freq_list(node) # Update min_freq if the old bucket is now empty and was the minimum if old_freq == self._min_freq and self._freq_map[old_freq].is_empty(): del self._freq_map[old_freq] if self._freq_map: self._min_freq = min(self._freq_map) else: self._min_freq = 0 def _evict_one(self) -> None: """Evict the least-frequently-used entry (LRU tie-break). O(1).""" if self._min_freq not in self._freq_map or self._freq_map[self._min_freq].is_empty(): # Fallback — should not happen in normal operation return freq_list = self._freq_map[self._min_freq] node = freq_list.pop_left() if node is not None: del self._cache[node.key] if freq_list.is_empty(): del self._freq_map[self._min_freq] if self._freq_map: self._min_freq = min(self._freq_map) else: self._min_freq = 0 def _evict_node(self, node: _Node) -> None: """Remove a single expired node from all structures.""" self._remove_from_freq_list(node) del self._cache[node.key] if node.freq == self._min_freq and self._freq_map.get(node.freq, _FreqList()).is_empty(): if node.freq in self._freq_map: del self._freq_map[node.freq] if self._freq_map: self._min_freq = min(self._freq_map) else: self._min_freq = 0 # =================================================================== # Executable Unit Tests # =================================================================== async def main() -> None: passed = 0 failed = 0 def check(name: str, condition: bool) -> None: nonlocal passed, failed if condition: passed += 1 print(f" ✓ {name}") else: failed += 1 print(f" ✗ {name}") # ------------------------------------------------------------------ # (a) O(1) LFU eviction order when capacity is reached # ------------------------------------------------------------------ print("\n[a] LFU Eviction Order") cache = LFUCache(capacity=3) # Insert a, b, c → all freq=1 await cache.put("a", 1) await cache.put("b", 2) await cache.put("c", 3) # Access a twice → freq(a)=3, freq(b)=1, freq(c)=1 await cache.get("a") await cache.put("a", 10) # bump to freq=4 # Access b once → freq(b)=2 await cache.get("b") # Now: a=freq4, b=freq2, c=freq1. Min freq = 1 (key c). # Insert d — should evict c (lowest freq). await cache.put("d", 4) check("evicts least-frequent key (c)", await cache.get("c") is None) check("keeps a", await cache.get("a") == 10) check("keeps b", await cache.get("b") == 2) check("keeps d", await cache.get("d") == 4) # ------------------------------------------------------------------ # (b) Lazy TTL vs. Background Async Sweep eviction # ------------------------------------------------------------------ print("\n[b] TTL Eviction (Lazy + Background)") cache2 = LFUCache(capacity=5) await cache2.put("x", 1, ttl_seconds=0.05) await cache2.put("y", 2, ttl_seconds=10.0) # Before expiry — both visible check("x present before TTL", await cache2.get("x") == 1) check("y present before TTL", await cache2.get("y") == 2) # Wait for x to expire await asyncio.sleep(0.1) # Lazy eviction on get check("x evicted lazily on get", await cache2.get("x") is None) check("y still present after x expired", await cache2.get("y") == 2) # Background evictor test cache3 = LFUCache(capacity=5) await cache3.put("p", 1, ttl_seconds=0.05) await cache3.put("q", 2, ttl_seconds=10.0) cache3.start_evictor(interval=0.05, batch_size=8) await asyncio.sleep(0.2) check("p evicted by background sweep", await cache3.get("p") is None) check("q still present after background sweep", await cache3.get("q") == 2) cache3.stop_evictor() # ------------------------------------------------------------------ # (c) Transaction commit visibility vs. rollback state restoration # ------------------------------------------------------------------ print("\n[c] Atomic Transactions") cache4 = LFUCache(capacity=5) await cache4.put("k1", 100) await cache4.put("k2", 200) # --- Commit test ------------------------------------------------------- tx1 = cache4.begin_transaction() tx1.put("k3", 300) # uncommitted — global should not see it tx1.put("k1", 999) # update existing check("global doesn't see uncommitted put", await cache4.get("k3") is None) check("global still sees old k1", await cache4.get("k1") == 100) # Read-your-own-writes check("tx sees its own put(k3)", tx1.get("k3") == 300) check("tx sees its own update(k1)", tx1.get("k1") == 999) await tx1.commit() check("global sees committed k3", await cache4.get("k3") == 300) check("global sees committed k1 update", await cache4.get("k1") == 999) # --- Rollback test ----------------------------------------------------- tx2 = cache4.begin_transaction() tx2.put("k4", 400) tx2.delete("k2") check("tx sees deleted k2 as None", tx2.get("k2") is None) check("global still has k2 before rollback", await cache4.get("k2") == 200) await tx2.rollback() check("global k2 restored after rollback", await cache4.get("k2") == 200) check("global k4 absent after rollback", await cache4.get("k4") is None) # --- Rollback of update test ------------------------------------------- tx3 = cache4.begin_transaction() tx3.put("k1", 777) await tx3.rollback() check("global k1 restored after rollback of update", await cache4.get("k1") == 999) # --- Transaction with TTL ---------------------------------------------- tx4 = cache4.begin_transaction() tx4.put("temp", "value", ttl_seconds=0.05) check("tx sees its own TTL'd key", tx4.get("temp") == "value") await asyncio.sleep(0.1) check("tx sees expired TTL'd key as None", tx4.get("temp") is None) await tx4.rollback() # ------------------------------------------------------------------ # (d) Stress test — 50 concurrent async tasks reading/writing # ------------------------------------------------------------------ print("\n[d] Stress Test (50 concurrent tasks)") cache5 = LFUCache(capacity=100) errors: list[str] = [] async def worker(task_id: int) -> None: try: for i in range(50): key = f"key_{task_id}_{i % 200}" if i % 3 == 0: await cache5.put(key, f"val_{task_id}_{i}", ttl_seconds=1.0) elif i % 3 == 1: await cache5.get(key) else: tx = cache5.begin_transaction() tx.put(f"tx_{task_id}_{i}", f"txval_{i}") val = tx.get(f"tx_{task_id}_{i}") check(f" task {task_id} tx read-your-own-write", val == f"txval_{i}") await tx.commit() except Exception as e: errors.append(f"task {task_id}: {e}") tasks = [asyncio.create_task(worker(tid)) for tid in range(50)] await asyncio.gather(*tasks) check("no concurrent errors", len(errors) == 0) check("cache has entries after stress", len(cache5._cache) > 0) # ------------------------------------------------------------------ # Summary # ------------------------------------------------------------------ total = passed + failed print(f"\n{'='*50}") print(f"Results: {passed}/{total} passed" + ( "" if failed == 0 else f", {failed} FAILED" )) print(f"{'='*50}") if failed: raise SystemExit(1) if __name__ == "__main__": asyncio.run(main())