2bbdb00a4d
包含节点账本、每日清算,以及 Ubuntu 交互式部署脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
8.5 KiB
Python
237 lines
8.5 KiB
Python
"""每日产出清算。
|
|
|
|
释放规则:
|
|
1. 授权金额在整个授权天数内线性释放。授权天数取 license 起止日期之差。
|
|
2. 在线率 = 当日授权窗口内的在线秒数 / 当日授权有效秒数。
|
|
尚未关闭的在线区间,若最后心跳已超过超时时间,只计算到最后一次心跳。
|
|
3. 任务响应率 = 校验通过且执行成功的任务数 / 当日内下发任务数。
|
|
4. 当天没有下发任务时,表现系数等于在线率,避免调度器故障误伤节点。
|
|
有任务时,表现系数 = 在线权重 × 在线率 + 任务权重 × 响应率。
|
|
5. 算力产出 = 授权金额 × 表现系数。
|
|
当日积分 = 算力产出 / 授权天数。
|
|
6. 单节点累计积分不超过授权金额。已存在的当日流水不会被覆盖。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.clock import day_bounds, utcnow
|
|
from app.config import settings
|
|
from app.errors import AppError
|
|
from app.models import ComputeNode, ComputeTask, NodeYield, TaskStatus, UptimeInterval, YieldStatus
|
|
from app.money import money, rate, ratio, to_decimal
|
|
from app.services.common import get_node_by_sn
|
|
from app.services.tasks import expire_overdue_tasks
|
|
|
|
|
|
@dataclass
|
|
class YieldComputation:
|
|
eligible: bool
|
|
yield_date: date
|
|
online_seconds: int = 0
|
|
eligible_seconds: int = 0
|
|
online_rate: Decimal = Decimal("0")
|
|
tasks_dispatched: int = 0
|
|
tasks_completed: int = 0
|
|
task_response_rate: Decimal = Decimal("0")
|
|
online_weight: Decimal = Decimal("0")
|
|
task_weight: Decimal = Decimal("0")
|
|
performance: Decimal = Decimal("0")
|
|
compute_output: Decimal = Decimal("0")
|
|
points_delta: Decimal = Decimal("0")
|
|
cycle_days: int = 0
|
|
|
|
|
|
@dataclass
|
|
class SettleSummary:
|
|
yield_date: date
|
|
created: int
|
|
skipped: int
|
|
total_points: Decimal
|
|
is_partial: bool
|
|
|
|
|
|
def compute_node_yield(
|
|
session: Session,
|
|
node: ComputeNode,
|
|
yield_date: date,
|
|
now: datetime | None = None,
|
|
) -> YieldComputation:
|
|
moment = now or utcnow()
|
|
day_start, day_end = day_bounds(yield_date)
|
|
window_start = max(day_start, node.licensed_at)
|
|
window_end = min(day_end, node.license_expires_at)
|
|
cycle_days = (node.license_expires_at.date() - node.licensed_at.date()).days
|
|
if window_end <= window_start or cycle_days <= 0:
|
|
return YieldComputation(eligible=False, yield_date=yield_date, cycle_days=max(cycle_days, 0))
|
|
|
|
eligible_seconds = int((window_end - window_start).total_seconds())
|
|
online_seconds = min(_online_seconds(session, node, window_start, window_end, moment), eligible_seconds)
|
|
online_rate = ratio(online_seconds, eligible_seconds)
|
|
|
|
tasks = session.scalars(
|
|
select(ComputeTask).where(
|
|
ComputeTask.node_id == node.id,
|
|
ComputeTask.dispatched_at >= window_start,
|
|
ComputeTask.dispatched_at < window_end,
|
|
)
|
|
).all()
|
|
tasks_dispatched = len(tasks)
|
|
tasks_completed = sum(1 for task in tasks if task.status == TaskStatus.COMPLETED)
|
|
response_rate = ratio(tasks_completed, tasks_dispatched) if tasks_dispatched else Decimal("0")
|
|
|
|
if tasks_dispatched == 0:
|
|
applied_online = Decimal("1")
|
|
applied_task = Decimal("0")
|
|
else:
|
|
applied_online = to_decimal(settings.online_weight)
|
|
applied_task = to_decimal(settings.task_weight)
|
|
performance = rate(applied_online * online_rate + applied_task * response_rate)
|
|
compute_output = money(to_decimal(node.purchase_amount) * performance)
|
|
points_delta = money(compute_output / Decimal(cycle_days))
|
|
points_delta = min(points_delta, _remaining_points(session, node))
|
|
|
|
return YieldComputation(
|
|
eligible=True,
|
|
yield_date=yield_date,
|
|
online_seconds=online_seconds,
|
|
eligible_seconds=eligible_seconds,
|
|
online_rate=online_rate,
|
|
tasks_dispatched=tasks_dispatched,
|
|
tasks_completed=tasks_completed,
|
|
task_response_rate=response_rate,
|
|
online_weight=money(applied_online),
|
|
task_weight=money(applied_task),
|
|
performance=performance,
|
|
compute_output=compute_output,
|
|
points_delta=points_delta,
|
|
cycle_days=cycle_days,
|
|
)
|
|
|
|
|
|
def settle_yield_date(session: Session, yield_date: date, now: datetime | None = None) -> SettleSummary:
|
|
"""清算指定 UTC 日。每个节点当天已有流水时跳过,保证脚本重复执行不会重复入账。"""
|
|
moment = now or utcnow()
|
|
if yield_date > moment.date():
|
|
raise AppError("不能清算未来日期")
|
|
expire_overdue_tasks(session, moment)
|
|
day_start, day_end = day_bounds(yield_date)
|
|
nodes = session.scalars(
|
|
select(ComputeNode)
|
|
.where(
|
|
ComputeNode.licensed_at < day_end,
|
|
ComputeNode.license_expires_at > day_start,
|
|
)
|
|
.order_by(ComputeNode.id)
|
|
).all()
|
|
created = 0
|
|
skipped = 0
|
|
total = Decimal("0")
|
|
partial = yield_date == moment.date()
|
|
for node in nodes:
|
|
exists = session.scalar(
|
|
select(NodeYield.id).where(NodeYield.node_id == node.id, NodeYield.yield_date == yield_date)
|
|
)
|
|
if exists is not None:
|
|
skipped += 1
|
|
continue
|
|
computed = compute_node_yield(session, node, yield_date, moment)
|
|
if not computed.eligible:
|
|
continue
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(_to_row(node, computed, partial))
|
|
except IntegrityError:
|
|
skipped += 1
|
|
continue
|
|
created += 1
|
|
total += computed.points_delta
|
|
return SettleSummary(
|
|
yield_date=yield_date,
|
|
created=created,
|
|
skipped=skipped,
|
|
total_points=money(total),
|
|
is_partial=partial,
|
|
)
|
|
|
|
|
|
def list_node_yields(session: Session, sn: str) -> list[NodeYield]:
|
|
node = get_node_by_sn(session, sn.strip())
|
|
return list(
|
|
session.scalars(
|
|
select(NodeYield).where(NodeYield.node_id == node.id).order_by(NodeYield.yield_date.desc())
|
|
).all()
|
|
)
|
|
|
|
|
|
def _online_seconds(
|
|
session: Session,
|
|
node: ComputeNode,
|
|
window_start: datetime,
|
|
window_end: datetime,
|
|
now: datetime,
|
|
) -> int:
|
|
intervals = session.scalars(
|
|
select(UptimeInterval).where(
|
|
UptimeInterval.node_id == node.id,
|
|
UptimeInterval.started_at < window_end,
|
|
or_(UptimeInterval.ended_at.is_(None), UptimeInterval.ended_at > window_start),
|
|
)
|
|
).all()
|
|
total = 0
|
|
for interval in intervals:
|
|
start = max(interval.started_at, window_start)
|
|
end = min(_proven_end(interval, node, now), window_end)
|
|
if end > start:
|
|
total += int((end - start).total_seconds())
|
|
return total
|
|
|
|
|
|
def _proven_end(interval: UptimeInterval, node: ComputeNode, now: datetime) -> datetime:
|
|
"""开放区间只能证明到最后一次心跳;心跳仍在超时窗口内才延续到当前时间。"""
|
|
if interval.ended_at is not None:
|
|
return interval.ended_at
|
|
last_seen = node.last_heartbeat_at or interval.started_at
|
|
if last_seen + timedelta(minutes=settings.heartbeat_timeout_minutes) < now:
|
|
return last_seen
|
|
return now
|
|
|
|
|
|
def _remaining_points(session: Session, node: ComputeNode) -> Decimal:
|
|
paid_rows = session.scalars(select(NodeYield.points_delta).where(NodeYield.node_id == node.id)).all()
|
|
paid = sum((to_decimal(item) for item in paid_rows), Decimal("0"))
|
|
remaining = to_decimal(node.purchase_amount) - paid
|
|
if remaining <= 0:
|
|
return Decimal("0")
|
|
return money(remaining)
|
|
|
|
|
|
def _to_row(node: ComputeNode, computed: YieldComputation, partial: bool) -> NodeYield:
|
|
return NodeYield(
|
|
node_id=node.id,
|
|
user_id=node.user_id,
|
|
yield_date=computed.yield_date,
|
|
compute_output=computed.compute_output,
|
|
points_delta=computed.points_delta,
|
|
online_seconds=computed.online_seconds,
|
|
eligible_seconds=computed.eligible_seconds,
|
|
online_rate=computed.online_rate,
|
|
tasks_dispatched=computed.tasks_dispatched,
|
|
tasks_completed=computed.tasks_completed,
|
|
task_response_rate=computed.task_response_rate,
|
|
online_weight=computed.online_weight,
|
|
task_weight=computed.task_weight,
|
|
performance=computed.performance,
|
|
cycle_days=computed.cycle_days,
|
|
status=YieldStatus.SETTLED,
|
|
is_partial=partial,
|
|
created_at=utcnow(),
|
|
)
|