mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat: propagate request correlation context
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""请求与后台工作共用的关联 ID 上下文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, Callable, Iterator, Mapping
|
||||
|
||||
CORRELATION_ID_HEADER = "X-Request-ID"
|
||||
MAX_CORRELATION_ID_LENGTH = 64
|
||||
_VALID_CORRELATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")
|
||||
_CURRENT_CORRELATION_ID: ContextVar[str | None] = ContextVar(
|
||||
"moviepilot_correlation_id",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def normalize_correlation_id(candidate: str | None) -> str:
|
||||
"""接受安全的调用方 ID;非法、超长或缺失值均替换为随机 ID。"""
|
||||
if candidate and _VALID_CORRELATION_ID.fullmatch(candidate):
|
||||
return candidate
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def get_correlation_id(default: str | None = None) -> str | None:
|
||||
"""返回当前执行上下文中的关联 ID。"""
|
||||
return _CURRENT_CORRELATION_ID.get() or default
|
||||
|
||||
|
||||
def set_correlation_id(correlation_id: str) -> Token[str | None]:
|
||||
"""设置当前关联 ID,并返回供调用方精确恢复的 token。"""
|
||||
return _CURRENT_CORRELATION_ID.set(correlation_id)
|
||||
|
||||
|
||||
def reset_correlation_id(token: Token[str | None]) -> None:
|
||||
"""恢复设置关联 ID 之前的上下文。"""
|
||||
_CURRENT_CORRELATION_ID.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def correlation_scope(correlation_id: str | None) -> Iterator[str | None]:
|
||||
"""在当前同步或异步任务作用域内绑定并自动恢复关联 ID。"""
|
||||
if correlation_id is None:
|
||||
yield None
|
||||
return
|
||||
token = set_correlation_id(correlation_id)
|
||||
try:
|
||||
yield correlation_id
|
||||
finally:
|
||||
reset_correlation_id(token)
|
||||
|
||||
|
||||
def with_correlation_header(headers: Mapping[str, str] | None) -> dict[str, str]:
|
||||
"""复制请求头并在调用方未显式指定时加入当前关联 ID。"""
|
||||
result = dict(headers or {})
|
||||
if any(key.lower() == CORRELATION_ID_HEADER.lower() for key in result):
|
||||
return result
|
||||
correlation_id = get_correlation_id()
|
||||
if correlation_id:
|
||||
result[CORRELATION_ID_HEADER] = correlation_id
|
||||
return result
|
||||
|
||||
|
||||
def call_with_correlation(
|
||||
correlation_id: str | None,
|
||||
func: Callable[..., Any],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
"""从可序列化参数恢复关联 ID 后调用函数,供子进程入口使用。"""
|
||||
with correlation_scope(correlation_id):
|
||||
return func(*args, **kwargs)
|
||||
@@ -12,6 +12,7 @@ from app.runtime.event.binding import EventBindingResolver
|
||||
from app.runtime.event.registry import EventRegistry
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.correlation import correlation_scope
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
@@ -114,6 +115,7 @@ class EventDispatcher:
|
||||
event_type=event.event_type,
|
||||
event_data=event_data,
|
||||
priority=event.priority,
|
||||
correlation_id=event.correlation_id,
|
||||
)
|
||||
if inspect.iscoroutinefunction(handler):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
@@ -143,16 +145,17 @@ class EventDispatcher:
|
||||
if not resolved:
|
||||
return
|
||||
method, binding, class_name, method_name = resolved
|
||||
try:
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
module_name=binding.owner_name,
|
||||
class_name=class_name,
|
||||
method_name=method_name,
|
||||
e=err,
|
||||
)
|
||||
with correlation_scope(event.correlation_id):
|
||||
try:
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
module_name=binding.owner_name,
|
||||
class_name=class_name,
|
||||
method_name=method_name,
|
||||
e=err,
|
||||
)
|
||||
|
||||
async def invoke_async(self, handler: Callable, event: Any) -> None:
|
||||
"""解析实例绑定,并按处理器类型选择协程、线程池或同步调用。"""
|
||||
@@ -160,21 +163,22 @@ class EventDispatcher:
|
||||
if not resolved:
|
||||
return
|
||||
method, binding, class_name, method_name = resolved
|
||||
try:
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
elif binding.run_sync_in_threadpool or not class_name:
|
||||
await run_in_threadpool(method, event)
|
||||
else:
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
module_name=binding.owner_name,
|
||||
class_name=class_name,
|
||||
method_name=method_name,
|
||||
e=err,
|
||||
)
|
||||
with correlation_scope(event.correlation_id):
|
||||
try:
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
elif binding.run_sync_in_threadpool or not class_name:
|
||||
await run_in_threadpool(method, event)
|
||||
else:
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
module_name=binding.owner_name,
|
||||
class_name=class_name,
|
||||
method_name=method_name,
|
||||
e=err,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def should_dispatch_to_target_plugin(
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.runtime.event.dispatch import EventDispatcher
|
||||
from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
|
||||
from app.runtime.event.registry import EventRegistry
|
||||
from app.runtime.event.contracts import validate_event_payload
|
||||
from app.runtime.correlation import get_correlation_id
|
||||
|
||||
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
||||
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
||||
@@ -35,11 +36,13 @@ class Event:
|
||||
|
||||
def __init__(self, event_type: Union[EventType, ChainEventType],
|
||||
event_data: Optional[Union[Dict, ChainEventData]] = None,
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY,
|
||||
correlation_id: Optional[str] = None):
|
||||
"""
|
||||
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
|
||||
:param event_data: 可选,事件携带的数据,默认为空字典
|
||||
:param priority: 可选,事件的优先级,默认为 10
|
||||
:param correlation_id: 生产事件时固化的请求关联 ID
|
||||
"""
|
||||
payload_problems = validate_event_payload(event_type, event_data)
|
||||
if payload_problems:
|
||||
@@ -52,6 +55,7 @@ class Event:
|
||||
self.event_type = event_type # 事件类型
|
||||
self.event_data = event_data or {} # 事件数据
|
||||
self.priority = priority # 事件优先级
|
||||
self.correlation_id = correlation_id or get_correlation_id()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
from contextvars import copy_context
|
||||
from functools import partial, wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -16,7 +17,8 @@ async def run_in_threadpool(
|
||||
"""在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。"""
|
||||
if kwargs:
|
||||
func = partial(func, **kwargs)
|
||||
return await run_sync(func, *args)
|
||||
context = copy_context()
|
||||
return await run_sync(context.run, func, *args)
|
||||
|
||||
|
||||
def retry(ExceptionToCheck: Any,
|
||||
|
||||
+24
-5
@@ -12,12 +12,11 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
from typing import Any, Callable, Dict, Optional, Protocol
|
||||
|
||||
import click
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class LogConfigModel(BaseModel):
|
||||
"""描述日志级别、格式和文件写入策略。"""
|
||||
|
||||
@@ -28,8 +27,12 @@ class LogConfigModel(BaseModel):
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_MAX_FILE_SIZE: int = 5
|
||||
LOG_BACKUP_COUNT: int = 10
|
||||
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
|
||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||
LOG_CONSOLE_FORMAT: str = (
|
||||
"%(leveltext)s[%(name)s] %(asctime)s [%(correlation_id)s] %(message)s"
|
||||
)
|
||||
LOG_FILE_FORMAT: str = (
|
||||
"【%(levelname)s】%(asctime)s [%(correlation_id)s] - %(message)s"
|
||||
)
|
||||
ASYNC_FILE_QUEUE_SIZE: int = 1000
|
||||
ASYNC_FILE_WORKERS: int = 2
|
||||
BATCH_WRITE_SIZE: int = 50
|
||||
@@ -60,6 +63,7 @@ class LogEntry:
|
||||
self.message = message
|
||||
self.file_path = file_path
|
||||
self.timestamp = timestamp or datetime.now()
|
||||
self.correlation_id = _get_log_correlation_id()
|
||||
|
||||
|
||||
class LogWriter(Protocol):
|
||||
@@ -73,6 +77,18 @@ class LogWriter(Protocol):
|
||||
|
||||
|
||||
log_settings = LogSettings()
|
||||
_correlation_id_provider: Callable[[], str | None] = lambda: None
|
||||
|
||||
|
||||
def configure_correlation_id_provider(provider: Callable[[], str | None]) -> None:
|
||||
"""由组合根注入日志关联 ID 读取端口,保持日志模块为依赖叶节点。"""
|
||||
global _correlation_id_provider
|
||||
_correlation_id_provider = provider
|
||||
|
||||
|
||||
def _get_log_correlation_id() -> str:
|
||||
"""读取当前关联 ID;未装配或无请求上下文时返回稳定占位符。"""
|
||||
return _correlation_id_provider() or "-"
|
||||
|
||||
|
||||
class NonBlockingFileHandler:
|
||||
@@ -169,7 +185,7 @@ class NonBlockingFileHandler:
|
||||
@staticmethod
|
||||
def _to_record(entry: LogEntry) -> logging.LogRecord:
|
||||
"""把日志条目转换为标准库日志记录。"""
|
||||
return logging.LogRecord(
|
||||
record = logging.LogRecord(
|
||||
name="",
|
||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||
pathname="",
|
||||
@@ -179,6 +195,8 @@ class NonBlockingFileHandler:
|
||||
exc_info=None,
|
||||
created=entry.timestamp.timestamp(),
|
||||
)
|
||||
record.correlation_id = entry.correlation_id
|
||||
return record
|
||||
|
||||
def _batch_writer(self) -> None:
|
||||
"""持续收集队列日志,并在停止哨兵后排空已有批次。"""
|
||||
@@ -258,6 +276,7 @@ class CustomFormatter(logging.Formatter):
|
||||
separator = " " * max(8 - len(record.levelname), 0)
|
||||
colorizer = _LEVEL_NAME_COLORS.get(record.levelno, str)
|
||||
record.leveltext = colorizer(record.levelname + ":") + separator
|
||||
record.correlation_id = _get_log_correlation_id()
|
||||
return super().format(record)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextvars import copy_context
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.foundation.singleton import Singleton
|
||||
@@ -20,7 +21,8 @@ class ThreadHelper(metaclass=Singleton):
|
||||
:param kwargs: 参数
|
||||
:return: future
|
||||
"""
|
||||
return self.pool.submit(func, *args, **kwargs)
|
||||
context = copy_context()
|
||||
return self.pool.submit(context.run, func, *args, **kwargs)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user