# Spotify Client Caching Implementation Plan ## Problem Statement Currently, the integration searches for the Spotify entity on **every service call**: - Iterates through all media player entities (O(n) operation) - Looks up entity object from component - Accesses coordinator and client - Repeats this 100% of the time, even though the result rarely changes ## Solution: Intelligent Caching Cache the Spotify client reference after first successful lookup, with smart invalidation. ## Implementation Design ### 1. Cache Structure ```python # Module-level cache (survives across service calls) _spotify_cache = { "client": None, # SpotifyClient instance "entity_id": None, # Entity ID for monitoring "last_validated": None, # Timestamp of last validation } ``` ### 2. Cache Lifecycle ``` First Call: → Cache Miss → Lookup entity (slow) → Store client + entity_id → Return client Subsequent Calls: → Cache Hit → Validate entity still exists (fast) → Return cached client Entity Removed: → Validation fails → Clear cache → Re-lookup on next call ``` ### 3. Validation Strategy **Fast validation** - Check if entity still exists: ```python # Very fast - just checks if entity_id exists in state registry if spotify_entity_id not in hass.states.async_entity_ids(): # Entity was removed, invalidate cache _spotify_cache.clear() ``` **When to validate:** - Option A: Every call (minimal overhead, ~0.1ms) - Option B: Every N seconds (e.g., 60s) - **Recommended: Option A** - Simple and fast enough ### 4. Cache Invalidation Triggers **Automatic:** 1. Entity no longer exists (validation check) 2. Integration reload/restart (cache is module-level, cleared on reload) **Manual (Future):** 1. Service call: `spotify_search.clear_cache` 2. HA restart (automatic) 3. Spotify integration reload (automatic via entity disappearance) ## Implementation Code ### Version 1: Simple (Recommended for v1.1.0) ```python """Spotify Search Integration for Home Assistant.""" import logging from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "spotify_search" # Cache Spotify client to avoid repeated lookups _spotify_cache = { "client": None, "entity_id": None, } async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Spotify Search component.""" async def get_spotify_client(): """Get Spotify client with caching and validation.""" # Validate cache if it exists if _spotify_cache["client"] is not None: # Fast check: Does entity still exist? if _spotify_cache["entity_id"] in hass.states.async_entity_ids(): _LOGGER.debug("Using cached Spotify client") return _spotify_cache["client"] else: _LOGGER.info("Cached Spotify entity no longer exists, invalidating cache") _spotify_cache.clear() # Cache miss or invalidated - do full lookup _LOGGER.debug("Cache miss, performing Spotify entity lookup") # Find Spotify media player entity spotify_entity_id = None for state in hass.states.async_all("media_player"): if "spotify" in state.entity_id.lower(): spotify_entity_id = state.entity_id break if not spotify_entity_id: raise LookupError("No Spotify media player entity found") # Get entity component entity_component = hass.data.get("entity_components", {}).get("media_player") if not entity_component: raise LookupError("Media player component not available") # Find Spotify entity object spotify_entity = None for entity in entity_component.entities: if entity.entity_id == spotify_entity_id: spotify_entity = entity break if not spotify_entity: raise LookupError(f"Spotify entity {spotify_entity_id} not found") # Get client from coordinator if not hasattr(spotify_entity, "coordinator"): raise AttributeError("Spotify entity missing coordinator") coordinator = spotify_entity.coordinator if not hasattr(coordinator, "client"): raise AttributeError("Coordinator missing client") client = coordinator.client # Cache for future calls _spotify_cache["client"] = client _spotify_cache["entity_id"] = spotify_entity_id _LOGGER.info("Cached Spotify client for entity: %s", spotify_entity_id) return client async def search_spotify(call: ServiceCall): """Search Spotify and return the first result's URI.""" query = call.data.get("query") search_type = call.data.get("type", "artist") if not query: _LOGGER.error("No query provided") return {"error": "No query provided"} try: client = await get_spotify_client() except (LookupError, AttributeError) as err: _LOGGER.error("Failed to get Spotify client: %s", err) return {"error": str(err)} # ... rest of search logic unchanged ... ``` ### Version 2: Time-Based (Alternative) Add timestamp-based validation to reduce checks: ```python import time _spotify_cache = { "client": None, "entity_id": None, "validated_at": 0, } VALIDATION_INTERVAL = 60 # Seconds between validations async def get_spotify_client(): """Get Spotify client with time-based cache validation.""" now = time.time() if _spotify_cache["client"] is not None: # Only validate every N seconds if now - _spotify_cache["validated_at"] < VALIDATION_INTERVAL: return _spotify_cache["client"] # Time to validate if _spotify_cache["entity_id"] in hass.states.async_entity_ids(): _spotify_cache["validated_at"] = now return _spotify_cache["client"] else: # Entity gone, clear cache _spotify_cache.clear() # ... lookup logic ... _spotify_cache["validated_at"] = now ``` ## Cache Clearing Service (Optional) Add manual cache clearing for troubleshooting: ```python async def clear_cache(call: ServiceCall): """Clear Spotify client cache.""" if _spotify_cache["client"] is not None: _LOGGER.info("Manually clearing Spotify client cache") _spotify_cache.clear() return {"success": True, "message": "Cache cleared"} else: return {"success": False, "message": "Cache was already empty"} # Register both services hass.services.async_register(DOMAIN, "search", search_spotify, supports_response="only") hass.services.async_register(DOMAIN, "clear_cache", clear_cache, supports_response="only") ``` Add to `services.yaml`: ```yaml clear_cache: name: Clear Cache description: Clear the cached Spotify client. Use this if you're experiencing issues after removing/re-adding the Spotify integration. ``` ## Performance Impact ### Before Caching ``` Each service call: - Iterate all media_player entities: ~1-5ms (depends on count) - Lookup entity object: ~0.5ms - Access coordinator: ~0.1ms Total: ~1.6-5.6ms per call ``` ### After Caching (V1 - Simple) ``` First call: - Same as before: ~1.6-5.6ms Subsequent calls: - Check entity exists: ~0.1ms - Return cached client: ~0.01ms Total: ~0.11ms per call Speedup: 15-50x faster ``` ### After Caching (V2 - Time-Based) ``` First call: ~1.6-5.6ms Calls within 60s: ~0.01ms (no validation) Validation call: ~0.11ms Average: ~0.01-0.02ms per call Speedup: 80-280x faster ``` ## Recommendation ### For v1.1.0: **Version 1 (Simple Validation)** **Pros:** - Simple implementation - Always validates (safer) - Validation is fast enough (~0.1ms) - No time tracking needed - Easy to understand **Cons:** - Validates on every call (tiny overhead) ### For v1.2.0+: **Version 2 (Time-Based)** - If needed Only if profiling shows validation is a bottleneck (unlikely). ## Testing Plan ### Unit Tests (Future) ```python async def test_cache_hit(): """Test that second call uses cache.""" # First call client1 = await get_spotify_client() # Second call should use cache client2 = await get_spotify_client() assert client1 is client2 # Same object reference async def test_cache_invalidation(): """Test cache clears when entity removed.""" # Get client (cached) client1 = await get_spotify_client() # Remove Spotify entity hass.states.async_remove("media_player.spotify_chad") # Next call should re-lookup and fail with pytest.raises(LookupError): await get_spotify_client() ``` ### Manual Testing 1. Call service twice, check logs for "Using cached Spotify client" 2. Remove Spotify integration, verify cache invalidates 3. Re-add Spotify integration, verify it re-caches 4. Restart HA, verify cache clears (new process) ## Migration Path ### v1.0.0 → v1.1.0 - Add caching (no breaking changes) - Add cache clearing service (optional) - Update CHANGELOG ### Code Changes Required - Modify `async_setup()` to add `get_spotify_client()` helper - Replace entity lookup in `search_spotify()` with `await get_spotify_client()` - Add module-level `_spotify_cache` dict - Optional: Add `clear_cache` service ### Lines Changed: ~30 lines - Remove: ~15 lines (inline lookup) - Add: ~30 lines (cached lookup function) - Modify: ~5 lines (call new function) **Net: ~20 line increase for 15-50x performance improvement** ## Security Considerations **Is caching safe?** - ✅ Client object doesn't contain credentials (OAuth tokens are in HA core) - ✅ Module-level cache is process-scoped (isolated per HA instance) - ✅ Cache invalidates when entity removed - ✅ No user data in cache - ✅ No cross-user data leakage (single-user system) **Risks:** - ⚠️ If Spotify integration is removed and re-added quickly, cache might use old client - Mitigation: Validation check catches this (entity_id won't match) ## Rollout Strategy 1. **v1.1.0-beta**: Release with caching, ask users to test 2. Monitor for issues (GitHub issues) 3. If stable after 2 weeks → **v1.1.0 stable** 4. Add to CHANGELOG with performance notes ## Documentation Updates ### README.md ```markdown ## Performance The integration caches the Spotify client reference after first use for optimal performance: - First search: ~5ms - Subsequent searches: ~0.1ms (50x faster) The cache automatically invalidates if the Spotify integration is removed or reloaded. ``` ### DEVELOPMENT.md ```markdown ## Caching The integration caches the Spotify client to avoid repeated entity lookups. To manually clear the cache: ```yaml service: spotify_search.clear_cache ``` This is rarely needed but useful for troubleshooting. ``` ## Summary **Recommended Implementation: Version 1 (Simple Validation)** - Cache client after first lookup - Validate entity exists on every call (~0.1ms overhead) - Automatic invalidation when entity removed - Optional manual cache clearing service - 15-50x performance improvement - Simple, safe, effective **Estimated effort:** 1-2 hours development + testing **Risk level:** Low **Performance gain:** High