Honor configurable cache size with LRU eviction
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user