Skip to content

Commit c69a36c

Browse files
authored
add explanation (#8)
1 parent f3b1672 commit c69a36c

1 file changed

Lines changed: 231 additions & 0 deletions

File tree

docs/explanation/index.md

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

Comments
 (0)