Skip to content

Commit 182bc72

Browse files
Merge pull request #950 from boffin-dmytro/fix/dir-size-gb-ttl-caching
fix(helpers): add TTL cache to dir_size_gb to prevent blocking rglob walks
2 parents 9c06645 + 99dc8a0 commit 182bc72

File tree

3 files changed

+88
-3
lines changed

3 files changed

+88
-3
lines changed

dream-server/extensions/services/dashboard-api/helpers.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@
1717
from config import SERVICES, INSTALL_DIR, DATA_DIR, LLM_BACKEND
1818
from models import ServiceStatus, DiskUsage, ModelInfo, BootstrapStatus
1919

20+
21+
class _DirSizeCache:
22+
"""Per-path TTL cache for dir_size_gb to avoid repeated rglob walks."""
23+
24+
def __init__(self, ttl: float = 60.0):
25+
self._ttl = ttl
26+
self._store: dict[str, tuple[float, float]] = {}
27+
28+
def get(self, path: Path) -> float | None:
29+
key = str(path.resolve())
30+
entry = self._store.get(key)
31+
if entry is None:
32+
return None
33+
expires_at, value = entry
34+
if time.monotonic() > expires_at:
35+
del self._store[key]
36+
return None
37+
return value
38+
39+
def set(self, path: Path, value: float):
40+
self._store[str(path.resolve())] = (time.monotonic() + self._ttl, value)
41+
42+
43+
_dir_size_cache = _DirSizeCache()
44+
2045
# Lemonade serves at /api/v1 instead of llama.cpp's /v1
2146
_LLM_API_PREFIX = "/api/v1" if LLM_BACKEND == "lemonade" else "/v1"
2247

@@ -282,8 +307,13 @@ def dir_size_gb(path: Path) -> float:
282307
"""Calculate total size of a directory in GB. Returns 0.0 if path doesn't exist.
283308
284309
Skips symlinks to avoid following links outside DATA_DIR and double-counting.
310+
Results are cached for 60 seconds to avoid repeated expensive rglob walks.
285311
"""
312+
cached = _dir_size_cache.get(path)
313+
if cached is not None:
314+
return cached
286315
if not path.exists():
316+
_dir_size_cache.set(path, 0.0)
287317
return 0.0
288318
total = 0
289319
try:
@@ -295,7 +325,19 @@ def dir_size_gb(path: Path) -> float:
295325
pass
296326
except (PermissionError, OSError):
297327
pass
298-
return round(total / (1024**3), 2)
328+
result = round(total / (1024**3), 2)
329+
_dir_size_cache.set(path, result)
330+
return result
331+
332+
333+
def invalidate_dir_size_cache(path: Path):
334+
"""Remove cached size for a specific path after it has been modified."""
335+
_dir_size_cache._store.pop(str(path.resolve()), None)
336+
337+
338+
def clear_dir_size_cache():
339+
"""Clear the entire dir_size_gb cache (e.g. after bulk operations)."""
340+
_dir_size_cache._store.clear()
299341

300342

301343
def get_disk_usage() -> DiskUsage:

dream-server/extensions/services/dashboard-api/routers/extensions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1411,10 +1411,11 @@ def purge_extension_data(
14111411
if not body.confirm:
14121412
raise HTTPException(status_code=400, detail="Confirmation required: set confirm=true")
14131413

1414-
from helpers import dir_size_gb # noqa: PLC0415
1414+
from helpers import dir_size_gb, invalidate_dir_size_cache # noqa: PLC0415
14151415
size_gb = dir_size_gb(data_path)
14161416

14171417
shutil.rmtree(data_path, ignore_errors=True)
1418+
invalidate_dir_size_cache(data_path)
14181419

14191420
if data_path.exists():
14201421
raise HTTPException(status_code=500, detail=f"Could not fully remove data/{service_id}. Some files may be owned by root.")

dream-server/extensions/services/dashboard-api/tests/test_helpers.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import json
5+
from pathlib import Path
56
from unittest.mock import AsyncMock, MagicMock
67

78
import aiohttp
@@ -13,7 +14,7 @@
1314
get_uptime, get_cpu_metrics, get_ram_metrics,
1415
check_service_health, get_all_services,
1516
get_llama_metrics, get_loaded_model, get_llama_context_size,
16-
get_disk_usage, dir_size_gb,
17+
get_disk_usage, dir_size_gb, invalidate_dir_size_cache, clear_dir_size_cache,
1718
_get_aio_session, set_services_cache, get_cached_services,
1819
_get_lifetime_tokens,
1920
)
@@ -821,14 +822,17 @@ def test_invalid_eta_string(self, data_dir):
821822
class TestDirSizeGb:
822823

823824
def test_nonexistent_path_returns_zero(self, tmp_path):
825+
clear_dir_size_cache()
824826
assert dir_size_gb(tmp_path / "does-not-exist") == 0.0
825827

826828
def test_empty_directory_returns_zero(self, tmp_path):
829+
clear_dir_size_cache()
827830
empty = tmp_path / "empty"
828831
empty.mkdir()
829832
assert dir_size_gb(empty) == 0.0
830833

831834
def test_directory_with_files(self, tmp_path):
835+
clear_dir_size_cache()
832836
d = tmp_path / "data"
833837
d.mkdir()
834838
# Write 100 MiB (avoids allocating 1 GiB in CI)
@@ -837,6 +841,7 @@ def test_directory_with_files(self, tmp_path):
837841
assert dir_size_gb(d) == 0.1
838842

839843
def test_symlinks_are_skipped(self, tmp_path):
844+
clear_dir_size_cache()
840845
d = tmp_path / "withlinks"
841846
d.mkdir()
842847
real = d / "real.bin"
@@ -846,3 +851,40 @@ def test_symlinks_are_skipped(self, tmp_path):
846851
# Only real.bin should be counted (1024 B ≈ 0.0 GB when rounded to 2dp)
847852
result = dir_size_gb(d)
848853
assert result == 0.0 # 1024 bytes rounds to 0.0 GB
854+
855+
def test_uses_cached_value_until_invalidated(self, tmp_path, monkeypatch):
856+
clear_dir_size_cache()
857+
d = tmp_path / "cached"
858+
d.mkdir()
859+
(d / "data.bin").write_bytes(b"\x00" * 1024)
860+
861+
assert dir_size_gb(d) == 0.0
862+
863+
def _unexpected_rglob(self, pattern):
864+
raise AssertionError("dir_size_gb unexpectedly walked the filesystem")
865+
866+
monkeypatch.setattr(Path, "rglob", _unexpected_rglob)
867+
assert dir_size_gb(d) == 0.0
868+
869+
def test_invalidate_dir_size_cache_forces_refresh(self, tmp_path, monkeypatch):
870+
clear_dir_size_cache()
871+
d = tmp_path / "refresh"
872+
d.mkdir()
873+
(d / "data.bin").write_bytes(b"\x00" * 1024)
874+
875+
assert dir_size_gb(d) == 0.0
876+
877+
original_rglob = Path.rglob
878+
calls = {"count": 0}
879+
880+
def _tracking_rglob(self, pattern):
881+
calls["count"] += 1
882+
return original_rglob(self, pattern)
883+
884+
monkeypatch.setattr(Path, "rglob", _tracking_rglob)
885+
assert dir_size_gb(d) == 0.0
886+
assert calls["count"] == 0
887+
888+
invalidate_dir_size_cache(d)
889+
assert dir_size_gb(d) == 0.0
890+
assert calls["count"] == 1

0 commit comments

Comments
 (0)