perf(store): cache GitHub API calls and eliminate redundant requests (#251)

The plugin store was making excessive GitHub API calls causing slow
page loads (10-30s):

- Installed plugins endpoint called get_plugin_info() per plugin (3
  GitHub API calls each) just to read the `verified` field from the
  registry. Use new get_registry_info() instead (zero API calls).
- _get_latest_commit_info() had no cache — all 31 monorepo plugins
  share the same repo URL, causing 31 identical API calls. Add 5-min
  cache keyed by repo:branch.
- _fetch_manifest_from_github() also uncached — add 5-min cache.
- load_config() called inside loop per-plugin — hoist outside loop.
- Install/update operations pass force_refresh=True to bypass caches
  and always get the latest commit SHA from GitHub.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-15 14:46:31 -05:00
committed by GitHub
parent 5b0ad5ab71
commit 22c495ea7c
3 changed files with 75 additions and 22 deletions

View File

@@ -54,6 +54,10 @@ class PluginStoreManager:
self.github_cache = {} # Cache for GitHub API responses self.github_cache = {} # Cache for GitHub API responses
self.cache_timeout = 3600 # 1 hour cache timeout self.cache_timeout = 3600 # 1 hour cache timeout
self.registry_cache_timeout = 300 # 5 minutes for registry cache self.registry_cache_timeout = 300 # 5 minutes for registry cache
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
self.manifest_cache_timeout = 300 # 5 minutes
self.github_token = self._load_github_token() self.github_token = self._load_github_token()
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)} self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
@@ -576,7 +580,7 @@ class PluginStoreManager:
return results return results
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json") -> Optional[Dict]: def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
""" """
Fetch manifest.json directly from a GitHub repository. Fetch manifest.json directly from a GitHub repository.
@@ -585,6 +589,7 @@ class PluginStoreManager:
branch: Branch name (default: master) branch: Branch name (default: master)
manifest_path: Path to manifest within the repo (default: manifest.json). manifest_path: Path to manifest within the repo (default: manifest.json).
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json". For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
force_refresh: If True, bypass the cache.
Returns: Returns:
Manifest data or None if not found Manifest data or None if not found
@@ -603,24 +608,38 @@ class PluginStoreManager:
owner = parts[-2] owner = parts[-2]
repo = parts[-1] repo = parts[-1]
# Check cache first
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
if not force_refresh and cache_key in self.manifest_cache:
cached_time, cached_data = self.manifest_cache[cache_key]
if time.time() - cached_time < self.manifest_cache_timeout:
return cached_data
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}" raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10) response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
return response.json() result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
elif response.status_code == 404: elif response.status_code == 404:
# Try main branch instead # Try main branch instead
if branch != "main": if branch != "main":
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}" raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10) response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
return response.json() result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
# Cache negative result
self.manifest_cache[cache_key] = (time.time(), None)
except Exception as e: except Exception as e:
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}") self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
return None return None
def _get_latest_commit_info(self, repo_url: str, branch: str = "main") -> Optional[Dict[str, Any]]: def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
"""Return metadata about the latest commit on the given branch.""" """Return metadata about the latest commit on the given branch."""
try: try:
if 'github.com' not in repo_url: if 'github.com' not in repo_url:
@@ -637,6 +656,13 @@ class PluginStoreManager:
owner = parts[-2] owner = parts[-2]
repo = parts[-1] repo = parts[-1]
# Check cache first
cache_key = f"{owner}/{repo}:{branch}"
if not force_refresh and cache_key in self.commit_info_cache:
cached_time, cached_data = self.commit_info_cache[cache_key]
if time.time() - cached_time < self.commit_cache_timeout:
return cached_data
branches_to_try = self._distinct_sequence([branch, 'main', 'master']) branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
headers = { headers = {
@@ -659,7 +685,7 @@ class PluginStoreManager:
commit_author = commit_meta.get('author', {}) commit_author = commit_meta.get('author', {})
commit_date_iso = commit_author.get('date', '') commit_date_iso = commit_author.get('date', '')
return { result = {
'branch': branch_name, 'branch': branch_name,
'sha': commit_sha_full, 'sha': commit_sha_full,
'short_sha': commit_sha_short, 'short_sha': commit_sha_short,
@@ -668,6 +694,8 @@ class PluginStoreManager:
'author': commit_author.get('name', ''), 'author': commit_author.get('name', ''),
'message': commit_meta.get('message', ''), 'message': commit_meta.get('message', ''),
} }
self.commit_info_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 403 and not self.github_token: if response.status_code == 403 and not self.github_token:
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.") self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
@@ -678,33 +706,37 @@ class PluginStoreManager:
if last_error: if last_error:
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}") self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
# Cache negative result to avoid repeated failing calls
self.commit_info_cache[cache_key] = (time.time(), None)
except Exception as e: except Exception as e:
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}") self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
return None return None
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True) -> Optional[Dict]: def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
""" """
Get detailed information about a plugin from the registry. Get detailed information about a plugin from the registry.
GitHub provides authoritative metadata such as stars and the latest GitHub provides authoritative metadata such as stars and the latest
commit. The registry supplies descriptive information (name, id, repo URL). commit. The registry supplies descriptive information (name, id, repo URL).
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
fetch_latest_from_github: If True (default), augment with GitHub commit metadata. fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
force_refresh: If True, bypass caches for commit/manifest data.
Returns: Returns:
Plugin metadata or None if not found Plugin metadata or None if not found
""" """
registry = self.fetch_registry() registry = self.fetch_registry()
plugins = registry.get('plugins', []) or [] plugins = registry.get('plugins', []) or []
plugin_info = next((p for p in plugins if p['id'] == plugin_id), None) plugin_info = next((p for p in plugins if p['id'] == plugin_id), None)
if not plugin_info: if not plugin_info:
return None return None
if fetch_latest_from_github: if fetch_latest_from_github:
repo_url = plugin_info.get('repo') repo_url = plugin_info.get('repo')
if repo_url: if repo_url:
@@ -718,7 +750,7 @@ class PluginStoreManager:
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated')) plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso')) plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
commit_info = self._get_latest_commit_info(repo_url, branch) commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
if commit_info: if commit_info:
plugin_info['last_commit'] = commit_info.get('short_sha') plugin_info['last_commit'] = commit_info.get('short_sha')
plugin_info['last_commit_sha'] = commit_info.get('sha') plugin_info['last_commit_sha'] = commit_info.get('sha')
@@ -731,14 +763,31 @@ class PluginStoreManager:
plugin_subpath = plugin_info.get('plugin_path', '') plugin_subpath = plugin_info.get('plugin_path', '')
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json" manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel) github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
if github_manifest: if github_manifest:
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'): if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
plugin_info['last_updated'] = github_manifest['last_updated'] plugin_info['last_updated'] = github_manifest['last_updated']
if 'description' in github_manifest: if 'description' in github_manifest:
plugin_info['description'] = github_manifest['description'] plugin_info['description'] = github_manifest['description']
return plugin_info return plugin_info
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Args:
plugin_id: Plugin identifier
Returns:
Plugin metadata from registry or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return next((p for p in plugins if p.get('id') == plugin_id), None)
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool: def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
""" """
@@ -754,7 +803,7 @@ class PluginStoreManager:
branch_info = f" (branch: {branch})" if branch else " (latest branch head)" branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}") self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
if not plugin_info: if not plugin_info:
self.logger.error(f"Plugin not found in registry: {plugin_id}") self.logger.error(f"Plugin not found in registry: {plugin_id}")
return False return False
@@ -1721,7 +1770,7 @@ class PluginStoreManager:
# Try to get remote info from registry (optional) # Try to get remote info from registry (optional)
self.fetch_registry(force_refresh=True) self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
remote_branch = None remote_branch = None
remote_sha = None remote_sha = None
@@ -1993,7 +2042,7 @@ class PluginStoreManager:
# Try registry-based update # Try registry-based update
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...") self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
self.fetch_registry(force_refresh=True) self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# If not in registry but we have a repo URL, try reinstalling from that URL # If not in registry but we have a repo URL, try reinstalling from that URL
if not plugin_info_remote and repo_url: if not plugin_info_remote and repo_url:

View File

@@ -1737,6 +1737,9 @@ def get_installed_plugins():
# Get all installed plugin info from the plugin manager # Get all installed plugin info from the plugin manager
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info() all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
# Load config once before the loop (not per-plugin)
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
# Format for the web interface # Format for the web interface
plugins = [] plugins = []
for plugin_info in all_plugin_info: for plugin_info in all_plugin_info:
@@ -1758,7 +1761,6 @@ def get_installed_plugins():
# Read from config file first, fall back to plugin instance if config doesn't have the key # Read from config file first, fall back to plugin instance if config doesn't have the key
enabled = None enabled = None
if api_v3.config_manager: if api_v3.config_manager:
full_config = api_v3.config_manager.load_config()
plugin_config = full_config.get(plugin_id, {}) plugin_config = full_config.get(plugin_id, {})
# Check if 'enabled' key exists in config (even if False) # Check if 'enabled' key exists in config (even if False)
if 'enabled' in plugin_config: if 'enabled' in plugin_config:
@@ -1773,8 +1775,8 @@ def get_installed_plugins():
# Default to True if no config key and plugin not loaded (matches BasePlugin default) # Default to True if no config key and plugin not loaded (matches BasePlugin default)
enabled = True enabled = True
# Get verified status from store registry (if available) # Get verified status from store registry (no GitHub API calls needed)
store_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False verified = store_info.get('verified', False) if store_info else False
# Get local git info for installed plugin (actual installed commit) # Get local git info for installed plugin (actual installed commit)

View File

@@ -186,6 +186,9 @@ def _load_plugins_partial():
# Get all installed plugin info # Get all installed plugin info
all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info() all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info()
# Load config once before the loop (not per-plugin)
full_config = pages_v3.config_manager.load_config() if pages_v3.config_manager else {}
# Format for the web interface # Format for the web interface
for plugin_info in all_plugin_info: for plugin_info in all_plugin_info:
plugin_id = plugin_info.get('id') plugin_id = plugin_info.get('id')
@@ -206,7 +209,6 @@ def _load_plugins_partial():
# Read from config file first, fall back to plugin instance if config doesn't have the key # Read from config file first, fall back to plugin instance if config doesn't have the key
enabled = None enabled = None
if pages_v3.config_manager: if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config()
plugin_config = full_config.get(plugin_id, {}) plugin_config = full_config.get(plugin_id, {})
# Check if 'enabled' key exists in config (even if False) # Check if 'enabled' key exists in config (even if False)
if 'enabled' in plugin_config: if 'enabled' in plugin_config:
@@ -221,8 +223,8 @@ def _load_plugins_partial():
# Default to True if no config key and plugin not loaded (matches BasePlugin default) # Default to True if no config key and plugin not loaded (matches BasePlugin default)
enabled = True enabled = True
# Get verified status from store registry # Get verified status from store registry (no GitHub API calls needed)
store_info = pages_v3.plugin_store_manager.get_plugin_info(plugin_id) store_info = pages_v3.plugin_store_manager.get_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False verified = store_info.get('verified', False) if store_info else False
last_updated = plugin_info.get('last_updated') last_updated = plugin_info.get('last_updated')