Honor configurable cache size with LRU eviction

This commit is contained in:
SpiderFish
2026-08-26 10:06:03 +10:00
parent 093315b382
commit 859df88e0e
3 changed files with 64 additions and 6 deletions
+13
View File
@@ -67,6 +67,19 @@ The fixed source blacklist is:
Client error reports never modify this list. Client error reports never modify this list.
## Cache resizing
`CACHE_MAX_BYTES` is the worker's configurable LRU limit. After changing it and
restarting the container:
- A smaller value evicts the oldest unlocked entries until usage fits.
- A larger value immediately makes the additional capacity available for
background population, up to the size of the mounted cache filesystem.
The host filesystem is deliberately separate from application logic. Enlarge or
shrink its preallocated image during infrastructure maintenance, then set the
matching `CACHE_MAX_BYTES` value in Coolify.
## Playback locking ## Playback locking
- An active movie locks that movie. - An active movie locks that movie.
+31 -6
View File
@@ -31,6 +31,7 @@ JELLYFIN_LIBRARY_ROOT = os.environ.get("JELLYFIN_LIBRARY_ROOT", "/data/library")
JELLYFIN_ORIGINAL_ROOT = os.environ.get("JELLYFIN_ORIGINAL_ROOT", "/data/original").rstrip("/") JELLYFIN_ORIGINAL_ROOT = os.environ.get("JELLYFIN_ORIGINAL_ROOT", "/data/original").rstrip("/")
JELLYFIN_CACHE_ROOT = os.environ.get("JELLYFIN_CACHE_ROOT", "/data/cache").rstrip("/") JELLYFIN_CACHE_ROOT = os.environ.get("JELLYFIN_CACHE_ROOT", "/data/cache").rstrip("/")
JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "")
CACHE_MAX_BYTES = env_int("CACHE_MAX_BYTES", 1_000_000_000_000)
SERIES_COOLDOWN_SECONDS = env_int("SERIES_COOLDOWN_SECONDS", 3600) SERIES_COOLDOWN_SECONDS = env_int("SERIES_COOLDOWN_SECONDS", 3600)
POLL_SECONDS = env_int("POLL_SECONDS", 5) POLL_SECONDS = env_int("POLL_SECONDS", 5)
RESCAN_SECONDS = env_int("RESCAN_SECONDS", 300) RESCAN_SECONDS = env_int("RESCAN_SECONDS", 300)
@@ -501,9 +502,33 @@ class Manager:
manifest.unlink(missing_ok=True) manifest.unlink(missing_ok=True)
return True return True
def cache_usage(self):
return sum(
path.stat().st_size
for path in (CACHE_ROOT / "files").iterdir()
if path.is_file()
)
def cache_available(self):
stat = os.statvfs(CACHE_ROOT)
filesystem_free = stat.f_bavail * stat.f_frsize
configured_free = max(0, CACHE_MAX_BYTES - self.cache_usage())
return min(filesystem_free, configured_free)
def enforce_cache_limit(self):
usage = self.cache_usage()
for _, relative, output, manifest in self.cache_entries():
if usage <= CACHE_MAX_BYTES:
break
if self.is_locked(relative):
continue
size = output.stat().st_size + manifest.stat().st_size
log(f"Evicting {relative} after cache resize")
if self.remove_cache(relative):
usage -= size
def ensure_space(self, required, may_evict): def ensure_space(self, required, may_evict):
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize if self.cache_available() >= required:
if free >= required:
return True return True
if not may_evict: if not may_evict:
return False return False
@@ -512,8 +537,7 @@ class Manager:
continue continue
log(f"Evicting {relative}") log(f"Evicting {relative}")
self.remove_cache(relative) self.remove_cache(relative)
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize if self.cache_available() >= required:
if free >= required:
return True return True
return False return False
@@ -625,6 +649,7 @@ class Manager:
if not JELLYFIN_API_KEY: if not JELLYFIN_API_KEY:
raise RuntimeError("JELLYFIN_API_KEY is required") raise RuntimeError("JELLYFIN_API_KEY is required")
self.read_sessions() self.read_sessions()
self.enforce_cache_limit()
self.scan_inventory() self.scan_inventory()
self.sync_view() self.sync_view()
self.save() self.save()
@@ -632,6 +657,7 @@ class Manager:
if not self.read_sessions(): if not self.read_sessions():
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
continue continue
self.enforce_cache_limit()
now = time.time() now = time.time()
if now - self.last_scan >= RESCAN_SECONDS: if now - self.last_scan >= RESCAN_SECONDS:
changed = self.scan_inventory() changed = self.scan_inventory()
@@ -651,8 +677,7 @@ class Manager:
for rank, _, relative in candidates: for rank, _, relative in candidates:
source = SOURCE_ROOT / relative source = SOURCE_ROOT / relative
estimate = min(max(int(source.stat().st_size * 2.2), 1_000_000_000), 100_000_000_000) estimate = min(max(int(source.stat().st_size * 2.2), 1_000_000_000), 100_000_000_000)
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize if rank == 0 or self.cache_available() >= estimate:
if rank == 0 or free >= estimate:
selected = (rank == 0, relative) selected = (rank == 0, relative)
break break
if not selected: if not selected:
+20
View File
@@ -64,6 +64,26 @@ class CoreTests(unittest.TestCase):
source.write_bytes(b"changed") source.write_bytes(b"changed")
self.assertFalse(manager.cache_valid(relative)) self.assertFalse(manager.cache_valid(relative))
def test_smaller_cache_limit_evicts_oldest_content(self):
m = self.module
manager = m.Manager()
relative = "movies/Resize/Resize.mkv"
source = self.root / "source" / relative
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(b"original")
output, manifest = manager.cache_paths(relative)
output.write_bytes(b"cached rendition")
manifest.write_text(json.dumps({"relative": relative, "source_fingerprint": m.fingerprint(source)}))
manager.ensure_link(relative, m.cache_target(relative))
old_limit = m.CACHE_MAX_BYTES
try:
m.CACHE_MAX_BYTES = 1
manager.enforce_cache_limit()
finally:
m.CACHE_MAX_BYTES = old_limit
self.assertFalse(output.exists())
self.assertEqual(os.readlink(self.root / "view" / relative), m.source_target(relative))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()