+5
-4
backend/src/backend/_internal/atproto/client.py
+5
-4
backend/src/backend/_internal/atproto/client.py
···
6
6
from typing import Any
7
7
8
8
from atproto_oauth.models import OAuthSession
9
-
from cachetools import TTLCache
9
+
from cachetools import LRUCache
10
10
11
11
from backend._internal import Session as AuthSession
12
12
from backend._internal import get_oauth_client, get_session, update_session_tokens
···
14
14
logger = logging.getLogger(__name__)
15
15
16
16
# per-session locks for token refresh to prevent concurrent refresh races.
17
-
# uses TTLCache to auto-expire locks for inactive sessions (1 hour TTL, max 10k sessions).
18
-
# this prevents unbounded memory growth as sessions are created and abandoned.
19
-
_refresh_locks: TTLCache[str, asyncio.Lock] = TTLCache(maxsize=10000, ttl=3600)
17
+
# uses LRUCache (not TTLCache) to bound memory - LRU eviction is safe because:
18
+
# 1. recently-used locks won't be evicted while in use
19
+
# 2. TTL expiration could evict a lock while a coroutine holds it, breaking mutual exclusion
20
+
_refresh_locks: LRUCache[str, asyncio.Lock] = LRUCache(maxsize=10000)
20
21
21
22
22
23
def reconstruct_oauth_session(oauth_data: dict[str, Any]) -> OAuthSession:
+68
backend/tests/test_token_refresh.py
+68
backend/tests/test_token_refresh.py
···
272
272
273
273
# OAuth client should have been called exactly once
274
274
assert refresh_call_count == 1
275
+
276
+
277
+
class TestRefreshLocksCache:
278
+
"""test _refresh_locks cache behavior (memory leak prevention)."""
279
+
280
+
def test_same_session_returns_same_lock(self):
281
+
"""same session_id should return the same lock instance."""
282
+
from backend._internal.atproto.client import _refresh_locks
283
+
284
+
# clear for isolated test
285
+
_refresh_locks.clear()
286
+
287
+
# create lock for session
288
+
_refresh_locks["session-a"] = asyncio.Lock()
289
+
lock1 = _refresh_locks["session-a"]
290
+
291
+
# accessing again should return same lock
292
+
lock2 = _refresh_locks["session-a"]
293
+
assert lock1 is lock2
294
+
295
+
def test_different_sessions_have_different_locks(self):
296
+
"""different session_ids should have different lock instances."""
297
+
from backend._internal.atproto.client import _refresh_locks
298
+
299
+
_refresh_locks.clear()
300
+
301
+
_refresh_locks["session-a"] = asyncio.Lock()
302
+
_refresh_locks["session-b"] = asyncio.Lock()
303
+
304
+
assert _refresh_locks["session-a"] is not _refresh_locks["session-b"]
305
+
306
+
def test_cache_is_bounded_by_maxsize(self):
307
+
"""cache should evict entries when full (LRU behavior)."""
308
+
from backend._internal.atproto.client import _refresh_locks
309
+
310
+
_refresh_locks.clear()
311
+
312
+
# fill cache beyond maxsize (maxsize=10000, but we'll test the behavior)
313
+
# just verify the maxsize property is set
314
+
assert _refresh_locks.maxsize == 10000
315
+
316
+
# add some entries and verify they exist
317
+
for i in range(100):
318
+
_refresh_locks[f"session-{i}"] = asyncio.Lock()
319
+
320
+
assert len(_refresh_locks) == 100
321
+
322
+
def test_lru_eviction_order(self):
323
+
"""LRU cache should evict least recently used entries first."""
324
+
from cachetools import LRUCache
325
+
326
+
# use a small cache to test eviction behavior
327
+
small_cache: LRUCache[str, asyncio.Lock] = LRUCache(maxsize=3)
328
+
329
+
small_cache["a"] = asyncio.Lock()
330
+
small_cache["b"] = asyncio.Lock()
331
+
small_cache["c"] = asyncio.Lock()
332
+
333
+
# access "a" to make it recently used
334
+
_ = small_cache["a"]
335
+
336
+
# add "d" - should evict "b" (least recently used)
337
+
small_cache["d"] = asyncio.Lock()
338
+
339
+
assert "a" in small_cache # recently accessed
340
+
assert "b" not in small_cache # evicted (LRU)
341
+
assert "c" in small_cache
342
+
assert "d" in small_cache