Spaces:
Running
Running
File size: 8,375 Bytes
edae745 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | """采集端任务进度上报账本 —— 本地 JSON 存储(无数据库)。
为什么要这个账本:``GET /device/tasks`` 原先把「项目目录下的 session 数」当
采集进度,而 session 是**上传**的产物。采集端本身在「录制完成」时就已经把
增量上报上来(见采集端 ``core/task_record.py`` 的分账模型),于是同一条数据
被算两次——录制一次、上传一次。2026-09-10 现场:UMIGripper_AI 本机录了 12 条、
服务器上 16 个 session,界面显示 28/20,两边的数都不可信。
现在的口径:
项目进度 = 各设备上报的录制完成数之和
只有**从未收到过上报**的项目才回退到 session 计数,这样升级前就存在的历史
项目不会突然归零;某个项目一旦有设备上报,它就固定按上报口径走(上传不再
改变它,采集端录一条 +1)。
幂等:采集端用 ``session_id = "{device}:{task_id}:{水位}"`` 标识一次增量,
同一 (task_id, device_name, session_id) 只计一次 —— 网络抖动重试、程序被杀
后重放同一个水位,都不会重复计数。
文件: ``data/state/device_task_progress.json``
{"tasks": {"<task_id>": {
"devices": {"<device_name>": {
"count": 12,
"sessions": ["EGO_001:<task_id>:0", ...], # 已计入的幂等键
"updated_at": "2026-09-10T02:11:39+00:00"}},
"updated_at": "..."}},
"updated_at": "..."}
"""
from __future__ import annotations
import json
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
# 读-改-写必须串行:uvicorn 单进程多线程,两个设备同时上报时不能丢增量
_LOCK = threading.RLock()
PROGRESS_FILE_NAME = "device_task_progress.json"
# 单次上报上限:采集端 flush 的是「本机未同步增量」,正常是 1~几十;
# 超过这个数说明上报方数据脏了,直接拒绝而不是把进度顶飞。
MAX_INCREMENT = 100000
_MAX_FIELD = 200 # task_id / device_name / session_id 的长度上限
def _progress_file(path: Path | None = None) -> Path:
if path is not None:
return Path(path)
from app.localstore import STATE_ROOT
return STATE_ROOT / PROGRESS_FILE_NAME
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _read(path: Path | None = None) -> dict:
try:
data = json.loads(_progress_file(path).read_text(encoding="utf-8"))
except Exception:
return {"tasks": {}}
if not isinstance(data, dict):
return {"tasks": {}}
tasks = data.get("tasks")
if not isinstance(tasks, dict):
data["tasks"] = {}
return data
def _write(data: dict, path: Path | None = None) -> None:
target = _progress_file(path)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(target)
def _clean(value, limit: int = _MAX_FIELD) -> str:
return str(value or "").strip()[:limit]
def reported_totals(path: Path | None = None) -> dict[str, int]:
"""各任务的上报聚合数。**只含收到过上报的任务** —— 没上报过的项目不在
这里,调用方据此回退到 session 计数。"""
out: dict[str, int] = {}
with _LOCK:
data = _read(path)
for task_id, entry in (data.get("tasks") or {}).items():
if not isinstance(entry, dict):
continue
total = 0
for dev in (entry.get("devices") or {}).values():
if isinstance(dev, dict):
total += int(dev.get("count") or 0)
out[str(task_id)] = total
return out
def reported_total(task_id: str, path: Path | None = None) -> int | None:
"""单个任务的上报聚合数;从未上报过返回 None。"""
return reported_totals(path).get(str(task_id))
def needs_session_fallback(projects: list[dict],
reported: dict[str, int]) -> bool:
"""是否还有项目需要 session 计数兜底。
全都有上报时调用方可跳过目录扫描(远程存储上的一次全量遍历)。
"""
return any(str(p.get("id") or "") not in reported for p in projects)
def resolve_count(project: dict, reported: dict[str, int],
sessions: int) -> tuple[int, str]:
"""单个项目的 (current_count, 口径)。
有上报 → 上报聚合数(录制口径,上传不再影响它);
没有 → session 数(升级前的历史项目不归零)。
"""
pid = str(project.get("id") or "")
if pid in reported:
return int(reported[pid]), "reported"
return int(sessions or 0), "sessions"
def apply_increment(task_id: str, device_name: str, session_id: str,
increment: int, path: Path | None = None) -> dict:
"""把一次上报落账,返回该任务的最新聚合数。
幂等:同一 (task_id, device_name, session_id) 第二次进来只读取不累加
(``applied`` 为 False),采集端重试因此安全。
"""
task_id = _clean(task_id)
device_name = _clean(device_name)
session_id = _clean(session_id)
with _LOCK:
data = _read(path)
tasks = data.setdefault("tasks", {})
entry = tasks.setdefault(task_id, {"devices": {}})
if not isinstance(entry.get("devices"), dict):
entry["devices"] = {}
dev = entry["devices"].setdefault(
device_name, {"count": 0, "sessions": []})
sessions = dev.get("sessions")
if not isinstance(sessions, list):
sessions = dev["sessions"] = []
applied = False
if session_id not in sessions:
dev["count"] = int(dev.get("count") or 0) + int(increment)
sessions.append(session_id)
dev["updated_at"] = _now()
entry["updated_at"] = dev["updated_at"]
data["updated_at"] = dev["updated_at"]
applied = True
total = sum(int(d.get("count") or 0)
for d in entry["devices"].values() if isinstance(d, dict))
if applied:
_write(data, path)
return {"total": total, "applied": applied,
"device_count": int(dev.get("count") or 0),
"device": device_name, "task_id": task_id}
def apply_report(body: dict, known_task_ids: Iterable[str] | None = None,
path: Path | None = None) -> dict:
"""校验并落账一次上报,返回响应体(路由直接回给采集端)。
Raises:
ValueError: 参数缺失/非法/任务不存在 —— 调用方转 HTTP 400。
刻意**不返回 404**:采集端把 404 当成「后端还没实现该端点」并永久降级
(``_progress_supported = False``),此后整机所有任务都不再上报。参数错
与未知任务都属于「这次请求有问题」,用 400 表达即可。
"""
if not isinstance(body, dict):
raise ValueError("body must be a JSON object")
task_id = _clean(body.get("task_id"))
device_name = _clean(body.get("device_name"))
session_id = _clean(body.get("session_id"))
if not task_id:
raise ValueError("task_id required")
if not device_name:
raise ValueError("device_name required")
if not session_id:
raise ValueError("session_id required (idempotency key)")
raw = body.get("increment", 0)
if isinstance(raw, bool):
raise ValueError("increment must be an integer")
try:
increment = int(raw)
except (TypeError, ValueError):
raise ValueError("increment must be an integer")
if increment < 0:
raise ValueError("increment must be >= 0")
if increment > MAX_INCREMENT:
raise ValueError(f"increment too large (max {MAX_INCREMENT})")
if known_task_ids is not None and task_id not in set(known_task_ids):
raise ValueError(f"unknown task_id: {task_id}")
result = apply_increment(task_id, device_name, session_id, increment, path)
return {
"ok": True,
"task_id": task_id,
"device_name": device_name,
"applied": result["applied"],
"increment": increment,
# 采集端读这个字段当后端权威数 —— 必须是 int(布尔会被 isinstance 放行)
"completed_count": int(result["total"]),
"updated_at": _now(),
}
|