|
| 1 | +import os |
| 2 | +import shutil |
| 3 | +import logging |
| 4 | +from typing import Optional, Dict |
| 5 | +import collections |
| 6 | + |
| 7 | +logger = logging.getLogger("AssetCacheManager") |
| 8 | + |
| 9 | +class AssetCacheManager: |
| 10 | + def __init__(self, in_memory_limit: int = 0, disk_cache_dir: str = "/tmp/autograder_assets_cache"): |
| 11 | + """ |
| 12 | + Initialize the asset cache manager. |
| 13 | + |
| 14 | + Args: |
| 15 | + in_memory_limit: Maximum number of assets to keep in memory. If 0, in-memory cache is disabled. |
| 16 | + disk_cache_dir: Directory to use for disk caching. |
| 17 | + """ |
| 18 | + self.in_memory_limit = in_memory_limit |
| 19 | + self.disk_cache_dir = disk_cache_dir |
| 20 | + |
| 21 | + # LRU cache for in-memory assets (filename -> bytes) |
| 22 | + self.in_memory_cache: Dict[str, bytes] = collections.OrderedDict() |
| 23 | + |
| 24 | + # Ensure disk cache directory exists |
| 25 | + if not os.path.exists(self.disk_cache_dir): |
| 26 | + os.makedirs(self.disk_cache_dir, exist_ok=True) |
| 27 | + |
| 28 | + def get(self, asset_key: str) -> Optional[bytes]: |
| 29 | + """Get an asset from cache (memory first, then disk).""" |
| 30 | + # Try in-memory cache first |
| 31 | + if self.in_memory_limit > 0 and asset_key in self.in_memory_cache: |
| 32 | + logger.debug("In-memory cache hit for %s", asset_key) |
| 33 | + # Move to end to mark as recently used |
| 34 | + content = self.in_memory_cache.pop(asset_key) |
| 35 | + self.in_memory_cache[asset_key] = content |
| 36 | + return content |
| 37 | + |
| 38 | + # Try disk cache |
| 39 | + disk_path = self._get_disk_path(asset_key) |
| 40 | + if os.path.exists(disk_path): |
| 41 | + logger.debug("Disk cache hit for %s", asset_key) |
| 42 | + try: |
| 43 | + with open(disk_path, "rb") as f: |
| 44 | + content = f.read() |
| 45 | + |
| 46 | + # Store in in-memory cache if enabled |
| 47 | + if self.in_memory_limit > 0: |
| 48 | + self._add_to_memory_cache(asset_key, content) |
| 49 | + |
| 50 | + return content |
| 51 | + except Exception as e: |
| 52 | + logger.error("Failed to read asset from disk cache: %s", str(e)) |
| 53 | + |
| 54 | + return None |
| 55 | + |
| 56 | + def put(self, asset_key: str, content: bytes) -> None: |
| 57 | + """Store an asset in both memory and disk caches.""" |
| 58 | + # Save to disk |
| 59 | + disk_path = self._get_disk_path(asset_key) |
| 60 | + try: |
| 61 | + # Create subdirectories if needed (e.g., if asset_key contains '/') |
| 62 | + os.makedirs(os.path.dirname(disk_path), exist_ok=True) |
| 63 | + with open(disk_path, "wb") as f: |
| 64 | + f.write(content) |
| 65 | + logger.debug("Stored asset %s in disk cache", asset_key) |
| 66 | + except Exception as e: |
| 67 | + logger.error("Failed to write asset to disk cache: %s", str(e)) |
| 68 | + |
| 69 | + # Save to memory if enabled |
| 70 | + if self.in_memory_limit > 0: |
| 71 | + self._add_to_memory_cache(asset_key, content) |
| 72 | + |
| 73 | + def _add_to_memory_cache(self, asset_key: str, content: bytes) -> None: |
| 74 | + """Helper to add an asset to the LRU memory cache.""" |
| 75 | + if asset_key in self.in_memory_cache: |
| 76 | + self.in_memory_cache.pop(asset_key) |
| 77 | + elif len(self.in_memory_cache) >= self.in_memory_limit: |
| 78 | + # Remove oldest item (first item in OrderedDict) |
| 79 | + self.in_memory_cache.popitem(last=False) |
| 80 | + |
| 81 | + self.in_memory_cache[asset_key] = content |
| 82 | + |
| 83 | + def _get_disk_path(self, asset_key: str) -> str: |
| 84 | + """Resolve the absolute disk path for an asset key.""" |
| 85 | + # Sanitize asset_key to prevent directory traversal |
| 86 | + # asset_key is expected to be a hash or a safe relative path |
| 87 | + safe_key = os.path.normpath(asset_key).lstrip('/') |
| 88 | + return os.path.join(self.disk_cache_dir, safe_key) |
| 89 | + |
| 90 | + def clear(self) -> None: |
| 91 | + """Clear all caches.""" |
| 92 | + self.in_memory_cache.clear() |
| 93 | + if os.path.exists(self.disk_cache_dir): |
| 94 | + shutil.rmtree(self.disk_cache_dir) |
| 95 | + os.makedirs(self.disk_cache_dir, exist_ok=True) |
0 commit comments