Skip to content

Commit efcbfe2

Browse files
committed
refactor(cache): centralize engine model cache with persistent registry cleanup
- Move model-list caching to BaseEngine with per-account disk persistence, TTL expiry, and stale fallback on refresh failure. - Add registry-based cache tracking/pruning plus account-removal cache cleanup, with tests for restart reuse, expiry reload, version mismatch, write failures, and registry maintenance.
1 parent 1024cba commit efcbfe2

6 files changed

Lines changed: 676 additions & 10 deletions

File tree

basilisk/config/account_config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
import basilisk.global_vars as global_vars
3030
from basilisk.consts import APP_NAME
3131
from basilisk.provider import Provider, get_provider, providers
32+
from basilisk.provider_engine.model_cache_registry import (
33+
remove_account_model_cache,
34+
)
3235

3336
from .config_enums import AccountSource, KeyStorageMethodEnum
3437
from .config_helper import (
@@ -578,6 +581,7 @@ def remove(self, account: Account):
578581
ValueError: If the account is not found in the configuration.
579582
"""
580583
account.delete_keyring_password()
584+
remove_account_model_cache(str(account.id))
581585
self.accounts.remove(account)
582586

583587
def clear(self):

basilisk/provider_engine/base_engine.py

Lines changed: 210 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,39 @@
66

77
from __future__ import annotations
88

9+
import hashlib
10+
import json
911
import logging
1012
import threading
1113
import time
1214
from abc import ABC, abstractmethod
15+
from dataclasses import asdict
1316
from functools import cached_property
17+
from pathlib import Path
1418
from typing import TYPE_CHECKING, Any, Optional
1519

20+
import basilisk.config as config
1621
from basilisk.consts import APP_NAME, APP_SOURCE_URL
1722
from basilisk.conversation import Conversation, Message, MessageBlock
1823
from basilisk.provider_ai_model import ProviderAIModel
1924
from basilisk.provider_capability import ProviderCapability
2025
from basilisk.provider_engine.dynamic_model_loader import load_models_from_url
26+
from basilisk.provider_engine.model_cache_registry import (
27+
get_models_cache_dir,
28+
get_registry_filename,
29+
prune_model_cache_registry,
30+
register_model_cache_file,
31+
remove_cache_file_from_registry,
32+
write_json_atomic,
33+
)
2134

2235
if TYPE_CHECKING:
2336
from basilisk.config import Account
2437

2538
log = logging.getLogger(__name__)
39+
_MODELS_CACHE_PAYLOAD_VERSION = 1
40+
_CACHE_PRUNE_INTERVAL_SECONDS = 3600
41+
_CACHE_STALE_MULTIPLIER = 7
2642

2743

2844
class BaseEngine(ABC):
@@ -39,6 +55,8 @@ class BaseEngine(ABC):
3955
capabilities: set[ProviderCapability] = set()
4056
supported_attachment_formats: set[str] = set()
4157
MODELS_JSON_URL: str | None = None
58+
_last_cache_prune_at: float = 0.0
59+
_cache_prune_lock = threading.Lock()
4260

4361
def __init__(self, account: Account) -> None:
4462
"""Initializes the engine with the given account.
@@ -76,28 +94,200 @@ def _load_models(self) -> list[ProviderAIModel]:
7694

7795
def _get_models_cache_ttl_seconds(self) -> int:
7896
"""Return model-list cache TTL in seconds from configuration."""
79-
import basilisk.config as config
80-
8197
return config.conf().general.model_metadata_cache_ttl_seconds
8298

99+
def _get_models_cache_max_stale_seconds(self, ttl_seconds: int) -> int:
100+
"""Return max age allowed for stale cache fallback."""
101+
return ttl_seconds * _CACHE_STALE_MULTIPLIER
102+
103+
@cached_property
104+
def _models_cache_file_path(self) -> Path:
105+
"""Return persistent cache file path for this engine/account."""
106+
cache_key_payload = {
107+
"account_id": str(self.account.id),
108+
"provider_id": str(self.account.provider.id),
109+
"base_url": (
110+
str(self.account.custom_base_url)
111+
if self.account.custom_base_url is not None
112+
else None
113+
),
114+
"engine_cls": self.__class__.__name__,
115+
"models_json_url": self.MODELS_JSON_URL,
116+
}
117+
cache_key = hashlib.sha256(
118+
json.dumps(cache_key_payload, sort_keys=True).encode("utf-8")
119+
).hexdigest()
120+
cache_dir = get_models_cache_dir()
121+
return cache_dir / f"{cache_key}.json"
122+
123+
def _set_models_ram_cache(
124+
self, models: list[ProviderAIModel], cached_at: float
125+
) -> list[ProviderAIModel]:
126+
"""Store models in RAM cache and return the stored list."""
127+
self._models_cache = models
128+
self._models_cached_at = cached_at
129+
return self._models_cache
130+
131+
def _write_models_disk_cache(
132+
self, models: list[ProviderAIModel], cached_at: float
133+
) -> None:
134+
"""Persist model cache payload to disk."""
135+
cache_file = self._models_cache_file_path
136+
payload = {
137+
"version": _MODELS_CACHE_PAYLOAD_VERSION,
138+
"cached_at": cached_at,
139+
"models": [asdict(model) for model in models],
140+
}
141+
write_json_atomic(cache_file, payload)
142+
register_model_cache_file(str(self.account.id), cache_file)
143+
144+
def _delete_cache_file(self, cache_file: Path) -> None:
145+
"""Delete one cache file, logging only on failure."""
146+
try:
147+
cache_file.unlink(missing_ok=True)
148+
except OSError:
149+
log.debug("Could not delete models cache file %s", cache_file)
150+
remove_cache_file_from_registry(cache_file.name)
151+
152+
def _read_models_disk_cache(
153+
self,
154+
now: float,
155+
ttl_seconds: int,
156+
allow_stale: bool = False,
157+
max_stale_seconds: int | None = None,
158+
) -> tuple[list[ProviderAIModel], float] | None:
159+
"""Read model cache payload from disk when valid for current TTL."""
160+
cache_file = self._models_cache_file_path
161+
if not cache_file.exists():
162+
return None
163+
try:
164+
payload = json.loads(cache_file.read_text(encoding="utf-8"))
165+
if not isinstance(payload, dict):
166+
raise TypeError("invalid cache payload")
167+
if payload.get("version") != _MODELS_CACHE_PAYLOAD_VERSION:
168+
raise ValueError("unsupported cache payload version")
169+
cached_at = float(payload["cached_at"])
170+
cache_age_seconds = now - cached_at
171+
if not allow_stale and cache_age_seconds >= ttl_seconds:
172+
log.debug(
173+
"Models disk cache expired for %s (age=%.1fs, ttl=%ss)",
174+
self.__class__.__name__,
175+
cache_age_seconds,
176+
ttl_seconds,
177+
)
178+
return None
179+
if (
180+
allow_stale
181+
and max_stale_seconds is not None
182+
and cache_age_seconds >= max_stale_seconds
183+
):
184+
log.debug(
185+
"Models stale disk cache exceeded retention for %s (age=%.1fs, max_stale=%ss)",
186+
self.__class__.__name__,
187+
cache_age_seconds,
188+
max_stale_seconds,
189+
)
190+
self._delete_cache_file(cache_file)
191+
return None
192+
model_rows = payload.get("models")
193+
if not isinstance(model_rows, list):
194+
raise TypeError("invalid models cache payload")
195+
models = [ProviderAIModel(**x) for x in model_rows]
196+
return models, cached_at
197+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
198+
log.warning("Failed reading models disk cache: %s", exc)
199+
self._delete_cache_file(cache_file)
200+
return None
201+
202+
def _prune_models_cache_dir(self, now: float, ttl_seconds: int) -> None:
203+
"""Periodically remove obsolete cache files to limit file growth."""
204+
last_prune_at = BaseEngine._last_cache_prune_at
205+
if (
206+
last_prune_at > 0
207+
and now - last_prune_at < _CACHE_PRUNE_INTERVAL_SECONDS
208+
):
209+
return
210+
with BaseEngine._cache_prune_lock:
211+
last_prune_at = BaseEngine._last_cache_prune_at
212+
if (
213+
last_prune_at > 0
214+
and now - last_prune_at < _CACHE_PRUNE_INTERVAL_SECONDS
215+
):
216+
return
217+
BaseEngine._last_cache_prune_at = now
218+
prune_model_cache_registry()
219+
cache_dir = get_models_cache_dir()
220+
if not cache_dir.exists():
221+
return
222+
max_stale_seconds = self._get_models_cache_max_stale_seconds(
223+
ttl_seconds
224+
)
225+
for cache_file in cache_dir.glob("*.json"):
226+
if cache_file.name == get_registry_filename():
227+
continue
228+
try:
229+
payload = json.loads(cache_file.read_text(encoding="utf-8"))
230+
cached_at = float(payload["cached_at"])
231+
version = payload.get("version")
232+
if version != _MODELS_CACHE_PAYLOAD_VERSION:
233+
self._delete_cache_file(cache_file)
234+
continue
235+
if now - cached_at >= max_stale_seconds:
236+
self._delete_cache_file(cache_file)
237+
except (
238+
OSError,
239+
json.JSONDecodeError,
240+
KeyError,
241+
TypeError,
242+
ValueError,
243+
):
244+
self._delete_cache_file(cache_file)
245+
83246
@property
84247
def models(self) -> list[ProviderAIModel]:
85248
"""Get models available for the provider.
86249
87250
Returns:
88251
List of supported provider models with their configurations.
89252
"""
90-
now = time.monotonic()
253+
now = time.time()
91254
ttl_seconds = self._get_models_cache_ttl_seconds()
255+
max_stale_seconds = self._get_models_cache_max_stale_seconds(
256+
ttl_seconds
257+
)
258+
self._prune_models_cache_dir(now, ttl_seconds)
92259
with self._models_cache_lock:
93260
if (
94261
self._models_cache is not None
95262
and self._models_cached_at is not None
96263
and now - self._models_cached_at < ttl_seconds
97264
):
265+
log.debug(
266+
"Using models from RAM cache for %s",
267+
self.__class__.__name__,
268+
)
269+
return self._models_cache
270+
disk_cache = self._read_models_disk_cache(now, ttl_seconds)
271+
if disk_cache is not None:
272+
models, cached_at = disk_cache
273+
with self._models_cache_lock:
274+
self._set_models_ram_cache(models, cached_at)
275+
self._models_last_error = None
276+
log.debug(
277+
"Using models from disk cache for %s",
278+
self.__class__.__name__,
279+
)
98280
return self._models_cache
99281
try:
282+
log.debug(
283+
"Loading models from provider source for %s",
284+
self.__class__.__name__,
285+
)
100286
models = self._load_models()
287+
try:
288+
self._write_models_disk_cache(models, now)
289+
except OSError as write_exc:
290+
log.warning("Failed writing models disk cache: %s", write_exc)
101291
except Exception as exc:
102292
log.warning(
103293
"Failed to refresh models for %s: %s",
@@ -108,10 +298,24 @@ def models(self) -> list[ProviderAIModel]:
108298
self._models_last_error = str(exc)
109299
if self._models_cache is not None:
110300
return self._models_cache
301+
stale_disk_cache = self._read_models_disk_cache(
302+
now,
303+
ttl_seconds,
304+
allow_stale=True,
305+
max_stale_seconds=max_stale_seconds,
306+
)
307+
if stale_disk_cache is not None:
308+
models, cached_at = stale_disk_cache
309+
with self._models_cache_lock:
310+
self._set_models_ram_cache(models, cached_at)
311+
log.debug(
312+
"Using stale models from disk cache fallback for %s",
313+
self.__class__.__name__,
314+
)
315+
return self._models_cache
111316
return []
112317
with self._models_cache_lock:
113-
self._models_cache = models
114-
self._models_cached_at = now
318+
self._set_models_ram_cache(models, now)
115319
self._models_last_error = None
116320
return self._models_cache
117321

@@ -144,6 +348,7 @@ def invalidate_models_cache(self) -> None:
144348
self._models_cache = None
145349
self._models_cached_at = None
146350
self._models_last_error = None
351+
self._delete_cache_file(self._models_cache_file_path)
147352

148353
@abstractmethod
149354
def prepare_message_request(self, message: Message) -> Any:

0 commit comments

Comments
 (0)