music-assistant-server

24.2 KBPY
controller.py
24.2 KB584 lines • python
1"""Cache controller implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import os
7import time
8from collections.abc import AsyncGenerator, Mapping
9from contextlib import asynccontextmanager
10from pathlib import Path
11from typing import TYPE_CHECKING, Any
12
13from music_assistant_models.background_task import TaskSchedule
14from music_assistant_models.config_entries import ConfigActionResult, ConfigEntry
15from music_assistant_models.enums import ConfigEntryType
16
17from music_assistant.constants import (
18    DB_TABLE_CACHE,
19    DB_TABLE_SETTINGS,
20    VACUUM_MIN_RECLAIM_RATIO,
21)
22from music_assistant.controllers.cache.constants import (
23    BYPASS_CACHE,
24    CACHE_DATABASE_CLEANUP_TASK_ID,
25    CONF_CLEAR_CACHE,
26    DB_SCHEMA_VERSION,
27    DEFAULT_CACHE_EXPIRATION,
28    LOGGER,
29    MAX_CACHE_DB_SIZE_MB,
30    SWR_FALLBACK_MAX_AGE,
31)
32from music_assistant.controllers.tasks.context import (
33    update_current_task_progress_text,
34)
35from music_assistant.helpers.database import DatabaseConnection
36from music_assistant.helpers.datetime import local_clock_time_to_utc
37from music_assistant.helpers.json import SerializableType, async_json_loads, json_dumps, json_loads
38from music_assistant.models.core_controller import CoreController
39
40if TYPE_CHECKING:
41    from music_assistant_models.config_entries import CoreConfig
42
43    from music_assistant import MusicAssistant
44
45
46class CacheController(CoreController):
47    """Controller handling caching of data throughout the application."""
48
49    domain: str = "cache"
50
51    def __init__(self, mass: MusicAssistant) -> None:
52        """Initialize core controller."""
53        super().__init__(mass)
54        self.database: DatabaseConnection | None = None
55        self.manifest.name = "Cache controller"
56        self.manifest.description = (
57            "Music Assistant's core controller for caching data throughout the application."
58        )
59        self.manifest.icon = "memory"
60
61    async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
62        """Return all Config Entries for this core module (if any)."""
63        return (
64            ConfigEntry(
65                key=CONF_CLEAR_CACHE,
66                type=ConfigEntryType.ACTION,
67            ),
68        )
69
70    async def handle_config_action(
71        self, action: str
72    ) -> tuple[ConfigEntry, ...] | ConfigActionResult | None:
73        """Handle a one-shot action button press and report its outcome."""
74        if action == CONF_CLEAR_CACHE:
75            await self.clear()
76            return ConfigActionResult(translation_key=f"{CONF_CLEAR_CACHE}.result")
77        return await super().handle_config_action(action)
78
79    async def setup(self, config: CoreConfig) -> None:
80        """Async initialize of cache module."""
81        self.logger.info("Initializing cache controller...")
82        await self._setup_database()
83
84    async def post_setup(self) -> None:
85        """Handle logic after all core controllers have been set up."""
86        self._register_cleanup_task()
87
88    async def close(self) -> None:
89        """Cleanup on exit."""
90        if self.database:
91            await self.database.close()
92
93    async def get_diagnostics(self) -> dict[str, SerializableType]:
94        """Return diagnostics info for this controller to include in diagnostics reports."""
95        return {
96            "db_schema_version": DB_SCHEMA_VERSION,
97            "db_size_mb": round(await self._get_cache_db_size_mb(), 1),
98            "entries": await self.database.get_count(DB_TABLE_CACHE) if self.database else None,
99        }
100
101    async def get(
102        self,
103        key: str,
104        provider: str = "default",
105        category: int = 0,
106        checksum: str | int | None = None,
107        default: Any = None,
108        allow_bypass: bool | None = None,
109        base_class: Any = None,
110        allow_expired_cache: bool = False,
111    ) -> Any:
112        """
113        Get data from cache.
114
115        Returns JSON-deserialized data (dicts, lists, strings, numbers, booleans, None).
116
117        If base_class is provided, the raw data is automatically reconstructed using
118        its from_dict() method. If the cached data is a list of dicts, each item is
119        reconstructed individually.
120
121        :param key: The (unique) lookup key of the cache object.
122        :param provider: Provider id to group cache objects.
123        :param category: Category to group cache objects.
124        :param checksum: If provided, only return data if the stored checksum matches.
125        :param default: Value to return if no cache object is found.
126        :param allow_bypass: Whether to respect the BYPASS_CACHE context variable.
127        :param base_class: If provided, reconstruct data using base_class.from_dict().
128        :param allow_expired_cache: If True, also return entries past their expiration
129            time instead of treating them as cache misses.
130        """
131        data, _, found = await self.get_with_freshness(
132            key,
133            provider=provider,
134            category=category,
135            checksum=checksum,
136            allow_bypass=allow_bypass,
137            base_class=base_class,
138            include_expired=allow_expired_cache,
139        )
140        return data if found else default
141
142    async def get_with_freshness(
143        self,
144        key: str,
145        provider: str = "default",
146        category: int = 0,
147        checksum: str | int | None = None,
148        allow_bypass: bool | None = None,
149        base_class: Any = None,
150        include_expired: bool = False,
151    ) -> tuple[Any, bool, bool]:
152        """
153        Get data from cache together with the freshness and presence of the entry.
154
155        Returns a (data, is_fresh, found) tuple. found is False when there is no usable
156        entry, in which case data is None; is_fresh is False when the entry is expired.
157        Because a stored None value is returned as-is, use the found flag to tell a cache
158        miss from a cached None.
159
160        :param key: The (unique) lookup key of the cache object.
161        :param provider: Provider id to group cache objects.
162        :param category: Category to group cache objects.
163        :param checksum: If provided, only return data if the stored checksum matches.
164        :param allow_bypass: Whether to respect the BYPASS_CACHE context variable.
165        :param base_class: If provided, reconstruct data using base_class.from_dict().
166        :param include_expired: If False (default), an expired entry is reported as not found
167            and is not deserialized; set True to also return expired entries as stale data.
168        """
169        assert self.database is not None
170        assert key, "No key provided"
171        if allow_bypass and BYPASS_CACHE.get():
172            return None, False, False
173        cur_time = int(time.time())
174        if checksum is not None and not isinstance(checksum, str):
175            checksum = str(checksum)
176        if (
177            db_row := await self.database.get_row(
178                DB_TABLE_CACHE, {"category": category, "provider": provider, "key": key}
179            )
180        ) and (not checksum or db_row["checksum"] == checksum):
181            # if allow_bypass is not explicitly set,
182            # determine it based on the 'persistent' flag of the cache entry
183            if allow_bypass is None:
184                allow_bypass = not bool(db_row["persistent"])
185            if allow_bypass and BYPASS_CACHE.get():
186                return None, False, False
187            is_fresh = bool(db_row["expires"] >= cur_time)
188            # skip deserialization for an expired entry the caller will not use
189            if not is_fresh and not include_expired:
190                return None, False, False
191            try:
192                data = await async_json_loads(db_row["data"])
193            except Exception as exc:
194                LOGGER.error(
195                    "Error parsing cache data for %s/%s/%s: %s",
196                    provider,
197                    category,
198                    key,
199                    str(exc),
200                    exc_info=exc if self.logger.isEnabledFor(10) else None,
201                )
202            else:
203                if base_class is not None and data is not None:
204                    if isinstance(data, list):
205                        return [base_class.from_dict(item) for item in data], is_fresh, True
206                    return base_class.from_dict(data), is_fresh, True
207                return data, is_fresh, True
208        return None, False, False
209
210    async def get_all(
211        self,
212        provider: str = "default",
213        category: int = 0,
214        base_class: Any = None,
215    ) -> dict[str, Any]:
216        """
217        Return every non-expired cache entry for a provider/category as a key -> data mapping.
218
219        Use this instead of many individual :meth:`get` calls when a caller needs to check a
220        large number of keys against the cache at once (e.g. while scanning a whole library),
221        since it issues a single query and a single deserialization batch rather than one of
222        each per key.
223
224        :param provider: Provider id to group cache objects.
225        :param category: Category to group cache objects.
226        :param base_class: If provided, reconstruct each entry using base_class.from_dict().
227        """
228        assert self.database is not None
229        cur_time = int(time.time())
230        rows = await self.database.get_rows_from_query(
231            f"SELECT key, data FROM {DB_TABLE_CACHE} "
232            "WHERE category = :category AND provider = :provider AND expires >= :cur_time",
233            {"category": category, "provider": provider, "cur_time": cur_time},
234            limit=0,
235        )
236        # deserialize every row in one thread-pool submission instead of one per row, which
237        # otherwise means thousands of thread submissions/context switches on a large result
238        result = await asyncio.to_thread(self._deserialize_rows, rows, provider, category)
239        if base_class is not None:
240            for key, data in result.items():
241                if data is None:
242                    continue
243                result[key] = (
244                    [base_class.from_dict(item) for item in data]
245                    if isinstance(data, list)
246                    else base_class.from_dict(data)
247                )
248        return result
249
250    async def get_expiration(
251        self,
252        key: str,
253        provider: str = "default",
254        category: int = 0,
255    ) -> int | None:
256        """
257        Return the expiration timestamp (epoch seconds) of a cache entry, if any.
258
259        Cheap existence/freshness probe: only the expiration column is read, the
260        stored data is not. Returns None when no entry exists for the given key.
261
262        :param key: The (unique) lookup key of the cache object.
263        :param provider: Provider id to group cache objects.
264        :param category: Category to group cache objects.
265        """
266        assert self.database is not None
267        assert key, "No key provided"
268        rows = await self.database.get_rows_from_query(
269            f"SELECT expires FROM {DB_TABLE_CACHE} "
270            "WHERE category = :category AND provider = :provider AND key = :key",
271            {"category": category, "provider": provider, "key": key},
272            limit=1,
273        )
274        return int(rows[0]["expires"]) if rows else None
275
276    async def set(
277        self,
278        key: str,
279        data: SerializableType,
280        expiration: int = DEFAULT_CACHE_EXPIRATION,
281        provider: str = "default",
282        category: int = 0,
283        checksum: str | None = None,
284        persistent: bool = False,
285        allow_expired_cache: bool = False,
286    ) -> None:
287        """
288        Store data in cache.
289
290        Data must be JSON-serializable (str, int, float, bool, None, list, dict).
291        Do not pass model objects directly — use .to_dict() first.
292        Non-serializable data will raise TypeError.
293
294        :param key: The (unique) lookup key of the cache object.
295        :param data: JSON-serializable data to store.
296        :param expiration: Time in seconds the cache object should be valid.
297        :param provider: Provider id to group cache objects.
298        :param category: Category to group cache objects.
299        :param checksum: Optional checksum to store with the cache object.
300        :param persistent: If True, the entry survives cache clears.
301        :param allow_expired_cache: If True, the entry survives the auto-cleanup task
302            after it expires, so it can still be served as fallback data by the
303            stale-while-revalidate path of `@use_cache`.
304        """
305        assert self.database is not None
306        if not key:
307            return
308        if checksum is not None:
309            checksum = str(checksum)
310        expires = int(time.time() + expiration)
311        # always serialize to JSON to ensure data is serializable
312        # this raises if the data contains non-serializable objects
313        data = await asyncio.to_thread(json_dumps, data)
314        # upsert (update in place on the UNIQUE(category, key, provider) conflict) instead of
315        # INSERT OR REPLACE, which deletes and re-inserts the row and so rewrites every index
316        await self.database.upsert(
317            DB_TABLE_CACHE,
318            {
319                "category": category,
320                "provider": provider,
321                "key": key,
322                "expires": expires,
323                "checksum": checksum,
324                "data": data,
325                "persistent": persistent,
326                "allow_expired_cache": allow_expired_cache,
327            },
328        )
329
330    async def delete(
331        self, key: str | None, category: int | None = None, provider: str | None = None
332    ) -> None:
333        """Delete data from cache."""
334        assert self.database is not None
335        match: dict[str, str | int] = {}
336        if key is not None:
337            match["key"] = key
338        if category is not None:
339            match["category"] = category
340        if provider is not None:
341            match["provider"] = provider
342        await self.database.delete(DB_TABLE_CACHE, match)
343
344    async def clear(
345        self,
346        key_filter: str | None = None,
347        category_filter: int | None = None,
348        provider_filter: str | None = None,
349        include_persistent: bool = False,
350    ) -> None:
351        """Clear all/partial items from cache."""
352        assert self.database is not None
353        self.logger.info("Clearing database...")
354        query_parts: list[str] = []
355        if category_filter is not None:
356            query_parts.append(f"category = {category_filter}")
357        if provider_filter is not None:
358            query_parts.append(f"provider LIKE '%{provider_filter}%'")
359        if key_filter is not None:
360            query_parts.append(f"key LIKE '%{key_filter}%'")
361        if not include_persistent:
362            query_parts.append("persistent = 0")
363        query = "WHERE " + " AND ".join(query_parts) if query_parts else None
364        await self.database.delete(DB_TABLE_CACHE, query=query)
365        self.logger.info("Clearing database DONE")
366
367    async def auto_cleanup(self) -> None:
368        """Run scheduled auto cleanup task."""
369        assert self.database is not None
370        self.logger.debug("Running automatic cleanup...")
371        update_current_task_progress_text("Removing expired cache records")
372        cur_timestamp = int(time.time())
373        # remove expired entries; allow_expired_cache entries are kept as stale-while-revalidate
374        # fallback, but only until they are expired beyond SWR_FALLBACK_MAX_AGE - past that their
375        # key is clearly no longer requested and the row would otherwise live forever
376        swr_cutoff = cur_timestamp - SWR_FALLBACK_MAX_AGE
377        cursor = await self.database.execute(
378            f"DELETE FROM {DB_TABLE_CACHE} WHERE "
379            "(expires < :timestamp AND allow_expired_cache = 0) "
380            "OR (expires < :swr_cutoff AND allow_expired_cache = 1)",
381            {"timestamp": cur_timestamp, "swr_cutoff": swr_cutoff},
382        )
383        await self.database.commit()
384        cleaned_records = cursor.rowcount
385        update_current_task_progress_text(f"Cleaned up {cleaned_records} expired cache record(s)")
386        self.logger.debug("Automatic cleanup finished (cleaned up %s records)", cleaned_records)
387
388    @asynccontextmanager
389    async def handle_refresh(self, bypass: bool) -> AsyncGenerator[None]:
390        """Handle the cache bypass."""
391        try:
392            token = BYPASS_CACHE.set(bypass)
393            yield None
394        finally:
395            BYPASS_CACHE.reset(token)
396
397    async def _check_oversized_cache(self) -> None:
398        """Warn if the cache database exceeds the recommended max size."""
399        db_size_mb = await self._get_cache_db_size_mb()
400        if db_size_mb > MAX_CACHE_DB_SIZE_MB:
401            self.logger.warning(
402                "Cache database size %.2f MB exceeds recommended maximum of %d MB",
403                db_size_mb,
404                MAX_CACHE_DB_SIZE_MB,
405            )
406
407    def _deserialize_rows(
408        self, rows: list[Mapping[str, Any]], provider: str, category: int
409    ) -> dict[str, Any]:
410        """
411        JSON-deserialize a batch of raw cache rows synchronously, skipping unparsable ones.
412
413        :param rows: Raw ``key``/``data`` rows selected from the cache table.
414        :param provider: Provider id the rows were selected for, used only for error logging.
415        :param category: Category the rows were selected for, used only for error logging.
416        """
417        result: dict[str, Any] = {}
418        for row in rows:
419            try:
420                result[row["key"]] = json_loads(row["data"])
421            except ValueError as exc:
422                LOGGER.error(
423                    "Error parsing cache data for %s/%s/%s: %s",
424                    provider,
425                    category,
426                    row["key"],
427                    str(exc),
428                    exc_info=exc if self.logger.isEnabledFor(10) else None,
429                )
430        return result
431
432    async def _get_cache_db_size_mb(self) -> float:
433        """Return the on-disk size of the cache database (in MB)."""
434        db_path = os.path.join(self.mass.cache_path, "cache.db")
435        # also include the write ahead log and shared memory db files
436        db_files = [db_path + suffix for suffix in ("", "-wal", "-shm")]
437
438        def _get_db_size() -> float:
439            total = 0
440            for path in db_files:
441                if Path(path).exists():
442                    total += Path(path).stat().st_size
443            return total / (1024 * 1024)
444
445        return await asyncio.to_thread(_get_db_size)
446
447    async def _setup_database(self) -> None:
448        """Initialize database."""
449        await self._check_oversized_cache()
450        db_path = os.path.join(self.mass.cache_path, "cache.db")
451        self.database = DatabaseConnection(db_path)
452        await self.database.setup()
453
454        # always create db tables if they don't exist to prevent errors trying to access them later
455        await self.__create_database_tables()
456
457        try:
458            if db_row := await self.database.get_row(DB_TABLE_SETTINGS, {"key": "version"}):
459                prev_version = int(db_row["value"])
460            else:
461                prev_version = 0
462        except KeyError, ValueError:
463            prev_version = 0
464
465        if prev_version not in (0, DB_SCHEMA_VERSION):
466            LOGGER.warning(
467                "Performing database migration from %s to %s",
468                prev_version,
469                DB_SCHEMA_VERSION,
470            )
471            try:
472                await self.__migrate_database(prev_version)
473            except Exception as err:
474                LOGGER.warning("Cache database migration failed: %s, resetting cache", err)
475                await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_CACHE}")
476                await self.__create_database_tables()
477
478        # store current schema version
479        await self.database.insert_or_replace(
480            DB_TABLE_SETTINGS,
481            {"key": "version", "value": str(DB_SCHEMA_VERSION), "type": "str"},
482        )
483        await self.__create_database_indexes()
484
485        # Skip the full rebuild unless a meaningful share of the file can be reclaimed.
486        try:
487            reclaimable_ratio = await self.database.get_reclaimable_ratio()
488            if reclaimable_ratio < VACUUM_MIN_RECLAIM_RATIO:
489                self.logger.debug(
490                    "Skipping database compaction (only %.1f%% reclaimable)",
491                    reclaimable_ratio * 100,
492                )
493            else:
494                self.logger.debug(
495                    "Compacting database (%.1f%% reclaimable)...", reclaimable_ratio * 100
496                )
497                await self.database.vacuum()
498                self.logger.debug("Compacting database done")
499        except Exception as err:
500            self.logger.warning("Database vacuum failed: %s", str(err))
501
502    async def __create_database_tables(self) -> None:
503        """Create database table(s)."""
504        assert self.database is not None
505        await self.database.execute(
506            f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_SETTINGS}(
507                    key TEXT PRIMARY KEY,
508                    value TEXT,
509                    type TEXT
510                );"""
511        )
512        await self.database.execute(
513            f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_CACHE}(
514                    [id] INTEGER PRIMARY KEY AUTOINCREMENT,
515                    [category] INTEGER NOT NULL DEFAULT 0,
516                    [key] TEXT NOT NULL,
517                    [provider] TEXT NOT NULL,
518                    [expires] INTEGER NOT NULL,
519                    [data] TEXT NULL,
520                    [checksum] TEXT NULL,
521                    [persistent] INTEGER NOT NULL DEFAULT 0,
522                    [allow_expired_cache] INTEGER NOT NULL DEFAULT 0,
523                    UNIQUE(category, key, provider)
524                    )"""
525        )
526
527        await self.database.commit()
528
529    async def __create_database_indexes(self) -> None:
530        """Create database indexes."""
531        assert self.database is not None
532        # The UNIQUE(category, key, provider) constraint already provides an index that serves
533        # every point lookup (get() matches exactly those three columns) and any delete that
534        # includes the category. The only access pattern its column order cannot serve is a
535        # delete that filters by (key, provider) without a category, so that is the single
536        # secondary index kept here.
537        await self.database.execute(
538            f"CREATE INDEX IF NOT EXISTS {DB_TABLE_CACHE}_key_provider_idx "
539            f"ON {DB_TABLE_CACHE}(key,provider);"
540        )
541        await self.database.commit()
542
543    async def __migrate_database(self, prev_version: int) -> None:
544        """Perform a database migration."""
545        assert self.database is not None
546        if prev_version <= 6:
547            # clear spotify cache entries to fix bloated cache from playlist pagination bug
548            await self.database.delete(DB_TABLE_CACHE, query="WHERE provider LIKE '%spotify%'")
549        if prev_version <= 7:
550            await self.database.execute(
551                f"ALTER TABLE {DB_TABLE_CACHE} "
552                "ADD COLUMN allow_expired_cache INTEGER NOT NULL DEFAULT 0"
553            )
554        if prev_version <= 8:
555            # drop the redundant secondary indexes: they either duplicate the
556            # UNIQUE(category, key, provider) autoindex or are a left-prefix of it, so the
557            # autoindex already serves their lookups. The (key, provider) index is (re)created
558            # by __create_database_indexes and intentionally kept.
559            for index_name in (
560                "category_idx",
561                "key_idx",
562                "provider_idx",
563                "category_key_idx",
564                "category_provider_idx",
565                "category_key_provider_idx",
566            ):
567                await self.database.execute(f"DROP INDEX IF EXISTS {DB_TABLE_CACHE}_{index_name}")
568        await self.database.commit()
569
570    def _register_cleanup_task(self) -> None:
571        """Register the recurring cache database cleanup task."""
572        utc_hour, utc_minute = local_clock_time_to_utc(4, 0)
573        desired_schedule = TaskSchedule.daily(hour=utc_hour, minute=utc_minute)
574        self.mass.tasks.register_scheduled_task(
575            task_id=CACHE_DATABASE_CLEANUP_TASK_ID,
576            name="Cache database cleanup",
577            handler=self.auto_cleanup,
578            schedule=desired_schedule,
579            translation_key="cache_database_cleanup",
580            translation_owner=self.translation_owner,
581            metadata={"task_domain": "cache_database_cleanup"},
582            allow_retry=True,
583        )
584