mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: own shutdown lifecycle boundaries
This commit is contained in:
+399
-57
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -27,6 +28,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""
|
||||
目录监控门面,单例模式:装配本地/远程监控、维护生命周期与健康检查。
|
||||
"""
|
||||
# watcher/scheduler 在构造期启动;异常时保留实例供启动失败屏障收口。
|
||||
_retain_failed_singleton = True
|
||||
# 除目录配置外,同时监听仅在监控线程创建时读取的环境变量:这两项经
|
||||
# /system/env 保存后运行时值虽已更新,但已运行的监控不会重新决策模式,
|
||||
# 必须触发 init() 全量重建才能生效(MONITOR_RESCAN_DELAYS 为实时解析,无需在列)
|
||||
@@ -54,11 +57,25 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
REBUILD_KEY_PREFIX = "rebuild:"
|
||||
PROBE_KEY = "probe"
|
||||
PENDING_KEY = "pending"
|
||||
RELOAD_STOP_TIMEOUT = 30.0
|
||||
LIFECYCLE_CLOSE_TIMEOUT = 90.0
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""初始化目录监控依赖、owner 注册表与当前 lifespan 状态。"""
|
||||
super().__init__()
|
||||
# 生命周期操作串行化;永久关闭请求用 Event 提前封口,不能被阻塞 I/O 挡住。
|
||||
self._lifecycle_lock = threading.RLock()
|
||||
self._owner_lock = threading.Lock()
|
||||
self._work_stop_event = threading.Event()
|
||||
self._shutdown_event = threading.Event()
|
||||
self._closed = False
|
||||
self._compensation_threads: Dict[int, threading.Thread] = {}
|
||||
self._scheduler_shutdown_thread: Optional[threading.Thread] = None
|
||||
self._scheduler_shutdown_succeeded = False
|
||||
# 本地目录监控服务
|
||||
self._watchers = []
|
||||
# 已请求停止但尚未退出的 watcher,只保留 owner 身份,不再参与健康检查
|
||||
self._retired_watchers = []
|
||||
# 本地目录监控列表读写锁
|
||||
self._watcher_lock = Lock()
|
||||
# 启动失败待重试的本地监控配置
|
||||
@@ -87,9 +104,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self._poller = RemotePoller(store=self._store, dispatcher=self._dispatcher,
|
||||
alert_cb=self.__poller_alert)
|
||||
# 启动目录监控和文件整理
|
||||
self.init()
|
||||
if not self.init():
|
||||
raise RuntimeError("目录监控 owner 初始化未收敛")
|
||||
|
||||
def on_config_changed(self):
|
||||
def on_config_changed(self) -> None:
|
||||
"""配置变化时重建监控;lifespan 封口后拒绝重新启动。"""
|
||||
if self._shutdown_event.is_set():
|
||||
logger.info("目录监控已进入生命周期封口,忽略配置热更新")
|
||||
return
|
||||
self.init()
|
||||
|
||||
def get_reload_name(self):
|
||||
@@ -118,6 +140,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""
|
||||
强制全量扫描并处理所有文件(包括已存在的文件)。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
return self._poller.force_full_scan(storage=storage, mon_path=mon_path)
|
||||
|
||||
@staticmethod
|
||||
@@ -134,24 +158,61 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""
|
||||
return SnapshotStore.compare(old_snapshot, new_snapshot)
|
||||
|
||||
def init(self):
|
||||
def init(self, timeout: float = RELOAD_STOP_TIMEOUT) -> bool:
|
||||
"""在旧 owner 收敛后启动一代监控,关闭中的生命周期拒绝重开。"""
|
||||
if self._shutdown_event.is_set():
|
||||
logger.info("目录监控生命周期已关闭,跳过启动")
|
||||
return False
|
||||
deadline = time.monotonic() + max(0.0, timeout)
|
||||
if not self._lifecycle_lock.acquire(
|
||||
timeout=max(0.0, deadline - time.monotonic())
|
||||
):
|
||||
logger.error("目录监控未在 %.1f 秒内取得重载所有权", max(0.0, timeout))
|
||||
return False
|
||||
try:
|
||||
if self._shutdown_event.is_set():
|
||||
return False
|
||||
if not self.__stop_owned(deadline=deadline, close=False):
|
||||
logger.error("旧目录监控 owner 未收敛,取消本次配置热更新")
|
||||
return False
|
||||
if self._shutdown_event.is_set():
|
||||
return False
|
||||
if not self._recovery.reopen():
|
||||
logger.error("目录监控恢复线程仍未退出,取消本次配置热更新")
|
||||
return False
|
||||
if self._shutdown_event.is_set():
|
||||
self._recovery.request_stop()
|
||||
return False
|
||||
self._work_stop_event.clear()
|
||||
return self.__initialize_monitors()
|
||||
finally:
|
||||
self._lifecycle_lock.release()
|
||||
|
||||
def __initialize_monitors(self) -> bool:
|
||||
"""
|
||||
启动监控
|
||||
在已取得生命周期所有权后启动监控。
|
||||
|
||||
永久关闭请求可以在本方法阻塞于 FUSE 时从其他线程提前设置;每个可能产生
|
||||
新 owner 的边界都重新检查,确保解冻后只能退出,不能穿透旧生命周期。
|
||||
"""
|
||||
# 停止现有任务
|
||||
self.stop()
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
|
||||
# 读取目录配置
|
||||
monitor_dirs = DirectoryHelper().get_download_dirs()
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
if not monitor_dirs:
|
||||
logger.info("未找到任何目录监控配置")
|
||||
return
|
||||
return True
|
||||
|
||||
messagehelper = MessageHelper()
|
||||
|
||||
# 先筛出有效的监控配置,再按下载目录去重,避免非监控配置顶掉监控配置
|
||||
valid_dirs = []
|
||||
for mon_dir in monitor_dirs:
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
if not mon_dir.library_path:
|
||||
logger.warn(f"跳过监控配置 {mon_dir.download_path}:未设置媒体库目录")
|
||||
continue
|
||||
@@ -172,6 +233,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.info(f"找到 {len(monitor_dirs)} 个目录监控配置")
|
||||
|
||||
# 启动定时服务进程
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
self._scheduler = BackgroundScheduler(timezone=get_runtime_setting("TZ"))
|
||||
|
||||
mon_storages: Dict[str, List[Path]] = {}
|
||||
@@ -179,6 +242,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
local_started = 0
|
||||
local_failed = 0
|
||||
for mon_dir in monitor_dirs:
|
||||
if not self.__accepting_work():
|
||||
self.__abort_interrupted_startup()
|
||||
return False
|
||||
# 检查媒体库目录是不是下载目录的子目录
|
||||
mon_path = Path(mon_dir.download_path)
|
||||
target_path = Path(mon_dir.library_path)
|
||||
@@ -197,6 +263,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
mon_storages.setdefault(mon_dir.storage, []).append(mon_path)
|
||||
|
||||
for storage, paths in mon_storages.items():
|
||||
if not self.__accepting_work():
|
||||
self.__abort_interrupted_startup()
|
||||
return False
|
||||
# 远程目录监控 - 使用智能间隔
|
||||
# 先尝试加载已有快照获取文件数量
|
||||
snapshot_data = self._store.load(storage)
|
||||
@@ -238,6 +307,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.info(f"✓ 目录监控健康检查已启动: [间隔: {self.WATCHDOG_INTERVAL}秒]")
|
||||
|
||||
# 启动定时服务
|
||||
if not self.__accepting_work():
|
||||
self.__abort_interrupted_startup()
|
||||
return False
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
@@ -253,6 +325,47 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.warn(summary)
|
||||
else:
|
||||
logger.info(summary)
|
||||
return True
|
||||
|
||||
def __accepting_work(self) -> bool:
|
||||
"""判断当前 monitor 代是否仍允许创建 owner 和派发整理工作。"""
|
||||
return not self._work_stop_event.is_set() and not self._shutdown_event.is_set()
|
||||
|
||||
@property
|
||||
def lifecycle_closed(self) -> bool:
|
||||
"""返回当前 Monitor 是否已经被应用生命周期永久封口。"""
|
||||
return self._closed or self._shutdown_event.is_set()
|
||||
|
||||
def reopen(self, timeout: float = RELOAD_STOP_TIMEOUT) -> bool:
|
||||
"""确认旧 owner 已收敛后,为新的应用 lifespan 显式解除永久封口。"""
|
||||
if not self.lifecycle_closed:
|
||||
return True
|
||||
deadline = time.monotonic() + max(0.0, timeout)
|
||||
if not self._lifecycle_lock.acquire(
|
||||
timeout=max(0.0, deadline - time.monotonic())
|
||||
):
|
||||
logger.error("旧目录监控生命周期仍在收尾,无法重新开启")
|
||||
return False
|
||||
try:
|
||||
if not self.__stop_owned(deadline=deadline, close=True):
|
||||
logger.error("旧目录监控 owner 仍未收敛,无法重新开启")
|
||||
return False
|
||||
if not self._recovery.reopen():
|
||||
logger.error("旧目录监控恢复线程仍存活,无法重新开启")
|
||||
return False
|
||||
self._closed = False
|
||||
self._shutdown_event.clear()
|
||||
return True
|
||||
finally:
|
||||
self._lifecycle_lock.release()
|
||||
|
||||
def __abort_interrupted_startup(self) -> None:
|
||||
"""关闭请求穿透阻塞启动后,在当前生命周期锁内收拢已经创建的 owner。"""
|
||||
logger.info("目录监控启动期间收到关闭请求,正在回收本次已创建 owner")
|
||||
self.__stop_owned(
|
||||
deadline=time.monotonic() + self.RELOAD_STOP_TIMEOUT,
|
||||
close=self._shutdown_event.is_set(),
|
||||
)
|
||||
|
||||
def __start_local_monitor(self, mon_path: Path, monitor_mode: str) -> bool:
|
||||
"""
|
||||
@@ -261,6 +374,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
:param monitor_mode: 配置的监控模式
|
||||
:return: 是否启动成功
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return False
|
||||
logger.info(f"正在启动本地目录监控: {mon_path}")
|
||||
logger.info("*** 重要提示:目录监控只处理新增和修改的文件,不会处理监控启动前已存在的文件 ***")
|
||||
|
||||
@@ -298,6 +413,11 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
# 启动成功后再登记,避免失败的监控残留在列表中
|
||||
watcher.start()
|
||||
with self._watcher_lock:
|
||||
if not self.__accepting_work():
|
||||
watcher.stop()
|
||||
if watcher.is_alive():
|
||||
self._retired_watchers.append(watcher)
|
||||
return False
|
||||
self._watchers.append(watcher)
|
||||
self._pending_locals = [
|
||||
pending for pending in self._pending_locals
|
||||
@@ -312,13 +432,15 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self.__handle_start_failure(mon_path=mon_path, monitor_mode=monitor_mode, err=e)
|
||||
return False
|
||||
|
||||
def __handle_start_failure(self, mon_path: Path, monitor_mode: str, err: Exception):
|
||||
def __handle_start_failure(self, mon_path: Path, monitor_mode: str, err: Exception) -> None:
|
||||
"""
|
||||
处理本地目录监控启动失败,登记待重试并按需告警。
|
||||
:param mon_path: 监控目录
|
||||
:param monitor_mode: 配置的监控模式
|
||||
:param err: 启动异常
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
err_msg = str(err)
|
||||
logger.error(f"启动本地目录监控失败: {mon_path}")
|
||||
logger.error(f"错误详情: {err_msg}")
|
||||
@@ -346,7 +468,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
f"启动本地目录监控失败: {mon_path}\n错误: {err_msg}\n"
|
||||
f"将自动退避重试")
|
||||
|
||||
def watchdog(self):
|
||||
def watchdog(self) -> None:
|
||||
"""
|
||||
目录监控健康检查:检测监控线程状态并驱动恢复。
|
||||
|
||||
@@ -356,8 +478,12 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
动作会把看门狗冻死在它自己要修复的挂载上,随后停滞检测、告警、重试驱动
|
||||
全部静默失效——这正是全进程雪崩的起点。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
try:
|
||||
broken = self.__check_watchers()
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
self.__drive_recovery(broken)
|
||||
except Exception as e:
|
||||
logger.error(f"目录监控健康检查出现错误:{e}\n{traceback.format_exc()}")
|
||||
@@ -372,6 +498,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"""
|
||||
with self._watcher_lock:
|
||||
watchers = list(self._watchers)
|
||||
self._retired_watchers = [
|
||||
watcher for watcher in self._retired_watchers if watcher.is_alive()
|
||||
]
|
||||
isolated = set(self._isolated)
|
||||
# 探测已确认挂载恢复的目录,本轮直接送去重建
|
||||
resumed = list(self._pending_rebuild.values())
|
||||
@@ -412,7 +541,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
broken.append(watcher)
|
||||
return broken
|
||||
|
||||
def __drive_recovery(self, broken: List[LocalDirectoryWatcher]):
|
||||
def __drive_recovery(self, broken: List[LocalDirectoryWatcher]) -> None:
|
||||
"""
|
||||
把所有会触碰挂载的恢复动作派发到一次性工作线程,并等待有限时间。
|
||||
|
||||
@@ -421,6 +550,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
可放弃的子进程探测来确认挂载何时恢复。
|
||||
:param broken: 需要重建的监控列表
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
actions: Dict[str, Callable[[], None]] = {}
|
||||
rebuilds: Dict[str, LocalDirectoryWatcher] = {}
|
||||
for watcher in broken:
|
||||
@@ -432,6 +563,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
actions[self.PENDING_KEY] = self.__drive_pending
|
||||
|
||||
results = self._recovery.run(actions, timeout=self.RECOVERY_TIMEOUT)
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
|
||||
for key, state in results.items():
|
||||
if state is RecoveryState.COMPLETED:
|
||||
@@ -445,11 +578,13 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.warn(f"目录监控恢复动作未在 {self.RECOVERY_TIMEOUT} 秒内完成"
|
||||
f"({state.value}),将在后续健康检查周期重试: {key}")
|
||||
|
||||
def __enter_isolation(self, watcher: LocalDirectoryWatcher):
|
||||
def __enter_isolation(self, watcher: LocalDirectoryWatcher) -> None:
|
||||
"""
|
||||
将一个监控目录转入挂载级故障隔离:停止对它的一切新访问,等待探测恢复。
|
||||
:param watcher: 重建未能返回的监控
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
key = str(watcher.watch_path)
|
||||
with self._watcher_lock:
|
||||
if key in self._isolated:
|
||||
@@ -466,7 +601,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
f"已暂停对该目录的所有访问,正在周期探测挂载,恢复后将自动重建监控并补扫",
|
||||
stage="isolated")
|
||||
|
||||
def __probe_isolated(self):
|
||||
def __probe_isolated(self) -> None:
|
||||
"""
|
||||
对隔离中的监控目录做可放弃探测,挂载恢复应答后解除隔离并重建监控。
|
||||
|
||||
@@ -474,11 +609,17 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
超时可被 kill,因此本线程不会像内联 stat 那样永久冻死。探测通过后的重建
|
||||
仍有极小概率再次卡住,届时本线程会被下一轮的 BUSY 判定跳过,不再泄漏。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
with self._watcher_lock:
|
||||
keys = list(self._isolated)
|
||||
for key in keys:
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
mon_path = Path(key)
|
||||
if not probe_path(mon_path, timeout=self.MOUNT_PROBE_TIMEOUT):
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
with self._watcher_lock:
|
||||
entry = self._isolated.get(key)
|
||||
if not entry:
|
||||
@@ -489,6 +630,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.warn(f"挂载探测未通过(累计 {failures} 次,已隔离 "
|
||||
f"{int(time.time() - since)} 秒),继续隔离: {mon_path}")
|
||||
continue
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
with self._watcher_lock:
|
||||
entry = self._isolated.pop(key, None)
|
||||
if not entry:
|
||||
@@ -502,19 +645,24 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
with self._watcher_lock:
|
||||
self._pending_rebuild[key] = entry["watcher"]
|
||||
|
||||
def __drive_pending(self):
|
||||
def __drive_pending(self) -> None:
|
||||
"""
|
||||
驱动两条待重试队列。两者都会访问挂载(启动重试走目录遍历与 exists,
|
||||
整理重试走 stat),必须在恢复工作线程里执行而不是看门狗线程里。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
self.__retry_pending_locals()
|
||||
self._dispatcher.retry_pending()
|
||||
if self.__accepting_work():
|
||||
self._dispatcher.retry_pending()
|
||||
|
||||
def __rebuild_watcher(self, watcher: LocalDirectoryWatcher):
|
||||
def __rebuild_watcher(self, watcher: LocalDirectoryWatcher) -> None:
|
||||
"""
|
||||
重建一个本地目录监控线程。
|
||||
:param watcher: 需要重建的监控
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
# 卡死的线程阻塞在底层调用中无法强制回收,只能请求停止后由守护线程自然退出
|
||||
watcher.stop()
|
||||
new_watcher = LocalDirectoryWatcher(
|
||||
@@ -527,8 +675,13 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
new_watcher.start()
|
||||
except Exception as e:
|
||||
logger.error(f"重建目录监控失败: {watcher.watch_path} - {e}")
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
with self._watcher_lock:
|
||||
# 旧 watcher 可能仍卡在 FUSE 调用里;只有真实退出后才能移除句柄。
|
||||
self._watchers = [item for item in self._watchers if item is not watcher]
|
||||
if watcher.is_alive():
|
||||
self._retired_watchers.append(watcher)
|
||||
if all(pending["mon_path"] != watcher.watch_path for pending in self._pending_locals):
|
||||
self._pending_locals.append({
|
||||
"mon_path": watcher.watch_path,
|
||||
@@ -537,9 +690,18 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
})
|
||||
return
|
||||
with self._watcher_lock:
|
||||
if not self.__accepting_work():
|
||||
new_watcher.stop()
|
||||
if new_watcher.is_alive():
|
||||
self._retired_watchers.append(new_watcher)
|
||||
return
|
||||
registered = any(item is watcher for item in self._watchers)
|
||||
if registered:
|
||||
self._watchers = [new_watcher if item is watcher else item for item in self._watchers]
|
||||
if watcher.is_alive():
|
||||
# stop() 只能发信号,FUSE 上的旧线程可能永久不返回;继续持有它,
|
||||
# 让生命周期关闭如实失败,而不是把 daemon 泄漏伪装成已收敛。
|
||||
self._retired_watchers.append(watcher)
|
||||
if not registered:
|
||||
# 卡死的重建线程可能在挂载恢复后才解冻并走到这里,而该目录此时已由
|
||||
# 隔离恢复路径重建过。此处若直接放行,新建的监控既不在 _watchers 里
|
||||
@@ -547,6 +709,9 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
# 派发事件的孤儿线程,必须就地停掉
|
||||
logger.warn(f"目录监控已由其他路径重建,停止本次重建的冗余监控: {watcher.watch_path}")
|
||||
new_watcher.stop()
|
||||
if new_watcher.is_alive():
|
||||
with self._watcher_lock:
|
||||
self._retired_watchers.append(new_watcher)
|
||||
return
|
||||
# 新监控的重启计数从零开始,同步重置告警基准
|
||||
self._restart_marks.pop(str(watcher.watch_path), None)
|
||||
@@ -557,7 +722,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
self.__start_compensation(mon_path=watcher.watch_path,
|
||||
since=watcher.last_activity_time)
|
||||
|
||||
def __start_compensation(self, mon_path: Path, since: float):
|
||||
def __start_compensation(self, mon_path: Path, since: float) -> None:
|
||||
"""
|
||||
在后台线程发起补偿扫描,避免遍历目录阻塞健康检查周期。
|
||||
:param mon_path: 监控目录
|
||||
@@ -567,14 +732,34 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
# 从未活动过说明没有可靠的停摆起点,全量补扫代价不可控,跳过
|
||||
logger.debug(f"监控无活动记录,跳过补偿扫描: {mon_path}")
|
||||
return
|
||||
Thread(
|
||||
target=self.__compensate_scan,
|
||||
thread = Thread(
|
||||
target=self.__run_compensation,
|
||||
kwargs={"mon_path": mon_path, "since": since},
|
||||
name=f"MoviePilot-MonitorCompensation-{mon_path.name}",
|
||||
daemon=True
|
||||
).start()
|
||||
)
|
||||
with self._owner_lock:
|
||||
# 与 close() 的 owner 快照共用同一把锁;封口先发生时绝不再启动,
|
||||
# 登记先发生时 close() 必然能看到并等待这条线程。
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
self._compensation_threads[id(thread)] = thread
|
||||
try:
|
||||
thread.start()
|
||||
except Exception:
|
||||
self._compensation_threads.pop(id(thread), None)
|
||||
raise
|
||||
|
||||
def __compensate_scan(self, mon_path: Path, since: float):
|
||||
def __run_compensation(self, mon_path: Path, since: float) -> None:
|
||||
"""执行补偿扫描,并在真实终态后释放 owner 句柄。"""
|
||||
current_thread = threading.current_thread()
|
||||
try:
|
||||
self.__compensate_scan(mon_path=mon_path, since=since)
|
||||
finally:
|
||||
with self._owner_lock:
|
||||
self._compensation_threads.pop(id(current_thread), None)
|
||||
|
||||
def __compensate_scan(self, mon_path: Path, since: float) -> None:
|
||||
"""
|
||||
补扫监控停摆期间落地的文件。
|
||||
|
||||
@@ -586,7 +771,11 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
:param mon_path: 监控目录
|
||||
:param since: 停摆起点(墙钟时间戳),仅用于统计与日志
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
candidates = self.__collect_compensation_files(mon_path)
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
if candidates is None:
|
||||
return
|
||||
# mtime 不再作为过滤条件,但仍是「最可能是新文件」的排序依据
|
||||
@@ -599,6 +788,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
changed_count = sum(1 for candidate in candidates if candidate[1] >= threshold)
|
||||
handled = 0
|
||||
for file_path, file_modify_time, file_size in candidates:
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
if self._dispatcher.handle_file(
|
||||
storage="local",
|
||||
event_path=file_path,
|
||||
@@ -634,14 +825,18 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
return None
|
||||
return candidates
|
||||
|
||||
def __retry_pending_locals(self):
|
||||
def __retry_pending_locals(self) -> None:
|
||||
"""
|
||||
重试启动失败的本地目录监控,给网络存储/FUSE 挂载留出就绪时间。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
with self._watcher_lock:
|
||||
pending = list(self._pending_locals)
|
||||
isolated = set(self._isolated)
|
||||
for item in pending:
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
if str(item["mon_path"]) in isolated:
|
||||
# 隔离中的挂载不接受任何新访问:启动重试要走目录遍历与 exists,
|
||||
# 在「请求永不返回」的挂载上会再冻死一个线程
|
||||
@@ -655,7 +850,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
item["attempts"] = item.get("attempts", 0) + 1
|
||||
item["skip_cycles"] = min(item["attempts"], 10)
|
||||
|
||||
def __send_alert(self, mon_path: Path, message: str, stage: str = "fault"):
|
||||
def __send_alert(self, mon_path: Path, message: str, stage: str = "fault") -> None:
|
||||
"""
|
||||
推送目录监控异常告警,同一目录在同一阶段仅推送一次。
|
||||
:param mon_path: 监控目录
|
||||
@@ -664,6 +859,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
(监控已暂停访问、等待挂载恢复),只按目录去重会把这条
|
||||
关键消息吞掉,因此阶段变化时重新推送
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
key = str(mon_path)
|
||||
with self._watcher_lock:
|
||||
if self._alerted_paths.get(key) == stage:
|
||||
@@ -672,7 +869,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
MessageHelper().put(message, title="目录监控")
|
||||
|
||||
@staticmethod
|
||||
def __poller_alert(storage: str, message: str):
|
||||
def __poller_alert(storage: str, message: str) -> None:
|
||||
"""
|
||||
远程轮询监控告警回调,复用消息渠道推送。
|
||||
:param storage: 存储名称
|
||||
@@ -681,12 +878,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.warn(f"[{storage}] {message}")
|
||||
MessageHelper().put(message, title="目录监控")
|
||||
|
||||
def __clear_alert(self, mon_path: Path, message: str):
|
||||
def __clear_alert(self, mon_path: Path, message: str) -> None:
|
||||
"""
|
||||
清除目录监控异常告警状态,并在此前告警过时推送恢复消息。
|
||||
:param mon_path: 监控目录
|
||||
:param message: 恢复内容
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
key = str(mon_path)
|
||||
with self._watcher_lock:
|
||||
if key not in self._alerted_paths:
|
||||
@@ -695,12 +894,14 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
logger.info(message)
|
||||
MessageHelper().put(message, title="目录监控")
|
||||
|
||||
def polling_observer(self, storage: str, mon_paths: List[Path]):
|
||||
def polling_observer(self, storage: str, mon_paths: List[Path]) -> None:
|
||||
"""
|
||||
轮询监控:执行一轮快照并按结果动态调整监控间隔。
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
file_count = self._poller.poll(storage=storage, mon_paths=mon_paths)
|
||||
if file_count is None or not self._scheduler:
|
||||
if not self.__accepting_work() or file_count is None or not self._scheduler:
|
||||
return
|
||||
# 动态调整监控间隔
|
||||
new_interval = SnapshotStore.adjust_interval(file_count)
|
||||
@@ -716,7 +917,10 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
except Exception as e:
|
||||
logger.error(f"调整监控间隔失败: {storage} - {e}")
|
||||
|
||||
def event_handler(self, event, text: str, event_path: str, file_size: float = None):
|
||||
def event_handler(
|
||||
self, event: Any, text: str, event_path: str,
|
||||
file_size: Optional[float] = None
|
||||
) -> None:
|
||||
"""
|
||||
处理文件变化。
|
||||
:param event: 事件
|
||||
@@ -724,7 +928,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
:param event_path: 事件文件路径
|
||||
:param file_size: 文件大小
|
||||
"""
|
||||
if event.is_directory:
|
||||
if not self.__accepting_work() or event.is_directory:
|
||||
return
|
||||
if not self._dispatcher.is_transfer_candidate_path(Path(event_path)):
|
||||
return
|
||||
@@ -733,6 +937,8 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
file_modify_time = Path(event_path).stat().st_mtime
|
||||
except OSError as err:
|
||||
logger.debug(f"读取目录监控文件修改时间失败: {event_path} - {err}")
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
# 整理文件
|
||||
handle_kwargs = {
|
||||
"storage": "local",
|
||||
@@ -743,55 +949,191 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
handle_kwargs["file_modify_time"] = file_modify_time
|
||||
self._dispatcher.handle_file(**handle_kwargs)
|
||||
|
||||
def event_unreadable(self, event_path: Path):
|
||||
def event_unreadable(self, event_path: Path) -> None:
|
||||
"""
|
||||
处理读取失败的监控事件,登记待重试。
|
||||
:param event_path: 事件文件路径
|
||||
"""
|
||||
if not self.__accepting_work():
|
||||
return
|
||||
event_path = Path(event_path)
|
||||
if not self._dispatcher.is_transfer_candidate_path(event_path):
|
||||
return
|
||||
self._dispatcher.register_unreadable(storage="local", event_path=event_path)
|
||||
|
||||
def stop(self):
|
||||
def stop(self, timeout: float = RELOAD_STOP_TIMEOUT) -> bool:
|
||||
"""在共享预算内临时停止全部监控 owner,供配置热重载使用。"""
|
||||
return self.__stop_with_budget(timeout=timeout, close=False)
|
||||
|
||||
def close(self, timeout: float = LIFECYCLE_CLOSE_TIMEOUT) -> bool:
|
||||
"""永久封口当前 lifespan,并在共享预算内等待全部监控 owner。"""
|
||||
return self.__stop_with_budget(timeout=timeout, close=True)
|
||||
|
||||
def __stop_with_budget(self, timeout: float, close: bool) -> bool:
|
||||
"""从等待生命周期锁开始计算一次停止操作的完整预算。"""
|
||||
timeout = max(0.0, timeout)
|
||||
deadline = time.monotonic() + timeout
|
||||
# 先封住工作入口再等待锁。配置重载若正阻塞在 FUSE 上,解冻后也只能清理,
|
||||
# 不能在外层生命周期已经超时返回后继续创建 watcher 或派发整理任务。
|
||||
self._work_stop_event.set()
|
||||
if close:
|
||||
self._shutdown_event.set()
|
||||
if not self._lifecycle_lock.acquire(
|
||||
timeout=max(0.0, deadline - time.monotonic())
|
||||
):
|
||||
logger.error("目录监控未在 %.1f 秒内取得停机所有权", timeout)
|
||||
return False
|
||||
try:
|
||||
return self.__stop_owned(deadline=deadline, close=close)
|
||||
finally:
|
||||
self._lifecycle_lock.release()
|
||||
|
||||
def __stop_owned(self, deadline: float, close: bool) -> bool:
|
||||
"""
|
||||
退出监控
|
||||
在已取得生命周期锁时等待全部 owner,共享调用方给出的绝对截止时间。
|
||||
|
||||
超时只报告未收敛并保留所有活句柄;只有 watcher、补偿扫描、恢复动作及
|
||||
scheduler shutdown 都到达终态后,才清空这一代的内存状态。
|
||||
"""
|
||||
# 先停定时服务,避免健康检查在停止过程中重建监控线程
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
try:
|
||||
self._scheduler.shutdown()
|
||||
logger.info("定时监控服务已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止定时服务出现了错误:{e}")
|
||||
self._scheduler = None
|
||||
# 待重试条目按停止前的监控范围登记,重载后范围可能变化,一并清理
|
||||
self._work_stop_event.set()
|
||||
if close:
|
||||
self._closed = True
|
||||
self._shutdown_event.set()
|
||||
self._recovery.request_stop()
|
||||
self.__request_scheduler_stop()
|
||||
|
||||
with self._watcher_lock:
|
||||
watchers = tuple(self._watchers) + tuple(self._retired_watchers)
|
||||
if watchers:
|
||||
logger.info("正在停止本地目录监控服务...")
|
||||
for watcher in watchers:
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"请求停止目录监控服务出现了错误:{err}")
|
||||
|
||||
recovery_converged = self._recovery.close(deadline=deadline)
|
||||
current_thread = threading.current_thread()
|
||||
# 恢复线程可能在封口后才从 FUSE 调用返回,并登记一个已经请求停止的新
|
||||
# watcher;RecoveryExecutor 收敛后重新取快照,确保它也使用剩余预算 join。
|
||||
with self._watcher_lock:
|
||||
registered_watchers = tuple(self._watchers) + tuple(self._retired_watchers)
|
||||
for watcher in registered_watchers:
|
||||
try:
|
||||
watcher.stop()
|
||||
watcher.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
except Exception as err:
|
||||
logger.error(f"等待目录监控服务停止出现了错误:{err}")
|
||||
|
||||
with self._owner_lock:
|
||||
compensation_threads = tuple(self._compensation_threads.values())
|
||||
scheduler_thread = self._scheduler_shutdown_thread
|
||||
for thread in compensation_threads:
|
||||
if thread is not current_thread and thread.is_alive():
|
||||
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if (
|
||||
scheduler_thread is not None
|
||||
and scheduler_thread is not current_thread
|
||||
and scheduler_thread.is_alive()
|
||||
):
|
||||
scheduler_thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
|
||||
with self._watcher_lock:
|
||||
alive_watchers = tuple(
|
||||
watcher
|
||||
for watcher in (*self._watchers, *self._retired_watchers)
|
||||
if watcher.is_alive()
|
||||
)
|
||||
with self._owner_lock:
|
||||
alive_compensations = tuple(
|
||||
thread for thread in self._compensation_threads.values()
|
||||
if thread.is_alive()
|
||||
)
|
||||
scheduler_thread = self._scheduler_shutdown_thread
|
||||
scheduler_converged = (
|
||||
self._scheduler is None
|
||||
or (
|
||||
scheduler_thread is not None
|
||||
and not scheduler_thread.is_alive()
|
||||
and self._scheduler_shutdown_succeeded
|
||||
)
|
||||
)
|
||||
alive_recoveries = self._recovery.running_threads()
|
||||
if (
|
||||
alive_watchers
|
||||
or alive_compensations
|
||||
or alive_recoveries
|
||||
or not recovery_converged
|
||||
or not scheduler_converged
|
||||
):
|
||||
logger.error(
|
||||
"目录监控 owner 未在截止时间内收敛:watcher=%d,补偿=%d,恢复=%d,scheduler=%s",
|
||||
len(alive_watchers),
|
||||
len(alive_compensations),
|
||||
len(alive_recoveries),
|
||||
"未收敛" if not scheduler_converged else "已收敛",
|
||||
)
|
||||
return False
|
||||
|
||||
# 仅在所有 owner 真实终止后清理业务状态,确保失败重试仍能找到原句柄。
|
||||
self._dispatcher.clear_pending()
|
||||
with self._watcher_lock:
|
||||
watchers = self._watchers
|
||||
self._watchers = []
|
||||
self._retired_watchers = []
|
||||
self._pending_locals = []
|
||||
self._alerted_paths = {}
|
||||
self._restart_marks = {}
|
||||
self._stable_cycles = {}
|
||||
self._isolated = {}
|
||||
self._pending_rebuild = {}
|
||||
# 已冻死的恢复线程无法回收,这里只是不再跟踪它们,避免重载后同名目录
|
||||
# 被残留记录误判为 BUSY 而永远拿不到重建机会
|
||||
self._recovery.clear()
|
||||
with self._owner_lock:
|
||||
self._compensation_threads.clear()
|
||||
self._scheduler = None
|
||||
self._scheduler_shutdown_thread = None
|
||||
self._scheduler_shutdown_succeeded = False
|
||||
if watchers:
|
||||
logger.info("正在停止本地目录监控服务...")
|
||||
for watcher in watchers:
|
||||
try:
|
||||
watcher.stop()
|
||||
watcher.join(timeout=5)
|
||||
if watcher.is_alive():
|
||||
logger.warning(f"本地目录监控线程在5秒内未能停止: {watcher.watch_path}")
|
||||
else:
|
||||
logger.debug(f"已停止本地目录监控服务: {watcher.watch_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"停止目录监控服务出现了错误:{e}")
|
||||
logger.info("本地目录监控服务已停止")
|
||||
return True
|
||||
|
||||
def __request_scheduler_stop(self) -> None:
|
||||
"""创建并登记唯一 scheduler shutdown 线程,避免阻塞生命周期调用线程。"""
|
||||
with self._owner_lock:
|
||||
running = self._scheduler_shutdown_thread
|
||||
if running is not None and running.is_alive():
|
||||
return
|
||||
if running is not None and self._scheduler_shutdown_succeeded:
|
||||
return
|
||||
scheduler = self._scheduler
|
||||
if scheduler is None:
|
||||
return
|
||||
self._scheduler_shutdown_succeeded = False
|
||||
thread = threading.Thread(
|
||||
target=self.__shutdown_scheduler,
|
||||
args=(scheduler,),
|
||||
name="MoviePilot-MonitorSchedulerShutdown",
|
||||
daemon=True,
|
||||
)
|
||||
self._scheduler_shutdown_thread = thread
|
||||
try:
|
||||
thread.start()
|
||||
except Exception as err:
|
||||
self._scheduler_shutdown_thread = None
|
||||
logger.error(f"启动定时监控停止线程失败:{err}")
|
||||
|
||||
def __shutdown_scheduler(self, scheduler: BackgroundScheduler) -> None:
|
||||
"""停止 scheduler 及其在途 job,并把真实终态写回 owner 注册表。"""
|
||||
succeeded = False
|
||||
try:
|
||||
scheduler.remove_all_jobs()
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=True)
|
||||
succeeded = True
|
||||
logger.info("定时监控服务已停止")
|
||||
except Exception as err:
|
||||
logger.error(f"停止定时服务出现了错误:{err}")
|
||||
finally:
|
||||
with self._owner_lock:
|
||||
if self._scheduler is scheduler:
|
||||
self._scheduler_shutdown_succeeded = succeeded
|
||||
# 缓存与快照存储是共享后端的代理,生命周期由应用全局管理,这里不再关闭
|
||||
|
||||
+70
-15
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
监控恢复动作的可放弃执行单元。
|
||||
监控恢复动作的有限等待执行单元。
|
||||
|
||||
FUSE/网络挂载有两种故障形态,下游程序的免疫力完全不同:
|
||||
|
||||
@@ -11,8 +11,8 @@ FUSE/网络挂载有两种故障形态,下游程序的免疫力完全不同:
|
||||
本模块提供 block 型故障下唯一可行的两种自保手段:
|
||||
|
||||
1. RecoveryExecutor —— 把会触碰挂载的动作放进一次性守护线程执行,调用方只
|
||||
等待有限时间。超时即放弃该线程(它会作为守护线程悬挂到进程退出),换取
|
||||
调用方(健康检查这个全局自愈单点)永远活着。
|
||||
等待有限时间。超时后当前健康检查不再等待,但线程句柄仍由生命周期 owner
|
||||
持有到真实终态,换取看门狗可继续检测且停机屏障不会伪装收敛。
|
||||
2. probe_path —— 用子进程而非线程做挂载探测。子进程可以被 kill,因此探测
|
||||
本身是可放弃的,隔离期间可以无限次周期重试而不累积不可回收的资源。
|
||||
"""
|
||||
@@ -41,7 +41,7 @@ class RecoveryState(str, Enum):
|
||||
"""
|
||||
# 动作已在限定时间内执行完毕(内部抛异常也算完成,异常已记录)
|
||||
COMPLETED = "completed"
|
||||
# 超时仍未返回,判定为 block 型挂载故障,线程已被放弃
|
||||
# 超时仍未返回,判定为 block 型挂载故障,本轮不再等待但继续持有线程
|
||||
TIMEOUT = "timeout"
|
||||
# 同 key 的上一个动作仍未结束,本次未提交,避免持续泄漏冻死的线程
|
||||
BUSY = "busy"
|
||||
@@ -55,10 +55,12 @@ class RecoveryExecutor:
|
||||
健康检查周期都会在同一个死挂载上多泄漏一个线程。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""初始化在途线程注册表和当前生命周期的提交状态。"""
|
||||
# key -> 该 key 最近一次提交的执行线程
|
||||
self._running: Dict[str, threading.Thread] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._accepting = True
|
||||
|
||||
def run(self, actions: Dict[str, Callable[[], None]], timeout: float) -> Dict[str, RecoveryState]:
|
||||
"""
|
||||
@@ -85,26 +87,70 @@ class RecoveryExecutor:
|
||||
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if thread.is_alive():
|
||||
results[key] = RecoveryState.TIMEOUT
|
||||
logger.error(f"恢复动作超过 {timeout} 秒未返回,判定挂载无响应并放弃该线程: {key}")
|
||||
logger.error(f"恢复动作超过 {timeout} 秒未返回,判定挂载无响应,本轮不再等待: {key}")
|
||||
else:
|
||||
results[key] = RecoveryState.COMPLETED
|
||||
return results
|
||||
|
||||
def discard(self, key: str):
|
||||
def discard(self, key: str) -> None:
|
||||
"""
|
||||
丢弃一个 key 的在途记录。监控停止或配置重载时调用,避免残留条目
|
||||
让重建后的同名目录被误判为 BUSY。
|
||||
清理一个已到终态的 key;活线程继续保留,供生命周期停机屏障追踪。
|
||||
:param key: 动作标识
|
||||
"""
|
||||
with self._lock:
|
||||
self._running.pop(key, None)
|
||||
thread = self._running.get(key)
|
||||
if thread is None or not thread.is_alive():
|
||||
self._running.pop(key, None)
|
||||
|
||||
def clear(self):
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
清空全部在途记录。已经冻死的线程无法回收,这里只是不再跟踪它们。
|
||||
清理已经完成的记录,仍存活的线程继续由执行器持有。
|
||||
|
||||
兼容旧调用名,但不再丢弃挂死线程:遗失句柄会让宿主错误释放它仍可能使用
|
||||
的数据库和整理链资源。
|
||||
"""
|
||||
with self._lock:
|
||||
self._running = {
|
||||
key: thread
|
||||
for key, thread in self._running.items()
|
||||
if thread.is_alive()
|
||||
}
|
||||
|
||||
def request_stop(self) -> None:
|
||||
"""封住新恢复动作提交,既有线程只能自然完成。"""
|
||||
with self._lock:
|
||||
self._accepting = False
|
||||
|
||||
def close(self, deadline: float) -> bool:
|
||||
"""在绝对截止时间内等待全部恢复线程,超时继续保留活线程句柄。"""
|
||||
self.request_stop()
|
||||
with self._lock:
|
||||
threads = tuple(self._running.values())
|
||||
current_thread = threading.current_thread()
|
||||
for thread in threads:
|
||||
if thread is current_thread or not thread.is_alive():
|
||||
continue
|
||||
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
self.clear()
|
||||
with self._lock:
|
||||
return not any(thread.is_alive() for thread in self._running.values())
|
||||
|
||||
def reopen(self) -> bool:
|
||||
"""在旧恢复线程全部结束后,为新的 Monitor 生命周期恢复提交。"""
|
||||
self.clear()
|
||||
with self._lock:
|
||||
if any(thread.is_alive() for thread in self._running.values()):
|
||||
return False
|
||||
self._running.clear()
|
||||
self._accepting = True
|
||||
return True
|
||||
|
||||
def running_threads(self) -> tuple[threading.Thread, ...]:
|
||||
"""返回仍存活且由执行器持有的恢复线程快照。"""
|
||||
with self._lock:
|
||||
return tuple(
|
||||
thread for thread in self._running.values() if thread.is_alive()
|
||||
)
|
||||
|
||||
def _start(self, key: str, action: Callable[[], None]) -> Optional[threading.Thread]:
|
||||
"""
|
||||
@@ -114,6 +160,8 @@ class RecoveryExecutor:
|
||||
:return: 执行线程,未启动时为 None
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._accepting:
|
||||
return None
|
||||
running = self._running.get(key)
|
||||
if running is not None and running.is_alive():
|
||||
return None
|
||||
@@ -124,11 +172,18 @@ class RecoveryExecutor:
|
||||
daemon=True
|
||||
)
|
||||
self._running[key] = thread
|
||||
thread.start()
|
||||
return thread
|
||||
# 登记与 start 必须处于同一所有权临界区。否则 close() 可能把尚未
|
||||
# is_alive() 的句柄当成已结束并移除,随后该线程才真正启动。
|
||||
try:
|
||||
thread.start()
|
||||
except BaseException:
|
||||
if self._running.get(key) is thread:
|
||||
self._running.pop(key, None)
|
||||
raise
|
||||
return thread
|
||||
|
||||
@staticmethod
|
||||
def _execute(key: str, action: Callable[[], None]):
|
||||
def _execute(key: str, action: Callable[[], None]) -> None:
|
||||
"""
|
||||
执行一个恢复动作,异常只记录不外抛,避免一个目录的失败连累整批恢复。
|
||||
:param key: 动作标识
|
||||
|
||||
Reference in New Issue
Block a user