Skip to content

Commit afb6827

Browse files
authored
various improvements (#10)
1 parent 9028e7f commit afb6827

4 files changed

Lines changed: 47 additions & 28 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ if obj is not CACHE_MISS:
9090

9191
## Full API reference
9292

93-
Refer to the [API reference](reference/api.md) for the full API.
93+
Refer to the [API reference](https://fabien-marty.github.io/atomic-lru/reference/api/) for the full API.
9494

9595
## DEV
9696

atomic_lru/_storage/storage.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ class Storage(Generic[T]):
9898
__closed: bool = False
9999

100100
def __post_init__(self) -> None:
101+
# Validate configuration before allocating any resources
102+
if self.size_limit_in_bytes is not None and self.size_limit_in_bytes < 4096:
103+
raise ValueError("size_limit_in_bytes must be greater than 4096")
104+
101105
self._size_in_bytes = sys.getsizeof(self._data)
102106
if not self.expiration_disabled:
103107
self.__expiration_thread = ExpirationThread(
@@ -107,29 +111,31 @@ def __post_init__(self) -> None:
107111
log=self.expiration_thread_log,
108112
)
109113
self.__expiration_thread.start()
110-
if self.size_limit_in_bytes is not None and self.size_limit_in_bytes < 4096:
111-
raise ValueError("size_limit_in_bytes must be greater than 4096")
112114

113115
def close(self, wait: bool = False) -> None:
114116
"""Close the storage and stop the expiration thread.
115117
116118
Marks the storage as closed and stops the background expiration thread
117-
if it was started. After closing, all operations except `size_in_bytes`
118-
and `number_of_items` will raise a `RuntimeError`.
119+
if it was started. After closing, all operations except `size_in_bytes`,
120+
`number_of_items`, and `get()` will raise a `RuntimeError`.
119121
120122
Args:
121123
wait: If True, blocks until the expiration thread has fully stopped.
122124
If False, returns immediately after signaling the thread to stop.
123125
Defaults to False.
124126
125127
Note:
126-
This method is idempotent - calling it multiple times has no effect.
128+
This method is idempotent - calling it multiple times
129+
or from multiple threads has no effect beyond the first call.
127130
It's recommended to call this method when you're done with the storage
128131
to ensure proper cleanup of background threads.
129132
"""
130-
if self.__closed:
131-
return
132-
self.__closed = True
133+
with self.__lock:
134+
if self.__closed:
135+
return
136+
self.__closed = True
137+
# Stop thread outside the lock to avoid potential deadlock
138+
# (thread's _clean_expired also acquires the lock)
133139
if not self.expiration_disabled:
134140
assert self.__expiration_thread is not None
135141
self.__expiration_thread.stop(wait=wait)
@@ -317,7 +323,8 @@ def get(self, key: str) -> T | CacheMissSentinel:
317323
Note:
318324
Expired items are automatically deleted when accessed. This method does
319325
not raise exceptions for missing keys - use `CACHE_MISS` to check for
320-
cache misses.
326+
cache misses. Unlike mutating operations (`set`, `delete`, `clear`),
327+
this method can be called even after the storage has been closed.
321328
"""
322329
with self.__lock:
323330
value_obj = self._data.get(key)

atomic_lru/_storage/thread.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def clean_func(start, stop):
6464
# Internal threading event used to signal thread termination
6565
_stop_event: threading.Event = field(default_factory=threading.Event)
6666

67+
# Internal lock to protect thread start/stop operations
68+
_lock: threading.Lock = field(default_factory=threading.Lock)
69+
6770
# Internal thread instance
6871
_thread: threading.Thread | None = None
6972

@@ -89,14 +92,15 @@ def start(self) -> None:
8992
automatically terminate when the main process exits (daemon thread).
9093
9194
Note:
92-
The thread is started as a daemon thread, meaning it will not prevent
93-
the program from exiting if it's still running.
95+
This method is thread-safe. The thread is started as a daemon thread,
96+
meaning it will not prevent the program from exiting if it's still running.
9497
"""
95-
if self._thread is not None:
96-
return
97-
self._thread = threading.Thread(target=self.loop, daemon=True)
98-
self._thread.start()
99-
self._debug("Expiration thread started")
98+
with self._lock:
99+
if self._thread is not None:
100+
return
101+
self._thread = threading.Thread(target=self.loop, daemon=True)
102+
self._thread.start()
103+
self._debug("Expiration thread started")
100104

101105
def stop(self, wait: bool = False) -> None:
102106
"""Stop the expiration thread.
@@ -110,15 +114,17 @@ def stop(self, wait: bool = False) -> None:
110114
False.
111115
112116
Note:
113-
When `wait=False`, the thread may continue running briefly after this
114-
method returns. Use `wait=True` to ensure the thread has fully stopped
115-
before proceeding.
117+
This method is thread-safe. When `wait=False`, the thread may continue
118+
running briefly after this method returns. Use `wait=True` to ensure the
119+
thread has fully stopped before proceeding.
116120
"""
117-
if self._thread is None:
118-
return
121+
with self._lock:
122+
if self._thread is None:
123+
return
124+
thread = self._thread
119125
self._stop_event.set()
120126
if wait:
121-
self._thread.join()
127+
thread.join()
122128
self._debug("Expiration thread stopped")
123129
else:
124130
self._debug("Stop event set for expiration thread")
@@ -135,7 +141,8 @@ def loop(self) -> None:
135141
items have been checked)
136142
4. Waits for `delay` seconds before the next iteration
137143
138-
The loop terminates when the stop event is set via `stop()`. If
144+
The loop terminates when the stop event is set via `stop()`, or when the
145+
storage is closed (indicated by RuntimeError from the callback). If
139146
`max_checks_per_iteration` is 0, no checks are performed but the loop
140147
continues to wait, allowing the thread to be stopped gracefully.
141148
@@ -146,9 +153,14 @@ def loop(self) -> None:
146153
start_index: int = 0
147154
while not self._stop_event.is_set():
148155
if self.max_checks_per_iteration > 0:
149-
tested, deleted = self.clean_callback(
150-
start_index, start_index + self.max_checks_per_iteration
151-
)
156+
try:
157+
tested, deleted = self.clean_callback(
158+
start_index, start_index + self.max_checks_per_iteration
159+
)
160+
except RuntimeError:
161+
# Storage was closed while we were running - exit gracefully
162+
self._debug("Storage closed, expiration thread exiting")
163+
return
152164
if tested == 0:
153165
start_index = 0 # restart from the beginning
154166
else:

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
## Full API reference
1010

11-
Refer to the [API reference](reference/api.md) for the full API.
11+
Refer to the [API reference](https://fabien-marty.github.io/atomic-lru/reference/api/) for the full API.
1212

1313
{{ include_markdown('reference/dev.md', 1) }}
1414

0 commit comments

Comments
 (0)