Skip to content

Commit 4f260b2

Browse files
leszkoclaude
andauthored
perf: optimize resolve workflow and plugin listing (#857)
## Summary - **Skip update checks in resolve_workflow**: `resolve_workflow()` only needs plugin `name` and `version`, not update availability. Added `skip_update_check` parameter to `list_plugins_sync()` — resolve goes from N × (up to 60s) subprocess calls to near-instant. - **TTL cache on `_check_plugin_update()`**: Results are cached for 10 minutes so repeated Nodes panel opens or plugin listings reuse cached results instead of spawning `uv pip compile` subprocesses. - **Pre-warm cache at startup**: A background task calls `list_plugins_sync()` during app startup so the cache is warm by the time the user first opens Nodes. - **Cache invalidation**: `_invalidate_plugin_caches()` (called on install/uninstall) also clears the update check TTL cache. ## Test plan - [ ] Open the app, click "Nodes" — should load quickly (cache warmed at startup) - [ ] Import a workflow via "Resolve Workflow" — should resolve near-instantly - [ ] Install/uninstall a plugin, then reopen Nodes — should show fresh update info - [ ] Wait 10+ minutes and reopen Nodes — should re-fetch update info (TTL expired) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Rafał Leszko <rafal@livepeer.org> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5895e8e commit 4f260b2

3 files changed

Lines changed: 76 additions & 11 deletions

File tree

src/scope/core/plugins/manager.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import sys
1010
import threading
11+
import time
1112
from dataclasses import dataclass
1213
from pathlib import Path
1314
from typing import TYPE_CHECKING, Any
@@ -113,6 +114,17 @@ def __init__(self):
113114
# Cache for bundled plugin names (file is immutable at runtime)
114115
self._bundled_package_names: set[str] | None = None
115116

117+
# TTL cache for plugin update checks: {name: (result_dict, timestamp)}
118+
self._update_check_cache: dict[str, tuple[dict[str, Any], float]] = {}
119+
self._update_check_ttl: float = 600.0 # 10 minutes
120+
121+
def clear_update_check_cache(self) -> None:
122+
"""Clear the TTL cache for plugin update checks.
123+
124+
Should be called after plugin install/uninstall/upgrade.
125+
"""
126+
self._update_check_cache.clear()
127+
116128
def _read_plugins_file(self) -> list[str]:
117129
"""Read plugin specifiers from plugins.txt."""
118130
plugins_file = get_plugins_file()
@@ -665,16 +677,22 @@ def _get_plugin_source(self, dist: Any) -> tuple[str, bool, str | None, str | No
665677
# Default to PyPI
666678
return ("pypi", False, None, None)
667679

668-
async def list_plugins_async(self) -> list[dict[str, Any]]:
680+
async def list_plugins_async(
681+
self, *, skip_update_check: bool = False
682+
) -> list[dict[str, Any]]:
669683
"""Get all installed plugins with metadata.
670684
671685
Returns:
672686
List of plugin info dictionaries
673687
"""
674688
loop = asyncio.get_event_loop()
675-
return await loop.run_in_executor(None, self.list_plugins_sync)
689+
return await loop.run_in_executor(
690+
None, lambda: self.list_plugins_sync(skip_update_check=skip_update_check)
691+
)
676692

677-
def list_plugins_sync(self) -> list[dict[str, Any]]:
693+
def list_plugins_sync(
694+
self, *, skip_update_check: bool = False
695+
) -> list[dict[str, Any]]:
678696
"""Synchronous implementation of list_plugins."""
679697
from importlib.metadata import distributions
680698

@@ -722,7 +740,7 @@ def list_plugins_sync(self) -> list[dict[str, Any]]:
722740
)
723741

724742
# Check for updates (skip local/editable plugins)
725-
if source == "local" or editable:
743+
if skip_update_check or source == "local" or editable:
726744
latest_version = None
727745
update_available = None
728746
else:
@@ -779,6 +797,9 @@ def _check_plugin_update(
779797
Compares current resolved.txt with a fresh compile using --upgrade-package
780798
to find if a newer version is available that respects project constraints.
781799
800+
Results are cached for ``_update_check_ttl`` seconds to avoid repeated
801+
expensive subprocess calls.
802+
782803
Args:
783804
name: Package name (used for version lookup)
784805
package_spec: Package specifier (not used in compile approach, kept for API compat)
@@ -788,14 +809,25 @@ def _check_plugin_update(
788809
"""
789810
import tempfile
790811

812+
# Return cached result if still fresh
813+
cached = self._update_check_cache.get(name)
814+
if cached is not None:
815+
result_dict, timestamp = cached
816+
if time.monotonic() - timestamp < self._update_check_ttl:
817+
return result_dict
818+
791819
resolved_file = get_resolved_file()
792820

793821
# Get current version from resolved.txt (if it exists)
794822
current_version = self._get_version_from_resolved(name, str(resolved_file))
795823

824+
def _cache_and_return(r: dict[str, Any]) -> dict[str, Any]:
825+
self._update_check_cache[name] = (r, time.monotonic())
826+
return r
827+
796828
# If no resolved file exists, we can't check for updates via compile
797829
if not resolved_file.exists():
798-
return {"latest_version": None, "update_available": None}
830+
return _cache_and_return({"latest_version": None, "update_available": None})
799831

800832
# Create temp file for upgrade check
801833
try:
@@ -809,7 +841,9 @@ def _check_plugin_update(
809841
pyproject = project_root / "pyproject.toml"
810842

811843
if not pyproject.exists():
812-
return {"latest_version": None, "update_available": None}
844+
return _cache_and_return(
845+
{"latest_version": None, "update_available": None}
846+
)
813847

814848
args = [
815849
"uv",
@@ -841,19 +875,25 @@ def _check_plugin_update(
841875
)
842876

843877
if result.returncode != 0:
844-
return {"latest_version": None, "update_available": None}
878+
return _cache_and_return(
879+
{"latest_version": None, "update_available": None}
880+
)
845881

846882
# Get new version from temp resolved file
847883
new_version = self._get_version_from_resolved(name, temp_resolved)
848884

849885
if new_version and new_version != current_version:
850-
return {"latest_version": new_version, "update_available": True}
886+
return _cache_and_return(
887+
{"latest_version": new_version, "update_available": True}
888+
)
851889

852-
return {"latest_version": None, "update_available": False}
890+
return _cache_and_return(
891+
{"latest_version": None, "update_available": False}
892+
)
853893

854894
except Exception as e:
855895
logger.warning(f"Failed to check updates for {name}: {e}")
856-
return {"latest_version": None, "update_available": None}
896+
return _cache_and_return({"latest_version": None, "update_available": None})
857897
finally:
858898
Path(temp_resolved).unlink(missing_ok=True)
859899

src/scope/core/workflows/resolve.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def resolve_workflow(
285285
all_pipelines_ok = True
286286

287287
try:
288-
plugins = plugin_manager.list_plugins_sync()
288+
plugins = plugin_manager.list_plugins_sync(skip_update_check=True)
289289
except Exception:
290290
logger.warning(
291291
"Failed to list plugins; treating all plugins as missing", exc_info=True

src/scope/server/app.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ def _invalidate_plugin_caches():
131131
_pipeline_schemas_cache = None
132132
_plugins_list_cache = None
133133

134+
# Also clear the plugin manager's per-plugin update check TTL cache
135+
try:
136+
from scope.core.plugins import get_plugin_manager
137+
138+
get_plugin_manager().clear_update_check_cache()
139+
except Exception:
140+
pass
141+
134142

135143
class STUNErrorFilter(logging.Filter):
136144
"""Filter to suppress STUN/TURN connection errors that are not critical."""
@@ -328,6 +336,19 @@ async def prewarm_pipeline(pipeline_id: str):
328336
logger.error(f"Error pre-warming pipeline {pipeline_id} in background: {e}")
329337

330338

339+
async def _prewarm_plugin_update_cache():
340+
"""Background task to warm the plugin update check cache at startup."""
341+
try:
342+
from scope.core.plugins import get_plugin_manager
343+
344+
pm = get_plugin_manager()
345+
loop = asyncio.get_event_loop()
346+
await loop.run_in_executor(None, pm.list_plugins_sync)
347+
logger.info("Plugin update check cache warmed")
348+
except Exception as e:
349+
logger.debug(f"Plugin update cache warm-up skipped: {e}")
350+
351+
331352
@asynccontextmanager
332353
async def lifespan(app: FastAPI):
333354
"""Lifespan handler for startup and shutdown events."""
@@ -384,6 +405,10 @@ async def lifespan(app: FastAPI):
384405
if PIPELINE is not None:
385406
asyncio.create_task(prewarm_pipeline(PIPELINE))
386407

408+
# Pre-warm the plugin update check cache in the background so the first
409+
# "Nodes" / resolve-workflow call doesn't block on PyPI lookups.
410+
asyncio.create_task(_prewarm_plugin_update_cache())
411+
387412
webrtc_manager = WebRTCManager()
388413
logger.info("WebRTC manager initialized")
389414

0 commit comments

Comments
 (0)