Spaces:
Sleeping
Sleeping
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
|