1- """Unit tests for FileBasedCacheManager memory eviction logic .
1+ """Unit tests for FileBasedCacheManager LRU memory eviction.
22
33These tests exercise the in-memory cache eviction without making any API calls.
44"""
@@ -14,20 +14,14 @@ def _make_data(size_chars: int, num_keys: int = 1) -> dict:
1414 return {f"k{ i } " : "x" * (size_chars // num_keys ) for i in range (num_keys )}
1515
1616
17- class TestAddEntrySelfEviction :
18- """Regression tests for the self-eviction bug in add_entry.
19-
20- When a bin file is re-loaded from disk with a larger size (because
21- save_cache wrote new entries to disk without updating in_memory_cache),
22- add_entry previously left self.sizes and self.in_memory_cache out of
23- sync. This caused a KeyError on the next eviction cycle.
24- """
17+ class TestAddEntryReAdd :
18+ """Regression tests for re-adding bins (the self-eviction bug)."""
2519
2620 def test_re_add_larger_bin_does_not_crash (self ):
2721 """Re-adding a bin that triggers eviction must not self-evict."""
2822 with tempfile .TemporaryDirectory () as tmpdir :
29- small = _make_data (200_000 ) # ~0.2 MB
30- large = _make_data (400_000 ) # ~0.4 MB
23+ small = _make_data (200_000 )
24+ large = _make_data (400_000 )
3125
3226 limit = total_size (small ) + total_size (large ) + 0.01
3327 cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = limit )
@@ -36,20 +30,16 @@ def test_re_add_larger_bin_does_not_crash(self):
3630 bin_b = Path (tmpdir ) / "binB.json"
3731 bin_c = Path (tmpdir ) / "binC.json"
3832
39- # Fill cache: bin_a is the smallest entry
4033 cm .add_entry (bin_a , small )
4134 cm .add_entry (bin_b , large )
4235
4336 # Re-add bin_a with larger contents (simulates disk reload after
44- # save_cache grew the bin). This must evict bin_b, not bin_a itself .
37+ # save_cache grew the bin on disk) .
4538 cm .add_entry (bin_a , large )
46-
47- # Before the fix, sizes had bin_a but in_memory_cache didn't.
48- # This next add triggers eviction → KeyError on bin_a.pop().
4939 cm .add_entry (bin_c , large ) # must not crash
5040
51- def test_re_add_keeps_sizes_consistent (self ):
52- """After re-adding a bin, sizes and in_memory_cache must agree ."""
41+ def test_re_add_keeps_dicts_consistent (self ):
42+ """After re-adding a bin, sizes and in_memory_cache must have the same keys ."""
5343 with tempfile .TemporaryDirectory () as tmpdir :
5444 cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = 100 )
5545
@@ -76,22 +66,102 @@ def test_re_add_does_not_double_count(self):
7666 cm .add_entry (bin_a , data )
7767 assert abs (cm .total_usage_mb - expected_size ) < 0.001
7868
79- # Re-add with identical data — total should not change
8069 cm .add_entry (bin_a , data )
8170 assert abs (cm .total_usage_mb - expected_size ) < 0.001
8271
8372 def test_oversized_entry_not_leaked (self ):
84- """If an entry is too large for the cache, it must not leak in in_memory_cache ."""
73+ """If an entry exceeds the entire cache limit , it must not leak."""
8574 with tempfile .TemporaryDirectory () as tmpdir :
86- tiny_limit = 0.001 # ~1 KB
87- cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = tiny_limit )
75+ cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = 0.001 )
8876
8977 bin_a = Path (tmpdir ) / "binA.json"
90- huge = _make_data (1_000_000 ) # way over 1 KB
78+ huge = _make_data (1_000_000 )
9179
9280 result = cm .add_entry (bin_a , huge )
9381
9482 assert result is False
9583 assert bin_a not in cm .in_memory_cache
9684 assert bin_a not in cm .sizes
9785 assert cm .total_usage_mb == 0
86+
87+
88+ class TestLRUEvictionOrder :
89+ """Tests that eviction follows LRU order, not smallest-first."""
90+
91+ def test_evicts_oldest_not_smallest (self ):
92+ """When memory is full, the least-recently-used bin is evicted."""
93+ with tempfile .TemporaryDirectory () as tmpdir :
94+ small = _make_data (100_000 )
95+ large = _make_data (300_000 )
96+
97+ # Room for small + large, but not small + large + small
98+ limit = total_size (small ) + total_size (large ) + 0.01
99+ cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = limit )
100+
101+ bin_a = Path (tmpdir ) / "binA.json"
102+ bin_b = Path (tmpdir ) / "binB.json"
103+ bin_c = Path (tmpdir ) / "binC.json"
104+
105+ cm .add_entry (bin_a , small ) # oldest
106+ cm .add_entry (bin_b , large ) # newer
107+
108+ # Adding bin_c must evict bin_a (oldest), NOT bin_a (smallest).
109+ # Under the old smallest-first policy, bin_a would also have been
110+ # evicted — but for the wrong reason. We verify the LRU property
111+ # by checking that bin_b (larger but newer) survives.
112+ cm .add_entry (bin_c , small )
113+
114+ assert bin_a not in cm .in_memory_cache # evicted (oldest)
115+ assert bin_b in cm .in_memory_cache # kept (newer)
116+ assert bin_c in cm .in_memory_cache # just added
117+
118+ def test_touch_prevents_eviction (self ):
119+ """Accessing a bin via touch() moves it to the back of the LRU queue."""
120+ with tempfile .TemporaryDirectory () as tmpdir :
121+ data = _make_data (200_000 )
122+
123+ # Room for exactly 2 bins
124+ limit = total_size (data ) * 2 + 0.01
125+ cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = limit )
126+
127+ bin_a = Path (tmpdir ) / "binA.json"
128+ bin_b = Path (tmpdir ) / "binB.json"
129+ bin_c = Path (tmpdir ) / "binC.json"
130+
131+ cm .add_entry (bin_a , data ) # oldest
132+ cm .add_entry (bin_b , data ) # newer
133+
134+ # Touch bin_a — it's now the most-recently-used
135+ cm .touch (bin_a )
136+
137+ # Adding bin_c should evict bin_b (now the LRU), not bin_a
138+ cm .add_entry (bin_c , data )
139+
140+ assert bin_a in cm .in_memory_cache # survived (was touched)
141+ assert bin_b not in cm .in_memory_cache # evicted (LRU after touch)
142+ assert bin_c in cm .in_memory_cache
143+
144+ def test_add_entry_moves_to_mru (self ):
145+ """Re-adding a bin moves it to the most-recently-used position."""
146+ with tempfile .TemporaryDirectory () as tmpdir :
147+ data = _make_data (200_000 )
148+
149+ limit = total_size (data ) * 2 + 0.01
150+ cm = FileBasedCacheManager (Path (tmpdir ), max_mem_usage_mb = limit )
151+
152+ bin_a = Path (tmpdir ) / "binA.json"
153+ bin_b = Path (tmpdir ) / "binB.json"
154+ bin_c = Path (tmpdir ) / "binC.json"
155+
156+ cm .add_entry (bin_a , data ) # oldest
157+ cm .add_entry (bin_b , data ) # newer
158+
159+ # Re-add bin_a (simulates reload from disk) — now it's MRU
160+ cm .add_entry (bin_a , data )
161+
162+ # Adding bin_c should evict bin_b (now LRU), not bin_a
163+ cm .add_entry (bin_c , data )
164+
165+ assert bin_a in cm .in_memory_cache
166+ assert bin_b not in cm .in_memory_cache
167+ assert bin_c in cm .in_memory_cache
0 commit comments