565 lines
20 KiB
Python
Executable File
565 lines
20 KiB
Python
Executable File
"""Lightweight resource gate and task ledger for local stage execution.
|
||
|
||
The project runs on one workstation, so a small file-backed ledger is enough to
|
||
coordinate FastAPI and Celery worker processes without adding another service.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import fcntl
|
||
|
||
|
||
TERMINAL_STATES = {"success", "completed", "failed", "failure", "cancelled", "revoked"}
|
||
ACTIVE_STATES = {"waiting_resource", "running"}
|
||
|
||
|
||
def _project_root() -> Path:
|
||
return Path(os.environ.get("PROJECT_ROOT", Path(__file__).resolve().parents[2])).resolve()
|
||
|
||
|
||
def _state_dir() -> Path:
|
||
path = Path(os.environ.get("BBAV5_RESOURCE_STATE_DIR", _project_root() / "workspace" / "resource_governor"))
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def _state_path() -> Path:
|
||
return _state_dir() / "state.json"
|
||
|
||
|
||
def _config_path() -> Path:
|
||
return _state_dir() / "config.json"
|
||
|
||
|
||
def _lock_path() -> Path:
|
||
return _state_dir() / "state.lock"
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now().isoformat()
|
||
|
||
|
||
DEFAULT_RESOURCE_CONFIG = {
|
||
"total_cpu": 20,
|
||
"max_running_tasks": 11,
|
||
"gene_family_project_max_cpu": 20,
|
||
"rnaseq_project_max_cpu": 20,
|
||
"rnaseq_qc_sample_parallel_jobs": 2,
|
||
"rnaseq_align_sample_parallel_jobs": 2,
|
||
"stage_cpu_overrides": {},
|
||
}
|
||
|
||
|
||
def _sanitize_stage_cpu_overrides(value: Any) -> dict[str, dict[str, int]]:
|
||
if not isinstance(value, dict):
|
||
return {}
|
||
sanitized: dict[str, dict[str, int]] = {}
|
||
for stage, item in value.items():
|
||
if not isinstance(stage, str) or not isinstance(item, dict):
|
||
continue
|
||
try:
|
||
max_cpu = int(item.get("max_cpu", item.get("default_cpu", 1)))
|
||
default_cpu = int(item.get("default_cpu", max_cpu))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
max_cpu = max(4, max_cpu)
|
||
default_cpu = max(4, default_cpu)
|
||
sanitized[stage] = {"default_cpu": default_cpu, "max_cpu": max_cpu}
|
||
return sanitized
|
||
|
||
|
||
def _read_config_unlocked() -> dict[str, Any]:
|
||
config = dict(DEFAULT_RESOURCE_CONFIG)
|
||
config["total_cpu"] = int(os.environ.get("BBAV5_RESOURCE_TOTAL_CPU", config["total_cpu"]))
|
||
config["max_running_tasks"] = int(os.environ.get("BBAV5_RESOURCE_MAX_RUNNING_TASKS", config["max_running_tasks"]))
|
||
config["rnaseq_project_max_cpu"] = int(os.environ.get("BBAV5_RNASEQ_PROJECT_MAX_CPU", config["rnaseq_project_max_cpu"]))
|
||
path = _config_path()
|
||
if path.exists():
|
||
try:
|
||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||
if isinstance(saved, dict):
|
||
config.update({k: v for k, v in saved.items() if k in config})
|
||
except Exception:
|
||
pass
|
||
for key, default in DEFAULT_RESOURCE_CONFIG.items():
|
||
if key == "stage_cpu_overrides":
|
||
config[key] = _sanitize_stage_cpu_overrides(config.get(key, default))
|
||
continue
|
||
if isinstance(default, str):
|
||
config[key] = str(config.get(key, default) or "").strip()
|
||
continue
|
||
try:
|
||
config[key] = max(1, int(config.get(key, default)))
|
||
except (TypeError, ValueError):
|
||
config[key] = default
|
||
total = max(1, int(config.get("total_cpu", DEFAULT_RESOURCE_CONFIG["total_cpu"])))
|
||
config["total_cpu"] = total
|
||
config["gene_family_project_max_cpu"] = max(1, min(total, int(config.get("gene_family_project_max_cpu", total))))
|
||
config["rnaseq_project_max_cpu"] = max(1, min(total, int(config.get("rnaseq_project_max_cpu", total))))
|
||
return config
|
||
|
||
|
||
def get_resource_config() -> dict[str, Any]:
|
||
return _read_config_unlocked()
|
||
|
||
|
||
def update_resource_config(updates: dict[str, Any]) -> dict[str, Any]:
|
||
allowed = set(DEFAULT_RESOURCE_CONFIG)
|
||
current = _read_config_unlocked()
|
||
for key, value in (updates or {}).items():
|
||
if key not in allowed:
|
||
continue
|
||
if value is None or value == "":
|
||
continue
|
||
if key == "stage_cpu_overrides":
|
||
current[key] = _sanitize_stage_cpu_overrides(value)
|
||
elif isinstance(DEFAULT_RESOURCE_CONFIG.get(key), str):
|
||
current[key] = str(value or "").strip()
|
||
else:
|
||
current[key] = int(value)
|
||
total = max(1, int(current.get("total_cpu", DEFAULT_RESOURCE_CONFIG["total_cpu"])))
|
||
current["total_cpu"] = total
|
||
current["max_running_tasks"] = max(1, int(current.get("max_running_tasks", DEFAULT_RESOURCE_CONFIG["max_running_tasks"])))
|
||
current["gene_family_project_max_cpu"] = max(1, min(total, int(current.get("gene_family_project_max_cpu", total))))
|
||
current["rnaseq_project_max_cpu"] = max(1, min(total, int(current.get("rnaseq_project_max_cpu", total))))
|
||
current["rnaseq_qc_sample_parallel_jobs"] = max(1, int(current.get("rnaseq_qc_sample_parallel_jobs", 2)))
|
||
current["rnaseq_align_sample_parallel_jobs"] = max(1, int(current.get("rnaseq_align_sample_parallel_jobs", 2)))
|
||
current["stage_cpu_overrides"] = _sanitize_stage_cpu_overrides(current.get("stage_cpu_overrides", {}))
|
||
path = _config_path()
|
||
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return current
|
||
|
||
|
||
def _total_cpu() -> int:
|
||
return max(1, int(_read_config_unlocked().get("total_cpu", 20)))
|
||
|
||
|
||
def _max_running_tasks() -> int:
|
||
return max(1, int(_read_config_unlocked().get("max_running_tasks", 11)))
|
||
|
||
|
||
def _pid_alive(pid: Any) -> bool:
|
||
try:
|
||
value = int(pid)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return value > 0 and Path(f"/proc/{value}").exists()
|
||
|
||
|
||
def _total_memory_gb() -> float:
|
||
try:
|
||
with open("/proc/meminfo", "r", encoding="utf-8") as handle:
|
||
for line in handle:
|
||
if line.startswith("MemTotal:"):
|
||
kb = int(line.split()[1])
|
||
return round(kb / 1024 / 1024, 2)
|
||
except Exception:
|
||
pass
|
||
return 0.0
|
||
|
||
|
||
def _gpu_summary() -> list[dict[str, Any]]:
|
||
try:
|
||
import subprocess
|
||
result = subprocess.run(
|
||
[
|
||
"nvidia-smi",
|
||
"--query-gpu=name,memory.total",
|
||
"--format=csv,noheader,nounits",
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=5,
|
||
)
|
||
if result.returncode != 0:
|
||
return []
|
||
gpus = []
|
||
for idx, line in enumerate(result.stdout.splitlines()):
|
||
if not line.strip():
|
||
continue
|
||
parts = [part.strip() for part in line.split(",")]
|
||
gpus.append({
|
||
"index": idx,
|
||
"name": parts[0] if parts else "unknown",
|
||
"memory_mb": int(float(parts[1])) if len(parts) > 1 and parts[1] else 0,
|
||
})
|
||
return gpus
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def detect_hardware() -> dict[str, Any]:
|
||
"""Return local hardware facts used for RNA-seq resource planning."""
|
||
total_cpu = os.cpu_count() or _total_cpu()
|
||
configured_cpu = _total_cpu()
|
||
memory_gb = _total_memory_gb()
|
||
return {
|
||
"cpu_count": int(total_cpu),
|
||
"configured_total_cpu": int(configured_cpu),
|
||
"memory_gb": memory_gb,
|
||
"gpus": _gpu_summary(),
|
||
}
|
||
|
||
|
||
def rnaseq_project_cpu_limit(requested_cpu: int | None = None) -> int:
|
||
"""Return the CPU budget for one RNA-seq project within the global governor."""
|
||
config = _read_config_unlocked()
|
||
configured_total = int(config.get("total_cpu", _total_cpu()))
|
||
project_limit = int(config.get("rnaseq_project_max_cpu", configured_total))
|
||
if requested_cpu:
|
||
try:
|
||
project_limit = min(project_limit, int(requested_cpu))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return max(1, min(project_limit, configured_total))
|
||
|
||
|
||
def recommend_rnaseq_parallelism(
|
||
total_cpu: int | None = None,
|
||
sample_parallel_jobs: int | None = None,
|
||
threads_per_sample: int | None = None,
|
||
sample_count: int | None = None,
|
||
stage: str = "rnaseq_align",
|
||
) -> dict[str, int]:
|
||
"""Compute per-project RNA-seq sample/thread settings."""
|
||
cpu_total = max(1, int(total_cpu or rnaseq_project_cpu_limit()))
|
||
if sample_parallel_jobs is None or int(sample_parallel_jobs or 0) < 1:
|
||
jobs = int(_read_config_unlocked().get("rnaseq_qc_sample_parallel_jobs" if stage == "rnaseq_qc" else "rnaseq_align_sample_parallel_jobs", 2)) if stage in {"rnaseq_qc", "rnaseq_align"} else 1
|
||
else:
|
||
jobs = max(1, int(sample_parallel_jobs or 1))
|
||
if sample_count:
|
||
jobs = min(jobs, max(1, int(sample_count)))
|
||
if threads_per_sample is None or int(threads_per_sample) < 1:
|
||
threads = max(1, cpu_total // jobs)
|
||
else:
|
||
threads = max(1, int(threads_per_sample))
|
||
if jobs * threads > cpu_total:
|
||
threads = max(1, cpu_total // jobs)
|
||
effective_cpu = max(1, jobs * threads)
|
||
return {
|
||
"total_cpu": effective_cpu,
|
||
"project_cpu_limit": cpu_total,
|
||
"sample_parallel_jobs": jobs,
|
||
"threads_per_sample": threads,
|
||
}
|
||
|
||
|
||
def _empty_state() -> dict[str, Any]:
|
||
return {
|
||
"version": 1,
|
||
"updated_at": _now(),
|
||
"limits": {
|
||
"total_cpu": _total_cpu(),
|
||
"max_running_tasks": _max_running_tasks(),
|
||
"hardware": detect_hardware(),
|
||
"config": get_resource_config(),
|
||
},
|
||
"tasks": {},
|
||
}
|
||
|
||
|
||
@contextmanager
|
||
def _locked_state():
|
||
lock_file = _lock_path()
|
||
state_file = _state_path()
|
||
with lock_file.open("a+", encoding="utf-8") as lock:
|
||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||
try:
|
||
if state_file.exists():
|
||
try:
|
||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
state = _empty_state()
|
||
else:
|
||
state = _empty_state()
|
||
if not isinstance(state, dict):
|
||
state = _empty_state()
|
||
state.setdefault("tasks", {})
|
||
state["limits"] = {
|
||
"total_cpu": _total_cpu(),
|
||
"max_running_tasks": _max_running_tasks(),
|
||
"hardware": detect_hardware(),
|
||
"config": get_resource_config(),
|
||
}
|
||
_cleanup_stale_locked(state)
|
||
yield state
|
||
state["updated_at"] = _now()
|
||
tmp = state_file.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(state_file)
|
||
finally:
|
||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||
|
||
|
||
def _cleanup_stale_locked(state: dict[str, Any]) -> None:
|
||
tasks = state.setdefault("tasks", {})
|
||
for task_id, task in list(tasks.items()):
|
||
status = str(task.get("status") or "").lower()
|
||
if status in TERMINAL_STATES:
|
||
continue
|
||
if status == "running" and task.get("pid") and not _pid_alive(task.get("pid")):
|
||
task["status"] = "failed"
|
||
task["ended_at"] = task.get("ended_at") or _now()
|
||
task["message"] = "worker process disappeared; resource lease released"
|
||
task["cpu"] = 0
|
||
elif status == "waiting_resource" and task.get("pid") and not _pid_alive(task.get("pid")):
|
||
task["status"] = "cancelled"
|
||
task["ended_at"] = task.get("ended_at") or _now()
|
||
task["message"] = "waiting worker process disappeared"
|
||
|
||
|
||
def _usage_locked(state: dict[str, Any]) -> dict[str, Any]:
|
||
used_cpu = 0
|
||
running = 0
|
||
waiting = 0
|
||
by_class: dict[str, int] = {}
|
||
for task in state.get("tasks", {}).values():
|
||
status = str(task.get("status") or "").lower()
|
||
if status == "running":
|
||
running += 1
|
||
cpu = int(task.get("cpu") or 0)
|
||
used_cpu += cpu
|
||
cls = str(task.get("resource_class") or "unknown")
|
||
by_class[cls] = by_class.get(cls, 0) + cpu
|
||
elif status == "waiting_resource":
|
||
waiting += 1
|
||
limits = state.get("limits", {})
|
||
total_cpu = int(limits.get("total_cpu") or _total_cpu())
|
||
return {
|
||
"used_cpu": used_cpu,
|
||
"available_cpu": max(0, total_cpu - used_cpu),
|
||
"running_tasks": running,
|
||
"waiting_tasks": waiting,
|
||
"by_resource_class_cpu": by_class,
|
||
"limits": limits,
|
||
}
|
||
|
||
|
||
def record_task(
|
||
*,
|
||
task_id: str,
|
||
stage: str,
|
||
job_id: str,
|
||
prefix: str = "",
|
||
status: str = "queued",
|
||
cpu: int = 0,
|
||
queue: str = "",
|
||
resource_class: str = "",
|
||
concurrency_group: str = "",
|
||
celery_task_id: str = "",
|
||
message: str = "",
|
||
) -> dict[str, Any]:
|
||
with _locked_state() as state:
|
||
task = state["tasks"].setdefault(task_id, {})
|
||
task.update(
|
||
{
|
||
"task_id": task_id,
|
||
"stage": stage,
|
||
"job_id": job_id,
|
||
"prefix": prefix,
|
||
"status": status,
|
||
"cpu": int(cpu or 0) if status == "running" else 0,
|
||
"requested_cpu": int(cpu or 0),
|
||
"queue": queue,
|
||
"resource_class": resource_class,
|
||
"concurrency_group": concurrency_group,
|
||
"celery_task_id": celery_task_id or task.get("celery_task_id", ""),
|
||
"pid": os.getpid() if status in ACTIVE_STATES else task.get("pid"),
|
||
"message": message,
|
||
"updated_at": _now(),
|
||
}
|
||
)
|
||
task.setdefault("created_at", _now())
|
||
if status == "running":
|
||
task["started_at"] = task.get("started_at") or _now()
|
||
if status in TERMINAL_STATES:
|
||
task["ended_at"] = task.get("ended_at") or _now()
|
||
task["cpu"] = 0
|
||
return {"task": dict(task), "usage": _usage_locked(state)}
|
||
|
||
|
||
def try_acquire(
|
||
*,
|
||
task_id: str,
|
||
stage: str,
|
||
job_id: str,
|
||
prefix: str,
|
||
cpu: int,
|
||
queue: str = "",
|
||
resource_class: str = "",
|
||
concurrency_group: str = "",
|
||
celery_task_id: str = "",
|
||
) -> dict[str, Any]:
|
||
cpu = max(1, int(cpu or 1))
|
||
with _locked_state() as state:
|
||
tasks = state.setdefault("tasks", {})
|
||
existing = tasks.get(task_id, {})
|
||
if str(existing.get("status") or "").lower() == "running":
|
||
return {"granted": True, "task": dict(existing), "usage": _usage_locked(state)}
|
||
|
||
usage = _usage_locked(state)
|
||
limits = state.get("limits", {})
|
||
total_cpu = int(limits.get("total_cpu") or _total_cpu())
|
||
max_tasks = int(limits.get("max_running_tasks") or _max_running_tasks())
|
||
config = get_resource_config()
|
||
project_limit_key = "rnaseq_project_max_cpu" if resource_class == "rnaseq" else "gene_family_project_max_cpu"
|
||
project_limit = max(1, int(config.get(project_limit_key, total_cpu)))
|
||
project_used_cpu = 0
|
||
group_running = False
|
||
for existing_task in tasks.values():
|
||
if str(existing_task.get("status") or "").lower() != "running":
|
||
continue
|
||
if (
|
||
concurrency_group
|
||
and str(existing_task.get("concurrency_group") or "") == concurrency_group
|
||
and str(existing_task.get("task_id") or "") != task_id
|
||
):
|
||
group_running = True
|
||
if str(existing_task.get("job_id") or "") != str(job_id):
|
||
continue
|
||
existing_class = str(existing_task.get("resource_class") or "")
|
||
existing_key = "rnaseq_project_max_cpu" if existing_class == "rnaseq" else "gene_family_project_max_cpu"
|
||
if existing_key == project_limit_key:
|
||
project_used_cpu += int(existing_task.get("cpu") or 0)
|
||
project_has_cpu = True
|
||
# CPU token caps are disabled; max_running_tasks still prevents unbounded task fan-out.
|
||
if usage["running_tasks"] < max_tasks and project_has_cpu and not group_running:
|
||
task = tasks.setdefault(task_id, {})
|
||
task.update(
|
||
{
|
||
"task_id": task_id,
|
||
"stage": stage,
|
||
"job_id": job_id,
|
||
"prefix": prefix,
|
||
"status": "running",
|
||
"cpu": cpu,
|
||
"requested_cpu": cpu,
|
||
"queue": queue,
|
||
"resource_class": resource_class,
|
||
"concurrency_group": concurrency_group,
|
||
"celery_task_id": celery_task_id or task.get("celery_task_id", ""),
|
||
"pid": os.getpid(),
|
||
"started_at": task.get("started_at") or _now(),
|
||
"updated_at": _now(),
|
||
"message": "resource lease granted",
|
||
}
|
||
)
|
||
return {"granted": True, "task": dict(task), "usage": _usage_locked(state)}
|
||
|
||
task = tasks.setdefault(task_id, {})
|
||
task.update(
|
||
{
|
||
"task_id": task_id,
|
||
"stage": stage,
|
||
"job_id": job_id,
|
||
"prefix": prefix,
|
||
"status": "waiting_resource",
|
||
"cpu": 0,
|
||
"requested_cpu": cpu,
|
||
"queue": queue,
|
||
"resource_class": resource_class,
|
||
"concurrency_group": concurrency_group,
|
||
"celery_task_id": celery_task_id or task.get("celery_task_id", ""),
|
||
"pid": os.getpid(),
|
||
"updated_at": _now(),
|
||
"message": f"waiting for {cpu} CPU token(s)",
|
||
}
|
||
)
|
||
usage = _usage_locked(state)
|
||
return {
|
||
"granted": False,
|
||
"task": dict(task),
|
||
"usage": usage,
|
||
"reason": (
|
||
f"等待互斥资源组 {concurrency_group}(容量 1)"
|
||
if group_running else (
|
||
f"资源不足:需要 CPU {cpu},当前可用 CPU {usage['available_cpu']},"
|
||
f"运行任务 {usage['running_tasks']}/{max_tasks},"
|
||
f"项目已用 CPU {project_used_cpu}/{project_limit}"
|
||
)
|
||
),
|
||
}
|
||
|
||
|
||
def release(task_id: str, status: str = "completed", message: str = "") -> dict[str, Any]:
|
||
with _locked_state() as state:
|
||
task = state.setdefault("tasks", {}).setdefault(task_id, {"task_id": task_id})
|
||
task["status"] = status
|
||
task["cpu"] = 0
|
||
task["ended_at"] = task.get("ended_at") or _now()
|
||
task["updated_at"] = _now()
|
||
if message:
|
||
task["message"] = message
|
||
return {"task": dict(task), "usage": _usage_locked(state)}
|
||
|
||
|
||
def snapshot(include_terminal: bool = False) -> dict[str, Any]:
|
||
with _locked_state() as state:
|
||
tasks = []
|
||
for task in state.get("tasks", {}).values():
|
||
status = str(task.get("status") or "").lower()
|
||
if include_terminal or status not in TERMINAL_STATES:
|
||
tasks.append(dict(task))
|
||
tasks.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
|
||
return {
|
||
"state_path": str(_state_path()),
|
||
"updated_at": state.get("updated_at"),
|
||
"limits": state.get("limits", {}),
|
||
"usage": _usage_locked(state),
|
||
"tasks": tasks,
|
||
}
|
||
|
||
|
||
def get_task(task_id: str) -> dict[str, Any] | None:
|
||
with _locked_state() as state:
|
||
task = state.get("tasks", {}).get(task_id)
|
||
if not task:
|
||
return None
|
||
return {"task": dict(task), "usage": _usage_locked(state)}
|
||
|
||
|
||
@dataclass
|
||
class ResourceLease:
|
||
task_id: str
|
||
stage: str
|
||
job_id: str
|
||
prefix: str
|
||
cpu: int
|
||
queue: str = ""
|
||
resource_class: str = ""
|
||
concurrency_group: str = ""
|
||
celery_task_id: str = ""
|
||
poll_seconds: float = 5.0
|
||
|
||
def wait(self, update_state=None) -> dict[str, Any]:
|
||
while True:
|
||
result = try_acquire(
|
||
task_id=self.task_id,
|
||
stage=self.stage,
|
||
job_id=self.job_id,
|
||
prefix=self.prefix,
|
||
cpu=self.cpu,
|
||
queue=self.queue,
|
||
resource_class=self.resource_class,
|
||
concurrency_group=self.concurrency_group,
|
||
celery_task_id=self.celery_task_id,
|
||
)
|
||
if result.get("granted"):
|
||
return result
|
||
if update_state:
|
||
update_state(result)
|
||
time.sleep(self.poll_seconds)
|
||
|
||
def release(self, status: str = "completed", message: str = "") -> dict[str, Any]:
|
||
return release(self.task_id, status=status, message=message)
|