117 lines
3.9 KiB
Python
117 lines
3.9 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Dict, List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from redis.asyncio import Redis
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..deps import db_session_dep, redis_dep
|
|
from ..prometheus import (
|
|
match_candidates_returned,
|
|
match_failures_total,
|
|
match_latency_seconds,
|
|
match_requests_total,
|
|
)
|
|
from poolhub.repositories.match_repository import MatchRepository
|
|
from poolhub.repositories.miner_repository import MinerRepository
|
|
from ..schemas import MatchCandidate, MatchRequestPayload, MatchResponse
|
|
|
|
router = APIRouter(tags=["match"])
|
|
|
|
|
|
def _normalize_requirements(requirements: Dict[str, Any]) -> Dict[str, Any]:
|
|
return requirements or {}
|
|
|
|
|
|
def _candidate_from_payload(payload: Dict[str, Any]) -> MatchCandidate:
|
|
return MatchCandidate(**payload)
|
|
|
|
|
|
@router.post("/match", response_model=MatchResponse, summary="Find top miners for a job")
|
|
async def match_endpoint(
|
|
payload: MatchRequestPayload,
|
|
session: AsyncSession = Depends(db_session_dep),
|
|
redis: Redis = Depends(redis_dep),
|
|
) -> MatchResponse:
|
|
start = time.perf_counter()
|
|
match_requests_total.inc()
|
|
|
|
miner_repo = MinerRepository(session, redis)
|
|
match_repo = MatchRepository(session, redis)
|
|
|
|
requirements = _normalize_requirements(payload.requirements)
|
|
top_k = payload.top_k
|
|
|
|
try:
|
|
request = await match_repo.create_request(
|
|
job_id=payload.job_id,
|
|
requirements=requirements,
|
|
hints=payload.hints,
|
|
top_k=top_k,
|
|
)
|
|
|
|
active_miners = await miner_repo.list_active_miners()
|
|
candidates = _select_candidates(requirements, payload.hints, active_miners, top_k)
|
|
|
|
await match_repo.add_results(
|
|
request_id=request.id,
|
|
candidates=candidates,
|
|
)
|
|
|
|
match_candidates_returned.inc(len(candidates))
|
|
duration = time.perf_counter() - start
|
|
match_latency_seconds.observe(duration)
|
|
|
|
return MatchResponse(
|
|
job_id=payload.job_id,
|
|
candidates=[_candidate_from_payload(candidate) for candidate in candidates],
|
|
)
|
|
except Exception as exc: # pragma: no cover - safeguards unexpected failures
|
|
match_failures_total.inc()
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="match_failed") from exc
|
|
|
|
|
|
def _select_candidates(
|
|
requirements: Dict[str, Any],
|
|
hints: Dict[str, Any],
|
|
active_miners: List[tuple],
|
|
top_k: int,
|
|
) -> List[Dict[str, Any]]:
|
|
min_vram = float(requirements.get("min_vram_gb", 0))
|
|
min_ram = float(requirements.get("min_ram_gb", 0))
|
|
capabilities_required = set(requirements.get("capabilities_any", []))
|
|
region_hint = hints.get("region")
|
|
|
|
ranked: List[Dict[str, Any]] = []
|
|
for miner, status, score in active_miners:
|
|
if miner.gpu_vram_gb and miner.gpu_vram_gb < min_vram:
|
|
continue
|
|
if miner.ram_gb and miner.ram_gb < min_ram:
|
|
continue
|
|
if capabilities_required and not capabilities_required.issubset(set(miner.capabilities or [])):
|
|
continue
|
|
if region_hint and miner.region and miner.region != region_hint:
|
|
continue
|
|
|
|
candidate = {
|
|
"miner_id": miner.miner_id,
|
|
"addr": miner.addr,
|
|
"proto": miner.proto,
|
|
"score": float(score),
|
|
"explain": _compose_explain(score, miner, status),
|
|
"eta_ms": status.avg_latency_ms if status else None,
|
|
"price": miner.base_price,
|
|
}
|
|
ranked.append(candidate)
|
|
|
|
ranked.sort(key=lambda item: item["score"], reverse=True)
|
|
return ranked[:top_k]
|
|
|
|
|
|
def _compose_explain(score: float, miner, status) -> str:
|
|
load = status.queue_len if status else 0
|
|
latency = status.avg_latency_ms if status else "n/a"
|
|
return f"score={score:.3f} load={load} latency={latency}"
|