wardrobe-os-ml / src /lib /background.py
fengchen31's picture
Sync from GitHub refs/heads/main @ 3da1b47
b288a25 verified
Raw
History Blame Contribute Delete
5.9 kB
"""Process-lifetime fire-and-forget asyncio task helper.
【為什麼存在?】
某些 endpoint 想 kick off 慢的 side-effect 卻不要 block response。直接
`asyncio.create_task(coro)` 會回 Task,但 event loop 只 weakref 著它;
caller 沒留 strong ref 的話 GC 可能在 await 中途 finalize 它,工作
silently 消失(CPython 實作層細節,文件有寫)。
【pattern】
建 task 後立刻塞進 process-lifetime 的 set;task 結束時 done callback
把它從 set 拿掉。caller 完全不需要 await 或檢查狀態 — 真正的
fire-and-forget。
【目前 caller】
- /api/v1/ml/valuation/predict (F4 v2 (A) CR-3 fix) — observation pipeline
做 ~3s SSRF guard + httpx download + torch forward + psycopg INSERT。
await 它讓每個帶 image_url 的 predict call 多 ~3s latency 在 critical path。
record_observation 已經 universally fail-open(每環節 try/except → return
without raising),把 await 換成 fire_and_forget 的失誤代價是「worker
SIGKILL 時 in-flight task 丟失」,跟原 sync 版的 worker SIGKILL 失誤代價
一樣(兩者都不 durable)。
【為什麼不用 FastAPI BackgroundTasks?】
BackgroundTasks 的語意是「response 送出後執行」,會等 response 寫完 socket
才開始 — 我們要的是「prediction 算完同時並行寫 observation」,create_task
能讓 record_observation 的 download 部分在 prediction return 之前先跑。
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Coroutine
from typing import Any
logger = logging.getLogger(__name__)
# Strong-ref store: 沒有它 GC 可能在 task await 中途 collect。
# 模組級 set,process lifetime 共用。
_BACKGROUND_TASKS: set[asyncio.Task[Any]] = set()
def fire_and_forget(
coro: Coroutine[Any, Any, Any],
*,
name: str | None = None,
) -> asyncio.Task[Any]:
"""排程 `coro` 為 background task,保留 strong ref 直到結束。
回傳的 Task 給 test / introspection 用;prod caller 可以丟掉不接。
"""
task = asyncio.create_task(coro, name=name)
_BACKGROUND_TASKS.add(task)
task.add_done_callback(_BACKGROUND_TASKS.discard)
task.add_done_callback(_log_unhandled_exception)
return task
def _log_unhandled_exception(task: asyncio.Task[Any]) -> None:
"""把 task 內未處理的例外寫到 log + Sentry,不然會被 asyncio runtime 默默吞掉。
record_observation 文件保證不 raise,但未來新 caller 可能傳會 raise
的 coroutine。done callback 在 task 結束後跑;呼叫 .exception() 會
consume 例外(避免 "Task exception was never retrieved" warning),
同時 log warning 讓 observability 看得到。
Sentry capture 是 batch-3 post-review 補的(security-reviewer P2 conf 80):
sentry-sdk 2.x 的 AsyncioIntegration 只在「task exception 沒被 retrieve」
時 hook 進來。我們在這個 callback 裡顯式呼叫 task.exception() 把例外
consume 掉了,等於主動關掉 AsyncioIntegration 的捕獲路徑。
需要顯式 capture 才能讓 fire-and-forget escapes 進到 Sentry 告警鏈
(ml-service.error-rate-spike / ml-service.5xx-rate)。
F3 deferred sweep(2026-05-03)—— 直接 capture_exception(exc) 會把
exc.args(psycopg OperationalError 的 Neon DSN / huggingface_hub 401 的
hf_ token)原樣寫進 event 的 exception.values[].value。Sentry-only
reader 拿到 plaintext credentials。改走 capture_message +
scrub_exc_for_audit() opaque label。Sibling site: main.py:_on_preload_done。
memory: feedback_python_sentry_capture_needs_scrub。
sentry_sdk import 是 lazy 的:DSN 沒設時 init_sentry 從沒呼叫過
sentry_sdk.init,capture_message 是 no-op;DSN 有設時就會 capture。
都不會 raise,所以這條 path 對 caller 完全透明。
"""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
# Local logger 仍寫 unredacted exc — operator 從 Render / HF Spaces
# stdout 撈 trace。Sentry 只見 scrubbed label。
logger.warning(
"background task %s raised: %s",
task.get_name(),
exc,
)
try:
import sentry_sdk
from src.lib.error_scrub import scrub_exc_for_audit
# F2/F5 closer (c-series wave-2, hardened in wave-3):必須用
# isolation_scope,不是 new_scope。sentry-sdk Python 2.x scope 模型
# 拆三層(global / isolation / current),breadcrumb 寫在 isolation
# scope;上面 logger.warning("...%s", exc) 經 LoggingIntegration
# level=INFO 把 str(exc) 塞進 ambient isolation breadcrumb buffer。
# `new_scope()` 只 fork current scope,clear_breadcrumbs 對 isolation
# scope 沒效(empirically verified sentry-sdk 2.58.0:event 仍夾
# ambient breadcrumb);`isolation_scope()` fork isolation 才真清。
# 鏡像 TS `Sentry.withScope`(TS scope 不分層)。
# memory: feedback_sentry_breadcrumb_bypasses_message_scrub。
with sentry_sdk.isolation_scope() as scope:
scope.clear_breadcrumbs()
sentry_sdk.capture_message(
f"background-task-failed[{task.get_name()}]: "
f"{scrub_exc_for_audit(exc)}",
level="error",
)
except Exception:
# Sentry 失敗不 escalate(done_callback 內部)— logger 已經
# 留下 trail,operator 仍可從 Render/HF Spaces stdout 撈到。
pass
def background_task_count() -> int:
"""Test-only — 回 pending background task 數量。"""
return len(_BACKGROUND_TASKS)