avoigt1121 Claude Opus 4.8 commited on
Commit
908cfde
·
1 Parent(s): 2d435a4

perf(mcp): wire persistent HTTP MCP server + in-memory adata cache + step timing

Browse files

Eliminates the per-tool-call subprocess spawn that dominated specialist
latency (~44s/step). The HTTP path existed in-tree but had zero call sites.

- GradioAgentUI.__init__ starts the resident `server.py --transport http`
once per container and registers tools via add_mcp_http(url) in _prewarm
and the per-session rebuild; stdio kept as a health-checked fallback.
- Add agent.add_mcp_http / tool_manager.add_mcp_http_server delegating to
mcp_manager.add_mcp_http; fix its discovery to normalize MCP Tool objects
to dicts (was crashing _process_remote_server with 'Tool' has no 'get').
- Add process-lifetime in-memory AnnData cache (cache.read_h5ad_cached),
keyed by path+mtime+size, returns .copy() so it's safe under the shared
resident server / concurrent sessions; wire into hot DE-path reads.
- Add src/core/perf.py [perf] instrumentation on generate/execute and each
MCP tool call so the win is measurable in logs.

Verified locally: 52 tools register over HTTP, tool calls 26-43ms (vs tens
of seconds stdio), resident process count stays 1, zero stdio spawns. Full
suite 947 passed/55 skipped + new tests/test_inmemory_adata_cache.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/core/perf.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight per-step performance instrumentation.
2
+
3
+ Cheap timing hooks so the latency win from the persistent HTTP MCP server
4
+ (no per-tool-call subprocess spawn) is measurable rather than anecdotal.
5
+
6
+ Two things are timed:
7
+ - agent generate vs execute phases (src/agent.py)
8
+ - each MCP tool call (src/managers/tools/mcp_manager.py)
9
+
10
+ Each event is printed with a ``[perf]`` prefix (visible in HF Space logs) and
11
+ appended to a bounded module-level ring buffer so a test or a trace dump can
12
+ read the timings back without scraping stdout.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections import deque
18
+ from contextlib import contextmanager
19
+ from typing import Deque, Dict
20
+
21
+ # Bounded so a long-running container can't grow this unboundedly.
22
+ _EVENTS: Deque[Dict] = deque(maxlen=2000)
23
+
24
+
25
+ def record(kind: str, name: str, seconds: float) -> None:
26
+ """Record a single timed event and echo it to stdout."""
27
+ seconds = round(seconds, 3)
28
+ _EVENTS.append({"kind": kind, "name": name, "seconds": seconds, "t": time.time()})
29
+ print(f"[perf] {kind} {name}: {seconds}s", flush=True)
30
+
31
+
32
+ @contextmanager
33
+ def timed(kind: str, name: str):
34
+ """Context manager that records monotonic wall time of the block."""
35
+ t0 = time.monotonic()
36
+ try:
37
+ yield
38
+ finally:
39
+ record(kind, name, time.monotonic() - t0)
40
+
41
+
42
+ def get_events() -> list:
43
+ """Return a snapshot of recorded events (oldest first)."""
44
+ return list(_EVENTS)
45
+
46
+
47
+ def reset() -> None:
48
+ """Clear recorded events (used by tests)."""
49
+ _EVENTS.clear()
tests/test_inmemory_adata_cache.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the process-lifetime in-memory AnnData cache (src/cache.py).
2
+
3
+ These guard the two correctness properties the persistent HTTP server relies on:
4
+ 1. Each read returns an independent copy, so a caller mutating (or writing
5
+ back) the result cannot corrupt the shared, read-mostly cache — this is
6
+ what makes one resident server safe to share across concurrent sessions.
7
+ 2. A rewritten file (newer mtime/size) is transparently re-read instead of
8
+ serving a stale parse.
9
+ """
10
+ import time
11
+
12
+ import numpy as np
13
+ import anndata as ad
14
+ import pytest
15
+
16
+ from src import cache
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _clear_cache():
21
+ cache._MEM_CACHE.clear()
22
+ yield
23
+ cache._MEM_CACHE.clear()
24
+
25
+
26
+ def _write(path, fill):
27
+ a = ad.AnnData(X=np.full((10, 5), float(fill)))
28
+ a.write_h5ad(path)
29
+ return a
30
+
31
+
32
+ def test_second_read_hits_cache_single_entry(tmp_path):
33
+ p = tmp_path / "x.h5ad"
34
+ _write(p, 1)
35
+ cache.read_h5ad_cached(str(p))
36
+ cache.read_h5ad_cached(str(p))
37
+ assert len(cache._MEM_CACHE) == 1 # one entry, reused
38
+
39
+
40
+ def test_returns_independent_copies(tmp_path):
41
+ p = tmp_path / "x.h5ad"
42
+ _write(p, 1)
43
+ a = cache.read_h5ad_cached(str(p))
44
+ b = cache.read_h5ad_cached(str(p))
45
+ assert a is not b
46
+ # Mutating one copy must not affect the cache or other copies.
47
+ a.X[:] = 999
48
+ c = cache.read_h5ad_cached(str(p))
49
+ assert float(c.X.mean()) == 1.0
50
+
51
+
52
+ def test_rewrite_invalidates_stale_entry(tmp_path):
53
+ p = tmp_path / "x.h5ad"
54
+ _write(p, 1)
55
+ assert float(cache.read_h5ad_cached(str(p)).X.mean()) == 1.0
56
+ time.sleep(1.1) # ensure mtime advances at 1s resolution
57
+ _write(p, 7)
58
+ assert float(cache.read_h5ad_cached(str(p)).X.mean()) == 7.0
59
+ # Stale entry for the same path is evicted, not accumulated.
60
+ assert len(cache._MEM_CACHE) == 1
61
+
62
+
63
+ def test_missing_path_falls_through(tmp_path):
64
+ with pytest.raises(Exception):
65
+ cache.read_h5ad_cached(str(tmp_path / "nope.h5ad"))
66
+
67
+
68
+ def test_eviction_bounds_entries(tmp_path):
69
+ for i in range(cache._MEM_MAX_ENTRIES + 3):
70
+ p = tmp_path / f"d{i}.h5ad"
71
+ _write(p, i)
72
+ cache.read_h5ad_cached(str(p))
73
+ assert len(cache._MEM_CACHE) <= cache._MEM_MAX_ENTRIES