"""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)