|
| 1 | +# How atomic-lru works internally |
| 2 | + |
| 3 | +This document explains the internal architecture and design decisions of the `atomic-lru` library. It covers how thread-safety is achieved, how LRU eviction works, how TTL expiration is implemented, and the relationship between the high-level `Cache` and low-level `Storage` APIs. |
| 4 | + |
| 5 | +## Architecture overview |
| 6 | + |
| 7 | +The library is organized into two main layers: |
| 8 | + |
| 9 | +1. **Storage layer** (`atomic_lru._storage`): The low-level, thread-safe storage mechanism that handles LRU eviction and TTL expiration. It operates on raw values (typically `bytes` or object pointers) without serialization. |
| 10 | + |
| 11 | +2. **Cache layer** (`atomic_lru._cache`): The high-level API that extends `Storage` to provide automatic serialization and deserialization, allowing you to cache arbitrary Python objects. |
| 12 | + |
| 13 | +This separation allows users to choose the appropriate level of abstraction for their needs: use `Cache` for convenience when serialization is acceptable, or use `Storage` directly when you need to store object references without serialization overhead. |
| 14 | + |
| 15 | +## Thread-safety |
| 16 | + |
| 17 | +All operations in `atomic-lru` are thread-safe through the use of a single `threading.Lock` per storage instance. Every method that accesses or modifies the internal data structures acquires this lock before performing operations. |
| 18 | + |
| 19 | +### Lock acquisition pattern |
| 20 | + |
| 21 | +The lock is acquired using Python's `with` statement, ensuring proper release even if an exception occurs: |
| 22 | + |
| 23 | +```python |
| 24 | +with self.__lock: |
| 25 | + # Critical section - thread-safe operations |
| 26 | + value_obj = self._data.get(key) |
| 27 | + # ... |
| 28 | +``` |
| 29 | + |
| 30 | +This ensures that: |
| 31 | +- Only one thread can modify the storage at a time |
| 32 | +- Reads and writes are atomic |
| 33 | +- The internal state remains consistent across concurrent operations |
| 34 | + |
| 35 | +### Why a single lock? |
| 36 | + |
| 37 | +A single lock simplifies the implementation and ensures complete consistency. While more sophisticated locking strategies (like read-write locks) could potentially improve concurrent read performance, they add complexity and the risk of subtle race conditions. For an in-memory cache where operations are typically fast, a single lock provides excellent safety with minimal overhead. |
| 38 | + |
| 39 | +## LRU eviction mechanism |
| 40 | + |
| 41 | +The Least Recently Used (LRU) eviction policy ensures that when storage limits are reached, the items that haven't been accessed recently are removed first. |
| 42 | + |
| 43 | +### Data structure: OrderedDict |
| 44 | + |
| 45 | +The library uses Python's `collections.OrderedDict` as the primary data structure. `OrderedDict` maintains insertion order, which naturally represents the access order for LRU: |
| 46 | + |
| 47 | +- **Most recently used**: Items at the end of the dictionary |
| 48 | +- **Least recently used**: Items at the beginning of the dictionary |
| 49 | + |
| 50 | +### Maintaining LRU order |
| 51 | + |
| 52 | +When an item is accessed via `get()`, it's moved to the end of the `OrderedDict` using `move_to_end()`: |
| 53 | + |
| 54 | +```python |
| 55 | +self._data.move_to_end(key) |
| 56 | +``` |
| 57 | + |
| 58 | +When an item is stored via `set()`, it's added to the end (or moved to the end if it already exists): |
| 59 | + |
| 60 | +```python |
| 61 | +self._data[key] = value_obj # Adds to end if new, or updates existing |
| 62 | +``` |
| 63 | + |
| 64 | +This ensures that frequently accessed items naturally migrate toward the end, while rarely accessed items accumulate at the beginning. |
| 65 | + |
| 66 | +### Eviction process |
| 67 | + |
| 68 | +When limits are reached (either `max_items` or `size_limit_in_bytes`), the eviction process removes items from the beginning of the `OrderedDict` using `popitem(last=False)`: |
| 69 | + |
| 70 | +```python |
| 71 | +_, value_obj = self._data.popitem(last=False) # Remove from beginning |
| 72 | +``` |
| 73 | + |
| 74 | +The eviction continues until the storage is within limits. This happens atomically within the lock, ensuring no race conditions during eviction. |
| 75 | + |
| 76 | +### Size tracking |
| 77 | + |
| 78 | +For `size_limit_in_bytes`, the library maintains an approximate size counter (`_size_in_bytes`) that tracks: |
| 79 | + |
| 80 | +- The size of stored values (for `bytes` values) |
| 81 | +- Overhead for the `Value` wrapper object |
| 82 | +- Overhead for the `OrderedDict` entry (`PER_ITEM_APPROXIMATE_SIZE`) |
| 83 | + |
| 84 | +When items are added, removed, or updated, this counter is adjusted accordingly. The size calculation is approximate because: |
| 85 | + |
| 86 | +- Python object sizes can vary based on implementation details |
| 87 | +- The overhead estimates are conservative approximations |
| 88 | +- For non-`bytes` values, size tracking returns 0 (size limits only work correctly with `bytes`) |
| 89 | + |
| 90 | +## TTL expiration |
| 91 | + |
| 92 | +Time-to-live (TTL) expiration allows cached items to automatically expire after a specified duration. |
| 93 | + |
| 94 | +### Expiration tracking |
| 95 | + |
| 96 | +Each stored value is wrapped in a `Value` object that includes: |
| 97 | + |
| 98 | +- The actual value |
| 99 | +- An optional TTL (time-to-live in seconds) |
| 100 | +- An expiration timestamp (`_expire_at`) calculated when the value is created |
| 101 | + |
| 102 | +The expiration timestamp is calculated using `time.perf_counter()`, which provides high-resolution, monotonic timing suitable for measuring durations: |
| 103 | + |
| 104 | +```python |
| 105 | +object.__setattr__(self, "_expire_at", time.perf_counter() + self.ttl) |
| 106 | +``` |
| 107 | + |
| 108 | +### Checking expiration |
| 109 | + |
| 110 | +The `Value.is_expired` property checks if the current time exceeds the expiration timestamp: |
| 111 | + |
| 112 | +```python |
| 113 | +@property |
| 114 | +def is_expired(self) -> bool: |
| 115 | + return self._expire_at is not None and self._expire_at < time.perf_counter() |
| 116 | +``` |
| 117 | + |
| 118 | +### Lazy expiration |
| 119 | + |
| 120 | +Expired items are removed lazily in two scenarios: |
| 121 | + |
| 122 | +1. **On access**: When `get()` is called on an expired item, it's immediately deleted and `CACHE_MISS` is returned. |
| 123 | + |
| 124 | +2. **Background cleanup**: A background thread periodically checks for expired items and removes them proactively. |
| 125 | + |
| 126 | +### Background expiration thread |
| 127 | + |
| 128 | +The `ExpirationThread` runs as a daemon thread that periodically checks for expired items. It uses a round-robin approach: |
| 129 | + |
| 130 | +1. Checks a batch of items (up to `max_checks_per_iteration`) starting from a tracked index |
| 131 | +2. Calls the `_clean_expired()` method to test and delete expired items |
| 132 | +3. Updates the start index for the next iteration |
| 133 | +4. Waits for `expiration_thread_delay` seconds before the next iteration |
| 134 | + |
| 135 | +This approach ensures: |
| 136 | +- The cleanup work is distributed over time (doesn't block for long periods) |
| 137 | +- All items are eventually checked (round-robin through the entire storage) |
| 138 | +- The thread can be gracefully stopped when the storage is closed |
| 139 | + |
| 140 | +The thread is created automatically when a `Storage` or `Cache` instance is created (unless `expiration_disabled=True`), and is stopped when `close()` is called. |
| 141 | + |
| 142 | +## Serialization layer |
| 143 | + |
| 144 | +The `Cache` class extends `Storage[bytes]` to provide automatic serialization and deserialization of arbitrary Python objects. |
| 145 | + |
| 146 | +### Serialization protocol |
| 147 | + |
| 148 | +The library uses a protocol-based design (`Serializer` and `Deserializer`) that allows users to provide custom serialization logic. By default, Python's `pickle` module is used, which can serialize most Python objects. |
| 149 | + |
| 150 | +### Serialization flow |
| 151 | + |
| 152 | +When storing a value with `Cache.set()`: |
| 153 | + |
| 154 | +1. The value is serialized to `bytes` using the configured serializer |
| 155 | +2. The serialized bytes are stored in the underlying `Storage[bytes]` |
| 156 | +3. Size limits are enforced on the serialized bytes |
| 157 | + |
| 158 | +When retrieving a value with `Cache.get()`: |
| 159 | + |
| 160 | +1. The serialized bytes are retrieved from the underlying `Storage[bytes]` |
| 161 | +2. The bytes are deserialized back to the original Python object |
| 162 | +3. The deserialized object is returned |
| 163 | + |
| 164 | +### Why separate Storage and Cache? |
| 165 | + |
| 166 | +This design provides flexibility: |
| 167 | + |
| 168 | +- **Storage**: Use when you want to store object references directly (no serialization overhead, but no size tracking for non-bytes) |
| 169 | +- **Cache**: Use when you need to serialize objects (enables size limits, but adds serialization overhead) |
| 170 | + |
| 171 | +The separation also allows the low-level `Storage` to be used independently when serialization isn't needed, avoiding unnecessary overhead. |
| 172 | + |
| 173 | +## Design decisions and trade-offs |
| 174 | + |
| 175 | +### Why OrderedDict instead of a custom LRU implementation? |
| 176 | + |
| 177 | +Python's `OrderedDict` provides an efficient, well-tested implementation that's optimized for the access patterns needed for LRU. While a custom doubly-linked list implementation could potentially be slightly more memory-efficient, `OrderedDict` offers: |
| 178 | + |
| 179 | +- Proven reliability and performance |
| 180 | +- Simple, readable code |
| 181 | +- Built-in Python support |
| 182 | + |
| 183 | +### Why approximate size tracking? |
| 184 | + |
| 185 | +Accurately tracking the memory size of arbitrary Python objects is complex and would require: |
| 186 | + |
| 187 | +- Deep introspection of object structures |
| 188 | +- Handling of circular references |
| 189 | +- Accounting for Python's memory management overhead |
| 190 | +- Significant performance overhead |
| 191 | + |
| 192 | +Instead, the library uses approximate tracking that works well for `bytes` values (the primary use case for size limits) while keeping the implementation simple and performant. |
| 193 | + |
| 194 | +### Why a background thread for expiration? |
| 195 | + |
| 196 | +While lazy expiration on access is sufficient for correctness, a background thread provides: |
| 197 | + |
| 198 | +- Proactive cleanup of expired items |
| 199 | +- Prevention of memory accumulation from expired but unaccessed items |
| 200 | +- Better resource management |
| 201 | + |
| 202 | +The thread is designed to be lightweight and non-blocking, checking items in batches with configurable limits to avoid impacting application performance. |
| 203 | + |
| 204 | +### Why sentinel values instead of exceptions or None? |
| 205 | + |
| 206 | +The library uses sentinel values (`CACHE_MISS`, `DEFAULT_TTL`) instead of exceptions or `None` because: |
| 207 | + |
| 208 | +- **CACHE_MISS**: Allows distinguishing between "key not found" and "key found with value None" |
| 209 | +- **DEFAULT_TTL**: Allows distinguishing between "use default TTL", "no TTL", and "use this specific TTL value" |
| 210 | + |
| 211 | +This design provides clearer semantics and avoids ambiguity in the API. |
| 212 | + |
| 213 | +## Memory management |
| 214 | + |
| 215 | +The library is designed to be memory-efficient: |
| 216 | + |
| 217 | +- `Value` objects use `@dataclass(frozen=True, slots=True)` to minimize memory overhead |
| 218 | +- Size tracking includes approximations for object overhead |
| 219 | +- Items larger than half the size limit are rejected to prevent a single large item from dominating the cache |
| 220 | +- Expired items are proactively removed to prevent memory leaks |
| 221 | + |
| 222 | +## Concurrency considerations |
| 223 | + |
| 224 | +While the library is thread-safe, there are some considerations for concurrent usage: |
| 225 | + |
| 226 | +- All operations acquire the same lock, so highly concurrent workloads may experience contention |
| 227 | +- The background expiration thread shares the same lock, so expiration checks may briefly delay other operations |
| 228 | +- The lock is held for the entire duration of operations, ensuring atomicity but potentially causing brief blocking |
| 229 | + |
| 230 | +For most use cases, these considerations are negligible, but applications with extremely high concurrency requirements should be aware of potential lock contention. |
| 231 | + |
0 commit comments