Files
FishServer/fish_api/app/services/measure_watch.py

168 lines
5.7 KiB
Python
Raw Normal View History

"""后台轮询目录中的 .svo2跑 FishMeasure写入 SQLite与 ingest 共用)。"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Dict, Set
from loguru import logger
2026-04-10 10:30:01 +08:00
from app.db import (
add_watch_processed,
load_watch_processed,
measure_snapshot_deliverable,
save_measure_snapshot,
)
from app.services import measure as measure_svc
from app.settings import Settings
2026-04-10 10:30:01 +08:00
from app.state import app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
_MEASURE_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
"FISH_MEASURE_WATCH_IDLE_WARN_INTERVAL_SEC"
)
def _state_path(settings: Settings) -> Path:
if settings.measure_watch_state_file is not None:
return settings.measure_watch_state_file
assert settings.measure_watch_dir is not None
return settings.measure_watch_dir / ".fishmeasure_watch_processed.json"
def iter_svo2(watch_dir: Path, recursive: bool) -> list[Path]:
if recursive:
return sorted(
p
for p in watch_dir.rglob("*")
if p.is_file() and p.suffix.lower() == ".svo2"
)
return sorted(
p
for p in watch_dir.iterdir()
if p.is_file() and p.suffix.lower() == ".svo2"
)
async def _run_measure_and_state(
svo: Path,
settings: Settings,
processed: Set[str],
state_file: Path,
) -> None:
key = str(svo.resolve())
if key in processed:
return
logger.info("[measure-watch] inference: {}", svo)
async with app_state.measure_lock:
app_state.measure_status = "running"
try:
snap = await asyncio.to_thread(measure_svc.run_full_measure, svo, settings)
2026-04-10 10:30:01 +08:00
if measure_snapshot_deliverable(snap):
save_measure_snapshot(settings, snap, source_path=key)
else:
logger.warning(
"[measure-watch] no deliverable measure rows for {}, skip SQLite",
svo.name,
)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
r0 = snap.result[0] if snap.result else {}
w = r0.get("weight", "")
logger.info("[measure-watch] done: {} weight={!r}", svo.name, w)
2026-04-10 10:30:01 +08:00
except (RuntimeError, FileNotFoundError) as e:
# FishMeasure 常见失败:无点云、缺 JSON 等,避免整段 traceback 刷屏
logger.warning("[measure-watch] measure failed for {}: {}", svo.name, e)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
except Exception as e:
logger.exception("[measure-watch] error on {}: {}", svo, e)
2026-04-10 10:30:01 +08:00
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
async def watch_tick(
settings: Settings,
processed: Set[str],
stability: Dict[str, tuple[int, int]],
state_file: Path,
) -> bool:
assert settings.measure_watch_dir is not None
watch_dir = settings.measure_watch_dir
did = False
seen_keys: Set[str] = set()
for svo in iter_svo2(watch_dir, settings.measure_watch_recursive):
key = str(svo.resolve())
seen_keys.add(key)
if key in processed:
continue
try:
st = svo.stat()
except OSError:
continue
size = int(st.st_size)
if size <= 0:
stability.pop(key, None)
continue
last = stability.get(key)
if last is None or last[0] != size:
stability[key] = (size, 1)
else:
_, cnt = last
stability[key] = (size, cnt + 1)
_, cnt = stability[key]
if cnt >= settings.measure_watch_stable_polls:
2026-04-10 10:30:01 +08:00
await _run_measure_and_state(svo, settings, processed, state_file)
stability.pop(key, None)
did = True
for k in list(stability.keys()):
if k not in seen_keys:
del stability[k]
return did
async def run_measure_watch_loop(settings: Settings) -> None:
assert settings.measure_watch_dir is not None
wd = settings.measure_watch_dir
if not wd.is_dir():
logger.warning("[measure-watch] skip: not a directory: {}", wd)
return
state_file = _state_path(settings)
processed: Set[str] = (
load_watch_processed(settings, state_file, "measure")
if settings.measure_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
logger.info(
"[measure-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
wd,
settings.measure_watch_poll_interval,
settings.measure_watch_stable_polls,
"on" if settings.measure_watch_use_state_file else "off",
state_file if settings.measure_watch_use_state_file else "",
)
idle_warn_state = IdleWatchWarnState()
while True:
did = await watch_tick(settings, processed, stability, state_file)
maybe_warn_idle_watch(
did_work=did,
log_tag="measure-watch",
algo_name="FishMeasure",
idle_hint="目录内无 .svo2、已全部处理完毕或文件尚未达到稳定轮询次数",
watch_dir=wd,
state=idle_warn_state,
interval_sec=_MEASURE_IDLE_WARN_INTERVAL_SEC,
)
await asyncio.sleep(max(settings.measure_watch_poll_interval, 0.1))