Files
MoviePilot/app/db/worker.py
T

255 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""同步数据库短事务的异步执行器。"""
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