565 lines
20 KiB
Python
565 lines
20 KiB
Python
"""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)
|