Files
HyperNode-AI/app/services/tasks.py
T
dekun 2bbdb00a4d 初始化 HyperNode-AI 控制面。
包含节点账本、每日清算,以及 Ubuntu 交互式部署脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:53:20 +08:00

184 lines
5.9 KiB
Python

"""随机任务下发与回传校验。
节点不暴露回调地址时,任务留在待拉取队列,客户端用
GET /api/v1/node/{sn}/tasks 领取。配置了 callback_url 时,调度器会额外 POST 一次。
回传必须带上任务里的 nonce,避免未收到任务的客户端直接上报成功。
"""
from __future__ import annotations
import hmac
import json
import logging
import random
import secrets
import uuid
from datetime import datetime, timedelta
from urllib.error import URLError
from urllib.request import Request, urlopen
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, NodeStatus, PushStatus, TaskStatus
from app.services.common import get_node_by_sn
logger = logging.getLogger(__name__)
def dispatch_random_tasks(session: Session, now: datetime | None = None) -> list[ComputeTask]:
"""向当前在线、授权仍有效、且没有未完成任务的节点随机下发一轮任务。"""
moment = now or utcnow()
expire_overdue_tasks(session, moment)
pending_node_ids = set(
session.scalars(select(ComputeTask.node_id).where(ComputeTask.status == TaskStatus.PENDING)).all()
)
candidates = [
node
for node in session.scalars(
select(ComputeNode).where(
ComputeNode.status == NodeStatus.ONLINE,
ComputeNode.license_expires_at > moment,
)
).all()
if node.id not in pending_node_ids
]
if not candidates:
return []
batch = min(len(candidates), settings.dispatch_batch_size)
chosen = random.sample(candidates, batch)
deadline = moment + timedelta(minutes=settings.task_deadline_minutes)
tasks: list[ComputeTask] = []
for node in chosen:
task = ComputeTask(
id=str(uuid.uuid4()),
node_id=node.id,
task_type="simulated_compute",
payload={
"op": "simulated_compute",
"nonce": secrets.token_hex(16),
"seed": secrets.token_hex(8),
},
status=TaskStatus.PENDING,
push_status=PushStatus.QUEUED,
dispatched_at=moment,
deadline_at=deadline,
)
session.add(task)
tasks.append(task)
session.flush()
for node, task in zip(chosen, tasks):
_push_task(node, task)
session.flush()
return tasks
def list_pending_tasks(session: Session, sn: str, now: datetime | None = None) -> list[ComputeTask]:
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
tasks = session.scalars(
select(ComputeTask)
.where(ComputeTask.node_id == node.id, ComputeTask.status == TaskStatus.PENDING)
.order_by(ComputeTask.dispatched_at)
).all()
visible: list[ComputeTask] = []
for task in tasks:
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
continue
visible.append(task)
session.flush()
return visible
def report_task(
session: Session,
sn: str,
task_id: str,
success: bool,
nonce: str,
now: datetime | None = None,
) -> ComputeTask:
"""校验任务归属、时限和 nonce。只有校验通过且 success 为真才记为有效响应。"""
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
task = session.get(ComputeTask, task_id)
if task is None:
raise AppError("任务不存在", 404)
if task.node_id != node.id:
raise AppError("任务不属于该节点", 403)
if task.status != TaskStatus.PENDING:
raise AppError("任务已结束,不能重复回传", 409)
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
session.flush()
raise AppError("任务已超过回传时限", 409)
expected = str((task.payload or {}).get("nonce", ""))
if not _nonce_matches(nonce, expected):
raise AppError("任务响应校验失败")
task.responded_at = moment
task.success = success
task.status = TaskStatus.COMPLETED if success else TaskStatus.FAILED
session.flush()
return task
def expire_overdue_tasks(session: Session, now: datetime | None = None) -> int:
moment = now or utcnow()
tasks = session.scalars(
select(ComputeTask).where(
ComputeTask.status == TaskStatus.PENDING,
ComputeTask.deadline_at < moment,
)
).all()
for task in tasks:
task.status = TaskStatus.EXPIRED
if tasks:
session.flush()
return len(tasks)
def _nonce_matches(provided: str, expected: str) -> bool:
if not expected or len(provided) != len(expected):
return False
return hmac.compare_digest(provided, expected)
def _push_task(node: ComputeNode, task: ComputeTask) -> None:
if not node.callback_url:
task.push_status = PushStatus.QUEUED
return
body = json.dumps(
{
"task_id": task.id,
"sn": node.sn,
"task_type": task.task_type,
"payload": task.payload,
"deadline_at": task.deadline_at.isoformat(),
}
).encode()
request = Request(
node.callback_url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=2) as response:
if 200 <= response.status < 300:
task.push_status = PushStatus.PUSHED
task.push_error = None
return
task.push_status = PushStatus.FAILED
task.push_error = f"HTTP {response.status}"
except (URLError, TimeoutError, OSError) as exc:
task.push_status = PushStatus.FAILED
task.push_error = str(exc)[:255]
logger.warning("task push failed sn=%s task=%s error=%s", node.sn, task.id, exc)