File size: 9,170 Bytes
c665dc8 4735089 c665dc8 c3b49d6 c665dc8 98bec0e c665dc8 67cb726 4735089 c665dc8 4735089 c665dc8 67cb726 4735089 c665dc8 c3b49d6 4735089 c665dc8 4735089 c665dc8 c3b49d6 4735089 c665dc8 4735089 c3b49d6 4735089 c665dc8 4735089 c665dc8 4735089 c665dc8 4735089 c665dc8 c3b49d6 4735089 98bec0e c665dc8 4735089 c3b49d6 98bec0e 4735089 98bec0e 4735089 c665dc8 c3b49d6 c665dc8 4735089 c665dc8 c3b49d6 c665dc8 4735089 c665dc8 4735089 c665dc8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | """
Disk-based dataset cache for DecoupleRpy.
WHY DISK INSTEAD OF MEMORY
---------------------------
The MCP server runs as a short-lived subprocess: every tool call spawns a new
`python server.py` process and exits after returning the result. A module-level
memory dict is destroyed at the end of each call.
/tmp on HuggingFace Spaces persists for the lifetime of the container (hours to
days), so caching parsed h5ad files there eliminates the ~50MB GEO download on
all calls after the first.
Cache layout
------------
/tmp/decoupleRpy/cache/
GSE71729.h5ad β parsed AnnData (fast to read_h5ad, no download)
GSE71729.gpl_cache/
GPL14951.pkl β probeβgene mapping dict
"""
from __future__ import annotations
import json
import re
import threading
from pathlib import Path
from typing import Any
CACHE_DIR = Path("/tmp/decoupleRpy/cache")
# ββ In-memory AnnData cache (process-lifetime) βββββββββββββββββββββββββββββββ
# When server.py runs as a persistent HTTP process (`--transport http`), the
# same h5ad path is read on nearly every tool call of a multi-step run. Parsing
# a 50MB h5ad from disk each time is pure waste once the process is long-lived.
# This module-level cache holds the parsed AnnData for the process lifetime,
# keyed by (abspath, mtime, size) so a rewritten file is transparently
# re-read. Each read returns a `.copy()`, so callers may freely mutate (and
# write back) without corrupting the shared, read-mostly cache β this is what
# makes it safe to share one resident server across concurrent Gradio sessions.
# Under stdio transport (one process per call) it simply never gets a second
# hit, so it's a no-op there rather than a correctness risk.
_MEM_LOCK = threading.Lock()
_MEM_CACHE: dict[str, Any] = {}
_MEM_MAX_ENTRIES = 8 # bound memory; bulk h5ads are large
def _mem_key(path: str) -> str | None:
try:
st = Path(path).stat()
except OSError:
return None
return f"{Path(path).resolve()}::{int(st.st_mtime)}::{st.st_size}"
def read_h5ad_cached(path: str) -> Any:
"""Read an h5ad, reusing a process-lifetime in-memory parse when possible.
Returns a fresh `.copy()` every call, so the caller owns the object and may
mutate or write it back without affecting the cache. Falls back to a plain
`sc.read_h5ad` if the path can't be stat'd (e.g. a URL or missing file) so
the caller sees the normal error.
"""
import scanpy as sc
key = _mem_key(path)
if key is None:
return sc.read_h5ad(path)
with _MEM_LOCK:
cached = _MEM_CACHE.get(key)
if cached is not None:
return cached.copy()
adata = sc.read_h5ad(path)
with _MEM_LOCK:
# Drop stale entries for the same resolved path (older mtime/size).
resolved = str(Path(path).resolve())
for k in [k for k in _MEM_CACHE if k.startswith(resolved + "::") and k != key]:
_MEM_CACHE.pop(k, None)
_MEM_CACHE[key] = adata
# Evict oldest if over the bound (dict preserves insertion order).
while len(_MEM_CACHE) > _MEM_MAX_ENTRIES:
_MEM_CACHE.pop(next(iter(_MEM_CACHE)))
return adata.copy()
# ββ Cache key helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def url_to_cache_key(url_or_path: str) -> str:
"""Return a stable GSE accession key from any URL or path string.
Picks the match with the most digits to avoid partial matches like
GSE71 from GSE71nnn/GSE71729."""
matches = re.findall(r"GSE(\d+)", url_or_path, re.IGNORECASE)
if not matches:
return url_or_path
return "GSE" + max(matches, key=len)
# ββ AnnData (dataset) cache ββββββββββββββββββββββββββββββββββββββββββββββββββ
def _dataset_cache_path(cache_key: str) -> Path:
return CACHE_DIR / f"{cache_key}.h5ad"
def get_dataset(cache_key: str) -> Any | None:
"""Return a cached AnnData, or None if not cached."""
path = _dataset_cache_path(cache_key)
if path.exists() and path.stat().st_size > 0:
try:
import scanpy as sc
print(f"[cache] Disk hit for {cache_key} β loading from {path}")
return sc.read_h5ad(path)
except Exception as exc:
print(f"[cache] Disk read failed for {cache_key}: {exc} β will re-download")
path.unlink(missing_ok=True)
return None
def set_dataset(cache_key: str, adata: Any) -> None:
"""Write an AnnData to the disk cache."""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = _dataset_cache_path(cache_key)
adata.write_h5ad(path)
print(f"[cache] Wrote {cache_key} to disk cache ({path.stat().st_size // 1024} KB)")
except Exception as exc:
print(f"[cache] Failed to write {cache_key} to disk cache: {exc}")
def is_loaded(cache_key: str) -> bool:
return _dataset_cache_path(cache_key).exists()
# ββ GPL probe mapping cache ββββββββββββββββββββββββββββββββββββββββββββββββββ
def _gpl_cache_path(accession: str) -> Path:
# JSON (not pickle): the mapping is a plain dict[str, str], so JSON is safe
# to deserialize and human-inspectable. Old .pkl files are simply ignored
# (cache miss β regenerated).
return CACHE_DIR / "gpl" / f"{accession}.json"
def get_gpl(accession: str) -> dict[str, str] | None:
"""Return a cached probeβgene mapping, or None if not cached."""
path = _gpl_cache_path(accession)
if path.exists() and path.stat().st_size > 0:
try:
print(f"[cache] Disk hit for GPL {accession}")
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception as exc:
print(f"[cache] Disk read failed for GPL {accession}: {exc}")
path.unlink(missing_ok=True)
return None
def set_gpl(accession: str, mapping: dict[str, str]) -> None:
"""Write a probeβgene mapping to the disk cache."""
try:
_gpl_cache_path(accession).parent.mkdir(parents=True, exist_ok=True)
with open(_gpl_cache_path(accession), "w", encoding="utf-8") as f:
json.dump(mapping, f)
print(f"[cache] Wrote GPL {accession} to disk cache")
except Exception as exc:
print(f"[cache] Failed to write GPL {accession} to disk cache: {exc}")
# ββ Preloader (optional warm-up, still useful for first-query speed) βββββββββ
def preload_datasets() -> None:
"""
Pre-populate the disk cache at startup by downloading all registered
geo_series_matrix datasets. Runs in a background thread so the server
stays responsive. No-ops if the cache file already exists.
"""
import pandas as pd
import scanpy as sc
from src.datasets.registry import get_registry
from src.tools.rna import decode_geo_numeric_codes
from src.workflows.geo import load_geo_series_matrix_lines, parse_geo_series_matrix_lines
registry = get_registry()
for dataset_id in registry.list():
raw = registry.get(dataset_id)
if raw is None:
continue
expr_src = raw.get("expression_source", {})
if expr_src.get("type") != "geo_series_matrix":
continue
url = expr_src.get("url")
if not url:
continue
cache_key = url_to_cache_key(url)
if is_loaded(cache_key):
print(f"[cache] {dataset_id} already on disk β skipping preload")
continue
print(f"[cache] Preloading {dataset_id} from {url} β¦")
try:
lines = load_geo_series_matrix_lines(url)
parsed = parse_geo_series_matrix_lines(lines)
sample_ids = parsed["sample_ids"]
probe_ids = parsed["probe_ids"]
X = parsed["X"]
sample_characteristics = parsed["sample_characteristics"]
obs_df = pd.DataFrame(index=sample_ids)
for key, values in sample_characteristics.items():
col_name = key.lower().replace(" ", "_").replace("-", "_")
if len(values) == len(sample_ids):
obs_df[col_name] = values
for col in list(obs_df.columns):
result = decode_geo_numeric_codes(col, obs_df[col])
if result is not None:
base_name, decoded = result
if base_name not in obs_df.columns:
obs_df[base_name] = decoded
adata = sc.AnnData(X=X, obs=obs_df)
adata.var.index = pd.Index(probe_ids, name="probe_id")
set_dataset(cache_key, adata)
print(f"[cache] {dataset_id} preloaded: {adata.n_obs} Γ {adata.n_vars}")
except Exception as exc:
print(f"[cache] Failed to preload {dataset_id}: {exc}")
|