mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
255 lines
8.7 KiB
Python
255 lines
8.7 KiB
Python
"""同步数据库短事务的异步执行器。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import threading
|
||
import time
|
||
from concurrent.futures import Future, ThreadPoolExecutor
|
||
from contextvars import copy_context
|
||
from dataclasses import dataclass
|
||
from typing import Callable, TypeVar
|
||
|
||
from app.application.database import (
|
||
DatabaseWorkerClosedError,
|
||
DatabaseWorkerOverloadedError,
|
||
)
|
||
from app.runtime.observability import record_metric
|
||
|
||
|
||
T = TypeVar("T")
|
||
|
||
DATABASE_WORKER_MAX_WORKERS = 4
|
||
DATABASE_WORKER_CAPACITY = 32
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class DatabaseWorkerStats:
|
||
"""数据库执行器当前的容量和任务数量。"""
|
||
|
||
max_workers: int
|
||
capacity: int
|
||
queued: int
|
||
running: int
|
||
rejected: int
|
||
closing: bool
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _WorkItem:
|
||
"""记录一个任务的排队时间和执行状态。"""
|
||
|
||
submitted_at: float
|
||
started_at: float | None = None
|
||
|
||
|
||
class DatabaseWorker:
|
||
"""以有限线程和队列执行不能原生异步化的数据库短事务。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
max_workers: int = DATABASE_WORKER_MAX_WORKERS,
|
||
capacity: int = DATABASE_WORKER_CAPACITY,
|
||
) -> None:
|
||
"""保存容量配置,线程只在显式启动后创建。"""
|
||
if max_workers < 1:
|
||
raise ValueError("数据库 worker 线程数必须大于 0")
|
||
if capacity < max_workers:
|
||
raise ValueError("数据库 worker 总容量不能小于线程数")
|
||
self._max_workers = max_workers
|
||
self._capacity = capacity
|
||
self._state_lock = threading.Lock()
|
||
self._loop: asyncio.AbstractEventLoop | None = None
|
||
self._executor: ThreadPoolExecutor | None = None
|
||
self._futures: dict[
|
||
Future[object], tuple[asyncio.Future[object], _WorkItem]
|
||
] = {}
|
||
self._queued = 0
|
||
self._running = 0
|
||
self._reported_queued = 0
|
||
self._reported_running = 0
|
||
self._rejected = 0
|
||
self._closing = False
|
||
|
||
async def start(self) -> None:
|
||
"""绑定当前事件循环并准备专属线程池。"""
|
||
if self._executor is not None:
|
||
if self._loop is not asyncio.get_running_loop():
|
||
raise RuntimeError("数据库 worker 不能跨事件循环复用")
|
||
return
|
||
self._loop = asyncio.get_running_loop()
|
||
self._executor = ThreadPoolExecutor(
|
||
max_workers=self._max_workers,
|
||
thread_name_prefix="moviepilot-db",
|
||
)
|
||
self._closing = False
|
||
self._record_depth()
|
||
|
||
def snapshot(self) -> DatabaseWorkerStats:
|
||
"""返回无需访问任务对象的低基数运行快照。"""
|
||
with self._state_lock:
|
||
return DatabaseWorkerStats(
|
||
max_workers=self._max_workers,
|
||
capacity=self._capacity,
|
||
queued=self._queued,
|
||
running=self._running,
|
||
rejected=self._rejected,
|
||
closing=self._closing,
|
||
)
|
||
|
||
async def run(self, operation: Callable[[], T]) -> T:
|
||
"""执行短事务,取消时仍等待已开始的事务取得最终结果。"""
|
||
loop = asyncio.get_running_loop()
|
||
executor = self._executor
|
||
if executor is None or self._loop is not loop or self._closing:
|
||
raise DatabaseWorkerClosedError("数据库 worker 当前不可接收任务")
|
||
|
||
with self._state_lock:
|
||
if self._queued + self._running >= self._capacity:
|
||
self._rejected += 1
|
||
record_metric("db.worker.rejected")
|
||
raise DatabaseWorkerOverloadedError(
|
||
f"数据库 worker 容量已用尽(上限 {self._capacity})"
|
||
)
|
||
self._queued += 1
|
||
|
||
item = _WorkItem(submitted_at=time.perf_counter())
|
||
context = copy_context()
|
||
try:
|
||
future = executor.submit(self._execute, item, context.run, operation)
|
||
except BaseException:
|
||
with self._state_lock:
|
||
self._queued -= 1
|
||
self._record_depth()
|
||
raise
|
||
|
||
wrapped = asyncio.wrap_future(future, loop=loop)
|
||
with self._state_lock:
|
||
self._futures[future] = (wrapped, item)
|
||
future.add_done_callback(
|
||
lambda completed: self._schedule_completion(completed, item)
|
||
)
|
||
self._record_depth()
|
||
|
||
try:
|
||
return await asyncio.shield(wrapped)
|
||
except asyncio.CancelledError:
|
||
if not future.cancel():
|
||
await self._wait_until_done(wrapped)
|
||
if wrapped.done() and not wrapped.cancelled():
|
||
wrapped.exception()
|
||
raise
|
||
|
||
def _execute(
|
||
self,
|
||
item: _WorkItem,
|
||
context_run: Callable[..., T],
|
||
operation: Callable[[], T],
|
||
) -> T:
|
||
"""在线程中标记任务开始并保留提交时的上下文。"""
|
||
item.started_at = time.perf_counter()
|
||
loop = self._loop
|
||
if loop is not None:
|
||
try:
|
||
loop.call_soon_threadsafe(self._mark_running, item)
|
||
except RuntimeError:
|
||
pass
|
||
return context_run(operation)
|
||
|
||
def _mark_running(self, item: _WorkItem) -> None:
|
||
"""把任务从排队状态移入运行状态。"""
|
||
with self._state_lock:
|
||
self._queued -= 1
|
||
self._running += 1
|
||
started_at = item.started_at or time.perf_counter()
|
||
record_metric("db.worker.wait", started_at - item.submitted_at)
|
||
self._record_depth()
|
||
|
||
def _schedule_completion(
|
||
self,
|
||
future: Future[object],
|
||
item: _WorkItem,
|
||
) -> None:
|
||
"""把线程完成通知安全地回投到所属事件循环。"""
|
||
loop = self._loop
|
||
if loop is None:
|
||
return
|
||
try:
|
||
loop.call_soon_threadsafe(self._complete, future, item)
|
||
except RuntimeError:
|
||
pass
|
||
|
||
def _complete(self, future: Future[object], item: _WorkItem) -> None:
|
||
"""释放 admission,并记录任务的最终结果。"""
|
||
with self._state_lock:
|
||
self._futures.pop(future, None)
|
||
if future.running() or future.done() and not future.cancelled():
|
||
self._running -= 1
|
||
else:
|
||
self._queued -= 1
|
||
outcome = "cancelled" if future.cancelled() else "success"
|
||
if not future.cancelled():
|
||
try:
|
||
future.result()
|
||
except BaseException:
|
||
outcome = "error"
|
||
started_at = item.started_at or item.submitted_at
|
||
record_metric(
|
||
"db.worker.duration",
|
||
time.perf_counter() - started_at,
|
||
outcome=outcome,
|
||
)
|
||
self._record_depth()
|
||
|
||
async def _wait_until_done(
|
||
self,
|
||
future: asyncio.Future[object],
|
||
*,
|
||
interruptible: bool = False,
|
||
) -> None:
|
||
"""等待线程内事务结束,并按调用场景决定是否响应外层取消。"""
|
||
while not future.done():
|
||
try:
|
||
await asyncio.shield(future)
|
||
except asyncio.CancelledError:
|
||
if interruptible:
|
||
raise
|
||
continue
|
||
except BaseException:
|
||
break
|
||
|
||
async def shutdown(self) -> None:
|
||
"""拒绝新任务,取消排队任务并等待运行中事务结束。"""
|
||
executor = self._executor
|
||
if executor is None:
|
||
return
|
||
if self._loop is not asyncio.get_running_loop():
|
||
raise RuntimeError("数据库 worker 必须在所属事件循环中停止")
|
||
self._closing = True
|
||
with self._state_lock:
|
||
futures = tuple(self._futures.items())
|
||
for future, _state in futures:
|
||
future.cancel()
|
||
for future, (wrapped, _item) in futures:
|
||
if not future.cancelled():
|
||
# 关停超时必须能返回并保留 owner;已开始的数据库事务继续由线程完成。
|
||
await self._wait_until_done(wrapped, interruptible=True)
|
||
executor.shutdown(wait=True, cancel_futures=True)
|
||
while self.snapshot().queued or self.snapshot().running:
|
||
await asyncio.sleep(0)
|
||
self._executor = None
|
||
self._record_depth()
|
||
|
||
def _record_depth(self) -> None:
|
||
"""以状态变化量记录队列和运行中的任务数量。"""
|
||
stats = self.snapshot()
|
||
queued_delta = stats.queued - self._reported_queued
|
||
running_delta = stats.running - self._reported_running
|
||
if queued_delta:
|
||
record_metric("db.worker.queue.depth", queued_delta)
|
||
if running_delta:
|
||
record_metric("db.worker.active", running_delta)
|
||
self._reported_queued = stats.queued
|
||
self._reported_running = stats.running
|