Skip to content

Commit b0688d0

Browse files
chuenchen309claude
andcommitted
fix(stores): MemoryStore/FileStore.exists() ignores expiry
exists() checked only key/file presence, unlike get() which correctly treats an expired entry as gone (and lazily removes it). Since expired entries are only cleared on access rather than eagerly, a key that has passed its expires_in remains "present" in the backing dict/filesystem until something touches it - so exists() reported True for a key that get() would return None for, an inconsistency between two methods of the same Store interface. Give exists() the same load-check-expire logic get() already uses (minus the renew_for handling, which only makes sense for get()). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a43322 commit b0688d0

3 files changed

Lines changed: 37 additions & 2 deletions

File tree

litestar/stores/file.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,16 @@ async def delete_expired(self) -> None:
177177
async def exists(self, key: str) -> bool:
178178
"""Check if a given ``key`` exists."""
179179
path = self._path_from_key(key)
180-
return await path.exists()
180+
storage_obj = await self._load_from_path(path)
181+
182+
if not storage_obj:
183+
return False
184+
185+
if storage_obj.expired:
186+
await path.unlink(missing_ok=True)
187+
return False
188+
189+
return True
181190

182191
async def expires_in(self, key: str) -> int | None:
183192
"""Get the time in seconds ``key`` expires in. If no such ``key`` exists or no

litestar/stores/memory.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,17 @@ async def delete_expired(self) -> None:
104104

105105
async def exists(self, key: str) -> bool:
106106
"""Check if a given ``key`` exists."""
107-
return key in self._store
107+
async with self._lock:
108+
storage_obj = self._store.get(key)
109+
110+
if not storage_obj:
111+
return False
112+
113+
if storage_obj.expired:
114+
self._store.pop(key)
115+
return False
116+
117+
return True
108118

109119
async def expires_in(self, key: str) -> int | None:
110120
"""Get the time in seconds ``key`` expires in. If no such ``key`` exists or no

tests/unit/test_stores.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@ async def test_exists(store: Store) -> None:
156156
assert await store.exists("foo") is True
157157

158158

159+
async def test_exists_expired(store: Store, frozen_datetime: Traveller) -> None:
160+
# exists() must be consistent with get(): an expired key must report as
161+
# not existing, not just be lazily cleaned up on the next get()/set() call.
162+
await store.set("foo", b"bar", expires_in=1)
163+
164+
frozen_datetime.shift(2)
165+
if isinstance(store, RedisStore):
166+
# shifting time does not affect the Redis instance
167+
# this is done to emulate auto-expiration
168+
await store._redis.expire(f"{store.namespace}:foo", 0)
169+
if isinstance(store, ValkeyStore):
170+
await store._valkey.expire(f"{store.namespace}:foo", 0)
171+
172+
assert await store.exists("foo") is False
173+
174+
159175
async def test_expires_in_not_set(store: Store) -> None:
160176
assert await store.expires_in("foo") is None
161177

0 commit comments

Comments
 (0)