File size: 15,561 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
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""Transport request-reply tối giản trên redis-py (bàn giao 14, 04/08/2026).

Tự viết thay vì rq/arq/celery — QUYẾT ĐỊNH của Cowork: deps chỉ thêm `redis`,
~100 dòng hiểu được toàn bộ. Giao thức:

    API    : LPUSH pc:jobs:recommend  '{"job_id": ..., "payload": ...}'
             BLPOP pc:result:{job_id}  (timeout → 503 phía route)
    worker : BRPOP pc:jobs:recommend → app.engine.handle_job(payload)
             LPUSH pc:result:{job_id} + EXPIRE (TTL dọn rác nếu API đã bỏ đi)

Mode theo env `REDIS_URL` — cùng nếp `DATABASE_URL` (app/db.py): không set →
"inprocess", app chạy Y HỆT bản không queue; set → "queue". KHÔNG âm thầm
fallback in-process khi queue chết — che chết worker tệ hơn lỗi rõ (BRIEF 1.4).

CHƯA có priority queue (realtime/TV FullVision §5.4 sẽ cần) — chừa chỗ bằng
tên list theo LOẠI job (`pc:jobs:recommend`) thay vì một list chung; thêm loại
job mới = thêm list mới, không đổi giao thức. Bàn giao 18 (05/08) dùng đúng
chỗ chừa đó: queue thứ hai `pc:jobs:scan` cho CV worker (scripts/cv_worker.py)
— các hàm nhận `jobs_key`/`key` tuỳ chọn, mặc định giữ nguyên queue recommend
nên mọi caller cũ không đổi một chữ.

Mọi lệnh Redis ở đây là BLOCKING (redis-py sync) — route phải gọi `submit`
qua `run_in_executor`, nhất quán với cách engine đang được gọi (BRIEF 2.4).
Các hàm phía worker (`serve_one`/`beat`/`clear_heartbeat`) nhận `client`
tuỳ chọn để test tiêm connection fakeredis THỨ HAI — hai connection như hai
process thật, còn worker thật dùng client của module sau `setup()`.
"""

from __future__ import annotations

import json
import os
import time
import uuid

import redis

JOBS_KEY = "pc:jobs:recommend"
RESULT_KEY = "pc:result:{job_id}"
HEARTBEAT_KEY = "pc:worker:heartbeat"

# CV scan (bàn giao 18): queue + heartbeat RIÊNG — engine worker chết không
# được làm health/scan tưởng CV worker chết và ngược lại. RESULT_KEY dùng
# chung được vì job_id là uuid — không đụng nhau giữa hai loại job.
SCAN_JOBS_KEY = "pc:jobs:scan"
SCAN_HEARTBEAT_KEY = "pc:worker:heartbeat:scan"

# Analyze clip (bàn giao 24): job DÀI (phút) nên KHÔNG đi đường request-reply
# `submit` (timeout 30s là cho job ngắn) — API `enqueue` fire-and-forget rồi
# poll status key; worker cập nhật status trong lúc chạy. Cùng CV worker với
# scan nên dùng chung SCAN_HEARTBEAT_KEY, chỉ thêm list job mới (đúng chỗ
# chừa "thêm loại job = thêm list" ở trên).
ANALYZE_JOBS_KEY = "pc:jobs:analyze"
ANALYZE_STATUS_KEY = "pc:analyze:{analyze_id}"
ANALYZE_STATUS_TTL_S = 3600   # kết quả sống 1h — đủ cho Danh đọc, tự dọn rác

# Segment video đa cú (lát A1, 13/08): job scan-toàn-video, cùng giao thức
# fire-and-forget + status key như analyze (cùng CV worker, cùng heartbeat).
# Status/tiến độ per VIDEO ở VIDEO_STATUS_KEY; kết quả BỀN (danh sách cú +
# JSON per cú) nằm trên FILE ngay khi có — Redis chỉ là kênh tiến độ, vì
# kết quả job từng MẤT THẬT khi chỉ nằm Redis TTL 1h (BG29b, 13/08).
SEGMENT_JOBS_KEY = "pc:jobs:segment"
VIDEO_STATUS_KEY = "pc:video:{video_id}"

RESULT_TTL_S = 60        # kết quả mồ côi (API đã timeout bỏ đi) tự bốc hơi
HEARTBEAT_TTL_S = 15     # worker SET mỗi ~5s → chết là health thấy trong ≤15s
DEFAULT_TIMEOUT_S = 30.0

# redis-py 8.x đặt socket_timeout MẶC ĐỊNH 5s cho MỌI lệnh (_defaults.py —
# đời 7.x là None). Hệ quả đã ĐO trên Redis thật 04/08: BLPOP/BRPOP block
# server-side >= 5s là client tự ném TimeoutError đúng mốc 5.0s, trước khi
# server kịp trả nil (fakeredis không có tầng socket nên test không thấy).
# Chống bằng cách chờ theo LÁT < SOCKET_TIMEOUT_S qua `_bwait` — KHÔNG BAO
# GIỜ gọi thẳng blpop/brpop với timeout dài. socket_timeout pin tường minh
# trong setup() để đổi mặc định thư viện không âm thầm đổi hành vi ở đây.
SOCKET_TIMEOUT_S = 5.0
WAIT_SLICE_S = 2.0


class QueueDown(Exception):
    """Không nói chuyện được với Redis (mất kết nối / chưa bật)."""


class QueueTimeout(Exception):
    """Job đã đẩy nhưng không có kết quả trong hạn — worker chết hoặc kẹt."""


_client: redis.Redis | None = None
_mode: str = "inprocess"


def setup(url: str | None = None, client: redis.Redis | None = None) -> str:
    """Khởi tạo transport từ `url`/env `REDIS_URL` (chạy thật) hoặc `client`
    tiêm sẵn (test dùng fakeredis). Trả về mode. Idempotent như db.setup().

    Mode thứ ba **"local"** (14/08/2026, việc B — Space HF một container):
    không có `REDIS_URL` mà env `POOLCOACH_LOCAL_CV` bật → transport chạy
    trên fakeredis TRONG TIẾN TRÌNH và app tự nuôi một luồng CV worker
    (app/localcv.py). Vì sao fakeredis chứ không tự viết bộ nhớ tạm: nó là
    ĐÚNG backend mà 400+ test đang chạy qua chính module này từ bàn giao 14
    — thêm một bản hiện thực thứ hai là thêm một chỗ để lệch. Vì sao KHÔNG
    gọi nó là "queue": mode queue nghĩa là `/api/recommend` phải đi engine
    worker; ở "local" thì engine vẫn IN-PROCESS như bản không queue, chỉ
    tầng CV đi hàng đợi. Thiếu fakeredis → về "inprocess", app không chết.
    """
    global _client, _mode
    teardown()
    if client is not None:
        _client, _mode = client, "queue"
        return _mode
    url = url or os.environ.get("REDIS_URL")
    if url:
        _client = redis.Redis.from_url(url, decode_responses=True,
                                       socket_timeout=SOCKET_TIMEOUT_S)
        _mode = "queue"
        return _mode
    if os.environ.get("POOLCOACH_LOCAL_CV", "").strip().lower() not in (
            "", "0", "false", "no"):
        try:
            import fakeredis
        except ImportError:
            print("[poolcoach] POOLCOACH_LOCAL_CV bat nhung thieu goi "
                  "fakeredis -- ve mode inprocess", flush=True)
            return _mode
        _client = fakeredis.FakeStrictRedis(decode_responses=True)
        _mode = "local"
    return _mode


def teardown() -> None:
    """Về mode inprocess (đóng connection nếu có)."""
    global _client, _mode
    if _client is not None:
        try:
            _client.close()
        except Exception:  # noqa: BLE001 — đóng lúc Redis đã chết vẫn phải xong
            pass
    _client = None
    _mode = "inprocess"


def mode() -> str:
    return _mode


def cv_enabled() -> bool:
    """Tầng CV (scan/analyze/segment) có đường chạy không — dùng cho route
    thay cho `mode() == "queue"`: cả "queue" (worker process) lẫn "local"
    (luồng nhúng) đều có worker phục vụ, chỉ "inprocess" là không."""
    return _mode in ("queue", "local")


def timeout_s() -> float:
    """Hạn chờ kết quả — đọc env MỖI lần (test override bằng monkeypatch)."""
    return float(os.environ.get("POOLCOACH_QUEUE_TIMEOUT_S",
                                DEFAULT_TIMEOUT_S))


def _require(client: redis.Redis | None = None) -> redis.Redis:
    c = client or _client
    if c is None:
        raise RuntimeError("transport chưa setup — mode inprocess không có "
                           "client Redis (check mode() trước)")
    return c


def _bwait(op, key: str, timeout: float):
    """BLPOP/BRPOP chờ tối đa `timeout`s bằng nhiều LÁT ngắn.

    Mỗi lát < SOCKET_TIMEOUT_S để server luôn kịp trả nil trước khi client
    đứt socket (xem chú thích SOCKET_TIMEOUT_S). Trả item hoặc None khi hết
    hạn; lỗi Redis ném nguyên cho caller phân loại.
    """
    deadline = time.monotonic() + timeout
    while True:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            return None
        # kẹp >= 0.05: BLPOP timeout 0 nghĩa là "chờ vô hạn" — không bao giờ
        # được truyền 0 xuống Redis (tràn deadline tối đa 50ms, vô hại)
        item = op(key, timeout=max(min(WAIT_SLICE_S, remaining), 0.05))
        if item is not None:
            return item


# ------------------------------------------------------------- phía API

def submit(payload: dict, jobs_key: str = JOBS_KEY) -> dict:
    """Đẩy MỘT job và chờ kết quả (blocking — gọi trong executor).

    Trả reply dict của worker (`{"ok": ...}` — xem engine.handle_job).
    Ném QueueDown khi Redis không nói chuyện được, QueueTimeout khi quá hạn
    `timeout_s()` không có kết quả (worker chết/kẹt — 503 phía route, KHÔNG
    fallback in-process). `jobs_key` chọn LOẠI job (mặc định recommend;
    scan dùng SCAN_JOBS_KEY) — cùng giao thức, khác list.
    """
    c = _require()
    job_id = uuid.uuid4().hex
    limit = timeout_s()
    try:
        c.lpush(jobs_key, json.dumps({"job_id": job_id, "payload": payload}))
        item = _bwait(c.blpop, RESULT_KEY.format(job_id=job_id), limit)
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e
    if item is None:
        raise QueueTimeout(f"không có kết quả sau {limit:g}s (job {job_id})")
    return json.loads(item[1])


def enqueue(payload: dict, jobs_key: str = JOBS_KEY,
            job_id: str | None = None) -> str:
    """Đẩy MỘT job KHÔNG chờ kết quả (bàn giao 24 — job dài kiểu analyze).

    Cùng wire format với `submit` ({"job_id", "payload"}) nên `serve_one`
    phía worker không đổi một chữ; khác là không BLPOP reply — tiến độ/kết
    quả đi đường status key (`set_analyze_status`). `job_id` truyền vào được
    để route dùng LUÔN nó làm id public (client poll GET bằng id này).
    Ném QueueDown khi Redis không nói chuyện được.
    """
    c = _require()
    job_id = job_id or uuid.uuid4().hex
    try:
        c.lpush(jobs_key, json.dumps({"job_id": job_id, "payload": payload}))
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e
    return job_id


def set_analyze_status(analyze_id: str, data: dict,
                       client: redis.Redis | None = None) -> None:
    """Ghi status job analyze (SET + TTL — key tự bốc hơi sau 1h).

    `data` là dict JSON-thuần {"status": queued|running|done|error, ...} —
    shape do route/worker thống nhất, transport không phán. Ném QueueDown
    khi Redis chết — caller quyết nuốt hay không (worker nuốt để job dài
    không chết vì một nhịp Redis rớt; route thì 503).
    """
    try:
        _require(client).set(ANALYZE_STATUS_KEY.format(analyze_id=analyze_id),
                             json.dumps(data), ex=ANALYZE_STATUS_TTL_S)
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e


def get_analyze_status(analyze_id: str) -> dict | None:
    """Đọc status job analyze — None nếu id lạ hoặc key đã hết TTL.
    Ném QueueDown khi Redis chết (route trả 503, không đổ oan 404)."""
    try:
        raw = _require().get(ANALYZE_STATUS_KEY.format(analyze_id=analyze_id))
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e
    return json.loads(raw) if raw else None


def set_video_status(video_id: str, data: dict,
                     client: redis.Redis | None = None) -> None:
    """Status job segment per VIDEO (lát A1) — cùng giao ước
    set_analyze_status: SET + TTL, shape do route/worker thống nhất, ném
    QueueDown khi Redis chết (worker nuốt, route 503)."""
    try:
        _require(client).set(VIDEO_STATUS_KEY.format(video_id=video_id),
                             json.dumps(data), ex=ANALYZE_STATUS_TTL_S)
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e


def get_video_status(video_id: str) -> dict | None:
    """Đọc status job segment — None nếu id lạ hoặc hết TTL (danh sách cú
    BỀN nằm trên file, route tự đọc tiếp — Redis chỉ là kênh tiến độ)."""
    try:
        raw = _require().get(VIDEO_STATUS_KEY.format(video_id=video_id))
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e
    return json.loads(raw) if raw else None


def ping() -> bool:
    """Redis còn sống? — cho /api/health, nuốt lỗi (False là câu trả lời)."""
    try:
        return bool(_require().ping())
    except redis.RedisError:
        return False


def worker_alive(key: str = HEARTBEAT_KEY) -> bool:
    """Heartbeat worker còn hạn? — cho /api/health, nuốt lỗi như ping().
    `key` chọn worker (mặc định engine; CV dùng SCAN_HEARTBEAT_KEY)."""
    try:
        return _require().exists(key) == 1
    except redis.RedisError:
        return False


def worker_alive_or_raise(key: str = HEARTBEAT_KEY) -> bool:
    """Heartbeat check TRƯỚC enqueue (fast-fail, bàn giao 19) — KHÁC
    `worker_alive`: Redis chết phải NÉM QueueDown để route trả đúng message
    "Redis" như đường submit (nuốt thành False sẽ đổ oan cho worker). Lệnh
    Redis blocking như mọi lệnh khác — route gọi qua executor."""
    try:
        return _require().exists(key) == 1
    except redis.RedisError as e:
        raise QueueDown(f"{type(e).__name__}: {e}") from e


# ----------------------------------------------------------- phía worker

def serve_one(handler, timeout_s: float = 5.0,
              client: redis.Redis | None = None,
              jobs_key: str = JOBS_KEY) -> bool:
    """MỘT vòng BRPOP → ``handler(payload)`` → reply + TTL.

    Trả False nếu hết `timeout_s` không có job — vòng ngoài của worker nhân
    đó beat heartbeat / bắt Ctrl+C. Job kẹt lại từ lúc worker chết vẫn được
    xử lý khi worker sống lại (reply mồ côi tự hết hạn theo RESULT_TTL_S) —
    chấp nhận tốn vài giây sim thay vì thêm deadline vào giao thức.
    `jobs_key` chọn LOẠI job như bên `submit`.
    """
    c = _require(client)
    item = _bwait(c.brpop, jobs_key, timeout_s)
    if item is None:
        return False
    job = json.loads(item[1])
    reply = handler(job["payload"])
    key = RESULT_KEY.format(job_id=job["job_id"])
    pipe = c.pipeline()
    pipe.lpush(key, json.dumps(reply))
    pipe.expire(key, RESULT_TTL_S)
    pipe.execute()
    return True


def beat(client: redis.Redis | None = None,
         key: str = HEARTBEAT_KEY) -> None:
    """Worker báo sống — CHỈ gọi sau khi JIT/model-load xong (bối cảnh #3:
    health không được báo alive lúc còn đang khởi động). `key` là heartbeat
    của TỪNG worker (engine mặc định; CV dùng SCAN_HEARTBEAT_KEY)."""
    _require(client).set(key, "alive", ex=HEARTBEAT_TTL_S)


def clear_heartbeat(client: redis.Redis | None = None,
                    key: str = HEARTBEAT_KEY) -> None:
    """Worker thoát sạch (Ctrl+C) — xoá heartbeat để health thấy chết NGAY,
    không đợi TTL. Redis chết lúc này thì thôi — TTL sẽ dọn."""
    try:
        _require(client).delete(key)
    except redis.RedisError:
        pass