File size: 5,069 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""CV worker chạy TRONG tiến trình app — mode "local" (14/08/2026, việc B).

Vì sao tồn tại: Space HF free là MỘT container. Bản deploy 23/07 chỉ có app
gợi ý nên không cần gì thêm, còn Analyzer cần detector + ShotNet + segment.
Ba đường đi được cân:

1. Cài redis-server + chạy cv_worker (và cả engine_worker) làm tiến trình
   phụ trong container — giữ nguyên code path, nhưng bật ``REDIS_URL`` là
   ``/api/recommend`` cũng đòi engine worker, thành ra phải nuôi BA tiến
   trình + một binary Redis, và hai lần JIT pooltool trên 2 vCPU.
2. Viết một hàng đợi in-memory mới — thêm một bản hiện thực thứ hai của
   transport, tức thêm một chỗ để lệch khỏi bản Redis đã nghiệm thu.
3. (chọn) fakeredis trong tiến trình + LUỒNG worker gọi ĐÚNG hàm của
   ``scripts/cv_worker.py``. Không tiến trình phụ, không binary, không bản
   sao logic: ``handle_scan``/``handle_analyze``/``handle_segment`` và vòng
   ``serve_forever`` là CÙNG code chạy trên máy có GPU.

Đánh đổi đã biết, ghi rõ để đừng ngộ nhận: một luồng phục vụ tuần tự, nên
đúng như bản Redis "trong lúc một analyze chạy, scan job phải chờ" — demo
một người dùng (nếp BG24). GIL nhả trong numpy/torch/cv2 nên API vẫn trả
lời được trong lúc worker chạy, nhưng có tranh CPU: số đo thời gian trên
Space KHÔNG so ngang với số đo worker riêng máy GPU.

Bật bằng env ``POOLCOACH_LOCAL_CV=1`` (xem ``jobqueue.setup``). Không bật
thì file này không được import.
"""

from __future__ import annotations

import importlib.util
import os
import sys
import threading
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

_thread: threading.Thread | None = None
_stop = threading.Event()
_state: dict = {"stage": "off", "error": None, "since": None}


def state() -> dict:
    """Trạng thái luồng worker nhúng — cho log/health đọc, không cho FE."""
    return dict(_state)


def _load_worker_module():
    """Nạp ``scripts/cv_worker.py`` như một module.

    Import theo ĐƯỜNG DẪN vì repo không cài package và ``scripts/`` không
    phải package (quy ước repo — xem CLAUDE.md). Chỉ nạp, không gọi
    ``main()``: mọi thứ mức module của cv_worker là đẩy sys.path + import
    numpy/jobqueue/poolcoach_cv, an toàn khi nạp trong app.
    """
    name = "poolcoach_cv_worker"
    if name in sys.modules:
        return sys.modules[name]
    path = ROOT / "scripts" / "cv_worker.py"
    spec = importlib.util.spec_from_file_location(name, path)
    if spec is None or spec.loader is None:
        raise ImportError(f"không nạp được {path}")
    mod = importlib.util.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod


def _run() -> None:
    t0 = time.time()
    try:
        _state.update(stage="loading", error=None)
        cw = _load_worker_module()
        conf = float(os.environ.get("POOLCOACH_CV_CONF", cw.OP_CONF))
        device = os.environ.get("POOLCOACH_CV_DEVICE", "")
        predict_kw = {"device": device} if device else {}
        model, shotnet, resim = cw.load_all(conf, device, predict_kw)
        _state.update(stage="serving", since=round(time.time() - t0, 1))
        print(f"[poolcoach] CV worker nhung san sang sau "
              f"{time.time() - t0:.1f}s -- tab Analyzer dung duoc",
              flush=True)
        cw.serve_forever(model, conf, predict_kw, shotnet, resim,
                         should_stop=_stop.is_set)
    except BaseException as e:  # noqa: BLE001 — luồng chết ≠ app chết
        _state.update(stage="error", error=f"{type(e).__name__}: {e}")
        print(f"[poolcoach] LOI CV worker nhung: {type(e).__name__}: {e} "
              f"-- tab Analyzer se bao 'CV worker khong chay', phan con lai "
              f"cua app van chay", flush=True)
    finally:
        if _state.get("stage") != "error":
            _state.update(stage="stopped")


def start() -> None:
    """Bật luồng worker nhúng (daemon). Gọi ở lifespan khi mode == local.

    Không chặn startup: nạp YOLO + ShotNet + JIT pooltool mất hàng chục
    giây, nhưng trang danh sách cú của video mẫu ĐÃ PHÂN TÍCH SẴN đọc từ
    file nên dùng được ngay — heartbeat chỉ bật sau khi nạp xong, đúng nếp
    "không báo alive lúc còn đang khởi động".
    """
    global _thread
    if _thread is not None and _thread.is_alive():
        return
    _stop.clear()
    _thread = threading.Thread(target=_run, daemon=True,
                               name="poolcoach-localcv")
    _thread.start()


def stop(timeout: float = 10.0) -> None:
    """Xin luồng dừng ở vòng lặp kế tiếp (job đang chạy được chạy nốt)."""
    _stop.set()
    if _thread is not None and _thread.is_alive():
        _thread.join(timeout=timeout)