mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
fix(runtime): 收口调度与事件异步任务生命周期 (#6415)
This commit is contained in:
@@ -185,6 +185,7 @@ async def cookie_cloud_sync(
|
|||||||
|
|
||||||
@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None])
|
@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None])
|
||||||
async def reset(
|
async def reset(
|
||||||
|
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
|
||||||
command: SiteMutationCommand = Depends(get_site_mutation_command),
|
command: SiteMutationCommand = Depends(get_site_mutation_command),
|
||||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -194,9 +195,12 @@ async def reset(
|
|||||||
result = await command.reset()
|
result = await command.reset()
|
||||||
await get_configured_system_config().async_set(SystemConfigKey.IndexerSites, [])
|
await get_configured_system_config().async_set(SystemConfigKey.IndexerSites, [])
|
||||||
await get_configured_system_config().async_set(SystemConfigKey.RssSites, [])
|
await get_configured_system_config().async_set(SystemConfigKey.RssSites, [])
|
||||||
# 启动定时服务
|
resolve_background_task_registry(task_registry).create_sync(
|
||||||
Scheduler().start("cookiecloud", manual=True)
|
Scheduler().start,
|
||||||
# 插件站点删除
|
job_id="cookiecloud",
|
||||||
|
owner="api.site.reset",
|
||||||
|
manual=True,
|
||||||
|
)
|
||||||
return _SchemaResponse(success=result.success, message="站点已重置!")
|
return _SchemaResponse(success=result.success, message="站点已重置!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class EventDispatcher:
|
|||||||
event_loop: Callable[[], Any],
|
event_loop: Callable[[], Any],
|
||||||
event_factory: Callable[..., Any],
|
event_factory: Callable[..., Any],
|
||||||
error_handler: Callable[..., None],
|
error_handler: Callable[..., None],
|
||||||
|
async_handle_sink: Callable[[Any], bool] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""注入注册表、绑定器、执行器和错误策略回调。"""
|
"""注入注册表、绑定器、执行器和错误策略回调。"""
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
@@ -37,6 +38,7 @@ class EventDispatcher:
|
|||||||
self._event_loop = event_loop
|
self._event_loop = event_loop
|
||||||
self._event_factory = event_factory
|
self._event_factory = event_factory
|
||||||
self._error_handler = error_handler
|
self._error_handler = error_handler
|
||||||
|
self._async_handle_sink = async_handle_sink
|
||||||
|
|
||||||
def dispatch_chain(self, event: Any) -> bool:
|
def dispatch_chain(self, event: Any) -> bool:
|
||||||
"""同步按优先级顺序执行链式事件快照。"""
|
"""同步按优先级顺序执行链式事件快照。"""
|
||||||
@@ -119,10 +121,18 @@ class EventDispatcher:
|
|||||||
correlation_id=event.correlation_id,
|
correlation_id=event.correlation_id,
|
||||||
)
|
)
|
||||||
if inspect.iscoroutinefunction(handler):
|
if inspect.iscoroutinefunction(handler):
|
||||||
asyncio.run_coroutine_threadsafe(
|
coroutine = self.safe_invoke_async(handler, isolated)
|
||||||
self.safe_invoke_async(handler, isolated),
|
if self._async_handle_sink:
|
||||||
self._event_loop(),
|
self._async_handle_sink(coroutine)
|
||||||
)
|
continue
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(coroutine, self._event_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
coroutine.close()
|
||||||
|
logger.warning(
|
||||||
|
"事件 %s 的异步处理器无法投递,事件循环已停止",
|
||||||
|
event.event_type,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._executor().submit(
|
self._executor().submit(
|
||||||
self.safe_invoke_sync,
|
self.safe_invoke_sync,
|
||||||
|
|||||||
+158
-5
@@ -1,7 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from queue import Empty, PriorityQueue
|
from queue import Empty, PriorityQueue
|
||||||
from typing import Callable, Dict, List, Optional, Tuple, Union, Any, Type
|
from typing import Callable, Dict, List, Optional, Tuple, Union, Any, Type
|
||||||
|
|
||||||
@@ -28,6 +31,15 @@ DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
|||||||
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
||||||
INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始超时时间(秒)
|
INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始超时时间(秒)
|
||||||
MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒)
|
MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒)
|
||||||
|
_EVENT_STOP_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _EventAsyncHandle:
|
||||||
|
"""记录异步广播的取消代理和真实完成信号。"""
|
||||||
|
|
||||||
|
handle: concurrent.futures.Future[Any]
|
||||||
|
completion: concurrent.futures.Future[Any]
|
||||||
|
|
||||||
|
|
||||||
class Event:
|
class Event:
|
||||||
@@ -109,6 +121,10 @@ class EventManager(metaclass=Singleton):
|
|||||||
self.__lock = threading.Lock()
|
self.__lock = threading.Lock()
|
||||||
# 退出事件
|
# 退出事件
|
||||||
self.__event = threading.Event()
|
self.__event = threading.Event()
|
||||||
|
# 广播异步处理器的生命周期由事件总线自己持有,避免关闭后仍向主循环运行。
|
||||||
|
self.__lifecycle_lock = threading.RLock()
|
||||||
|
self.__lifecycle_state = "new"
|
||||||
|
self.__async_handles: Dict[int, _EventAsyncHandle] = {}
|
||||||
# 由上层管理器注册的处理器实例解析器
|
# 由上层管理器注册的处理器实例解析器
|
||||||
self.__handler_instance_resolvers: Dict[str, HandlerInstanceResolver] = {}
|
self.__handler_instance_resolvers: Dict[str, HandlerInstanceResolver] = {}
|
||||||
# 由启动组合层注入的错误通知回调
|
# 由启动组合层注入的错误通知回调
|
||||||
@@ -138,6 +154,7 @@ class EventManager(metaclass=Singleton):
|
|||||||
event_loop=lambda: global_vars.loop,
|
event_loop=lambda: global_vars.loop,
|
||||||
event_factory=Event,
|
event_factory=Event,
|
||||||
error_handler=lambda **kwargs: self.__handle_event_error(**kwargs),
|
error_handler=lambda **kwargs: self.__handle_event_error(**kwargs),
|
||||||
|
async_handle_sink=self.__register_async_handle,
|
||||||
)
|
)
|
||||||
|
|
||||||
def register_handler_instance_resolver(
|
def register_handler_instance_resolver(
|
||||||
@@ -165,8 +182,16 @@ class EventManager(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
开始广播事件处理线程
|
开始广播事件处理线程
|
||||||
"""
|
"""
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
if self.__lifecycle_state == "running":
|
||||||
|
return
|
||||||
|
if self.__lifecycle_state == "stopping":
|
||||||
|
logger.warning("事件处理仍在停止,忽略重复启动")
|
||||||
|
return
|
||||||
|
self.__lifecycle_state = "running"
|
||||||
|
self.__event.set()
|
||||||
|
self.__consumer_threads = []
|
||||||
# 启动消费者线程用于处理广播事件
|
# 启动消费者线程用于处理广播事件
|
||||||
self.__event.set()
|
|
||||||
for _ in range(MIN_EVENT_CONSUMER_THREADS):
|
for _ in range(MIN_EVENT_CONSUMER_THREADS):
|
||||||
thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True)
|
thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -177,14 +202,140 @@ class EventManager(metaclass=Singleton):
|
|||||||
停止广播事件处理线程
|
停止广播事件处理线程
|
||||||
"""
|
"""
|
||||||
logger.info("正在停止事件处理...")
|
logger.info("正在停止事件处理...")
|
||||||
self.__event.clear() # 停止广播事件处理
|
consumer_threads = self.__begin_stop()
|
||||||
try:
|
try:
|
||||||
# 通过遍历保存的线程来等待它们完成
|
self.__join_consumer_threads(consumer_threads)
|
||||||
for consumer_thread in self.__consumer_threads:
|
self.__discard_stop_sentinels()
|
||||||
consumer_thread.join()
|
self.__cancel_async_handles()
|
||||||
logger.info("事件处理停止完成")
|
logger.info("事件处理停止完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||||
|
finally:
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
self.__lifecycle_state = "stopped"
|
||||||
|
self.__consumer_threads = []
|
||||||
|
|
||||||
|
async def stop_async(self) -> None:
|
||||||
|
"""停止广播消费者并等待已投递的异步处理器收口。"""
|
||||||
|
logger.info("正在停止事件处理...")
|
||||||
|
consumer_threads = self.__begin_stop()
|
||||||
|
try:
|
||||||
|
if consumer_threads:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.__join_consumer_threads,
|
||||||
|
consumer_threads,
|
||||||
|
)
|
||||||
|
self.__discard_stop_sentinels()
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
handles = tuple(self.__async_handles.values())
|
||||||
|
for handle in handles:
|
||||||
|
handle.handle.cancel()
|
||||||
|
if handles:
|
||||||
|
await asyncio.gather(
|
||||||
|
*(
|
||||||
|
asyncio.shield(asyncio.wrap_future(handle.completion))
|
||||||
|
for handle in handles
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
logger.info("事件处理停止完成")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
self.__lifecycle_state = "stopped"
|
||||||
|
self.__consumer_threads = []
|
||||||
|
|
||||||
|
def __begin_stop(self) -> tuple[threading.Thread, ...]:
|
||||||
|
"""关闭提交入口并唤醒消费者线程。"""
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
self.__lifecycle_state = "stopping"
|
||||||
|
self.__event.clear()
|
||||||
|
consumer_threads = tuple(self.__consumer_threads)
|
||||||
|
if consumer_threads:
|
||||||
|
self.__event_queue.put((float("-inf"), _EVENT_STOP_SENTINEL))
|
||||||
|
return consumer_threads
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __join_consumer_threads(consumer_threads: tuple[threading.Thread, ...]) -> None:
|
||||||
|
"""在线程池或同步兼容入口中等待事件消费者退出。"""
|
||||||
|
for consumer_thread in consumer_threads:
|
||||||
|
consumer_thread.join()
|
||||||
|
|
||||||
|
def __discard_stop_sentinels(self) -> None:
|
||||||
|
"""清理仅用于唤醒消费者的标记,保留尚未消费的业务事件。"""
|
||||||
|
pending = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = self.__event_queue.get_nowait()
|
||||||
|
except Empty:
|
||||||
|
break
|
||||||
|
if item[1] is not _EVENT_STOP_SENTINEL:
|
||||||
|
pending.append(item)
|
||||||
|
for item in pending:
|
||||||
|
self.__event_queue.put(item)
|
||||||
|
|
||||||
|
def __cancel_async_handles(self) -> None:
|
||||||
|
"""请求取消所有仍由事件总线持有的异步处理器。"""
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
handles = tuple(self.__async_handles.values())
|
||||||
|
for handle in handles:
|
||||||
|
handle.handle.cancel()
|
||||||
|
|
||||||
|
def __register_async_handle(
|
||||||
|
self,
|
||||||
|
coroutine: Any,
|
||||||
|
) -> bool:
|
||||||
|
"""在同一生命周期临界区提交并登记异步广播处理器。"""
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
if self.__lifecycle_state != "running":
|
||||||
|
coroutine.close()
|
||||||
|
return False
|
||||||
|
completion: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
||||||
|
started = threading.Event()
|
||||||
|
|
||||||
|
async def _tracked() -> None:
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
result = await coroutine
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not completion.done():
|
||||||
|
completion.cancel()
|
||||||
|
except Exception as err:
|
||||||
|
if not completion.done():
|
||||||
|
completion.set_exception(err)
|
||||||
|
else:
|
||||||
|
if not completion.done():
|
||||||
|
completion.set_result(result)
|
||||||
|
|
||||||
|
tracked = _tracked()
|
||||||
|
try:
|
||||||
|
handle = asyncio.run_coroutine_threadsafe(tracked, global_vars.loop)
|
||||||
|
except RuntimeError:
|
||||||
|
tracked.close()
|
||||||
|
coroutine.close()
|
||||||
|
logger.warning("异步事件处理器无法投递,事件循环已停止")
|
||||||
|
return False
|
||||||
|
self.__async_handles[id(completion)] = _EventAsyncHandle(
|
||||||
|
handle=handle,
|
||||||
|
completion=completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _complete_unstarted_submission(
|
||||||
|
submitted: concurrent.futures.Future[Any],
|
||||||
|
) -> None:
|
||||||
|
if submitted.cancelled() and not started.is_set():
|
||||||
|
coroutine.close()
|
||||||
|
completion.cancel()
|
||||||
|
|
||||||
|
handle.add_done_callback(_complete_unstarted_submission)
|
||||||
|
completion.add_done_callback(self.__remove_async_handle)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __remove_async_handle(self, handle: concurrent.futures.Future[Any]) -> None:
|
||||||
|
"""异步处理器完成后移除其 owner 句柄。"""
|
||||||
|
with self.__lifecycle_lock:
|
||||||
|
self.__async_handles.pop(id(handle), None)
|
||||||
|
|
||||||
def check(self, etype: Union[EventType, ChainEventType]) -> bool:
|
def check(self, etype: Union[EventType, ChainEventType]) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -429,6 +580,8 @@ class EventManager(metaclass=Singleton):
|
|||||||
while self.__event.is_set():
|
while self.__event.is_set():
|
||||||
try:
|
try:
|
||||||
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
|
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
|
||||||
|
if event is _EVENT_STOP_SENTINEL:
|
||||||
|
break
|
||||||
record_metric(
|
record_metric(
|
||||||
"event.queue.depth",
|
"event.queue.depth",
|
||||||
self.__event_queue.qsize(),
|
self.__event_queue.qsize(),
|
||||||
|
|||||||
+653
-179
File diff suppressed because it is too large
Load Diff
@@ -580,7 +580,7 @@ async def stop_modules():
|
|||||||
|
|
||||||
await run_step("AI智能体", stop_agent)
|
await run_step("AI智能体", stop_agent)
|
||||||
await run_step("模块", lambda: ModuleManager().shutdown())
|
await run_step("模块", lambda: ModuleManager().shutdown())
|
||||||
await run_step("事件消费", lambda: EventManager().stop())
|
await run_step("事件消费", lambda: EventManager().stop_async())
|
||||||
await run_step("浏览器会话", close_browser_sessions)
|
await run_step("浏览器会话", close_browser_sessions)
|
||||||
await run_step("托管资源", stop_managed_resources)
|
await run_step("托管资源", stop_managed_resources)
|
||||||
await run_step("DoH服务", lambda: DohHelper().shutdown())
|
await run_step("DoH服务", lambda: DohHelper().shutdown())
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def init_scheduler():
|
|||||||
|
|
||||||
def stop_scheduler():
|
def stop_scheduler():
|
||||||
"""
|
"""
|
||||||
停止定时器;生命周期事件循环中返回有限等待的兼容协程。
|
停止定时器;生命周期事件循环中返回可等待的收口协程。
|
||||||
"""
|
"""
|
||||||
scheduler = Scheduler()
|
scheduler = Scheduler()
|
||||||
try:
|
try:
|
||||||
@@ -24,7 +24,7 @@ def stop_scheduler():
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
scheduler.stop()
|
scheduler.stop()
|
||||||
return None
|
return None
|
||||||
return scheduler.async_stop()
|
return scheduler.stop_async()
|
||||||
|
|
||||||
|
|
||||||
def restart_scheduler():
|
def restart_scheduler():
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
|
|||||||
- IMDb 同步 `clear_cache()` ABI 在事件循环内触发的异步缓存清理登记为
|
- IMDb 同步 `clear_cache()` ABI 在事件循环内触发的异步缓存清理登记为
|
||||||
`module.imdb.cache_clear`;同步调用方式和无运行事件循环时的立即清理行为保持不变,宿主关停后不再
|
`module.imdb.cache_clear`;同步调用方式和无运行事件循环时的立即清理行为保持不变,宿主关停后不再
|
||||||
接受新的清理任务。
|
接受新的清理任务。
|
||||||
- Scheduler 的协程作业与异步进度收尾由 Scheduler 自有任务集合持有;同步 `start()` / `stop()` ABI 保持,
|
- Scheduler 的协程作业与异步进度收尾由 Scheduler 自有句柄表持有;同步 `start()` / `stop()` ABI 保持,
|
||||||
生命周期关闭入口额外等待有限预算,跨线程提交的 Future 也会在停止时收到取消请求。
|
生命周期关闭入口等待目标事件循环确认真实收尾,跨线程取消代理不作为任务完成凭据。
|
||||||
|
|
||||||
### Transfer pending / 文件整理
|
### Transfer pending / 文件整理
|
||||||
|
|
||||||
|
|||||||
@@ -839,8 +839,8 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
|
|||||||
- IMDb 同步清缓存兼容入口在运行事件循环时改由 TaskRegistry 登记异步缓存清理任务,owner 为
|
- IMDb 同步清缓存兼容入口在运行事件循环时改由 TaskRegistry 登记异步缓存清理任务,owner 为
|
||||||
`module.imdb.cache_clear`;同步签名、模块调用方式和无事件循环时的立即清理语义保持不变。
|
`module.imdb.cache_clear`;同步签名、模块调用方式和无事件循环时的立即清理语义保持不变。
|
||||||
- Scheduler 的协程作业和异步进度收尾不再使用无主 `create_task` 或丢弃跨线程 Future;由 Scheduler 自有
|
- Scheduler 的协程作业和异步进度收尾不再使用无主 `create_task` 或丢弃跨线程 Future;由 Scheduler 自有
|
||||||
任务集合登记、停止时取消,生命周期入口通过异步兼容包装器在有限预算内等待收口,保留旧同步
|
句柄表登记并在停止时取消,completion 只在目标事件循环确认真实收尾后完成。关闭总预算由宿主生命周期
|
||||||
`Scheduler.start()` / `Scheduler.stop()` 与插件调度 ABI。
|
统一控制,保留旧同步 `Scheduler.start()` / `Scheduler.stop()` 与插件调度 ABI。
|
||||||
|
|
||||||
#### ARCH-251:用现有数据库做首个 durable side-effect pilot
|
#### ARCH-251:用现有数据库做首个 durable side-effect pilot
|
||||||
|
|
||||||
|
|||||||
+33
-4
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6451,
|
"edge_count": 6479,
|
||||||
"edge_sha256": "9075d8717e384580cd6a41bc36685438770db4a8d8f18c57e6c494f32937113a",
|
"edge_sha256": "a65f8d4024e2299b37510359c7ffea91219f0aa0ed9673b74a3eb22d51d6c67f",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -1510,6 +1510,8 @@
|
|||||||
"app.api.context -> app.application.subscription.delete",
|
"app.api.context -> app.application.subscription.delete",
|
||||||
"app.api.context -> app.application.subscription.identity",
|
"app.api.context -> app.application.subscription.identity",
|
||||||
"app.api.context -> app.application.subscription.mutation",
|
"app.api.context -> app.application.subscription.mutation",
|
||||||
|
"app.api.context -> app.runtime",
|
||||||
|
"app.api.context -> app.runtime.tasks",
|
||||||
"app.api.context -> app.startup",
|
"app.api.context -> app.startup",
|
||||||
"app.api.context -> app.startup.context",
|
"app.api.context -> app.startup.context",
|
||||||
"app.api.dependencies.agent -> app.api",
|
"app.api.dependencies.agent -> app.api",
|
||||||
@@ -1599,6 +1601,7 @@
|
|||||||
"app.api.dependencies.subscription -> app.runtime",
|
"app.api.dependencies.subscription -> app.runtime",
|
||||||
"app.api.dependencies.subscription -> app.runtime.events",
|
"app.api.dependencies.subscription -> app.runtime.events",
|
||||||
"app.api.dependencies.subscription -> app.runtime.log",
|
"app.api.dependencies.subscription -> app.runtime.log",
|
||||||
|
"app.api.dependencies.subscription -> app.runtime.tasks",
|
||||||
"app.api.dependencies.subscription -> app.schemas",
|
"app.api.dependencies.subscription -> app.schemas",
|
||||||
"app.api.dependencies.subscription -> app.schemas.types",
|
"app.api.dependencies.subscription -> app.schemas.types",
|
||||||
"app.api.dependencies.subscription -> app.startup",
|
"app.api.dependencies.subscription -> app.startup",
|
||||||
@@ -1682,6 +1685,7 @@
|
|||||||
"app.api.endpoints.anthropic -> app.agent",
|
"app.api.endpoints.anthropic -> app.agent",
|
||||||
"app.api.endpoints.anthropic -> app.agent.runtime_loader",
|
"app.api.endpoints.anthropic -> app.agent.runtime_loader",
|
||||||
"app.api.endpoints.anthropic -> app.api",
|
"app.api.endpoints.anthropic -> app.api",
|
||||||
|
"app.api.endpoints.anthropic -> app.api.context",
|
||||||
"app.api.endpoints.anthropic -> app.api.endpoints",
|
"app.api.endpoints.anthropic -> app.api.endpoints",
|
||||||
"app.api.endpoints.anthropic -> app.api.endpoints.openai",
|
"app.api.endpoints.anthropic -> app.api.endpoints.openai",
|
||||||
"app.api.endpoints.anthropic -> app.api.openai_utils",
|
"app.api.endpoints.anthropic -> app.api.openai_utils",
|
||||||
@@ -1689,6 +1693,8 @@
|
|||||||
"app.api.endpoints.anthropic -> app.api.presentation.sse",
|
"app.api.endpoints.anthropic -> app.api.presentation.sse",
|
||||||
"app.api.endpoints.anthropic -> app.application",
|
"app.api.endpoints.anthropic -> app.application",
|
||||||
"app.api.endpoints.anthropic -> app.application.configuration",
|
"app.api.endpoints.anthropic -> app.application.configuration",
|
||||||
|
"app.api.endpoints.anthropic -> app.runtime",
|
||||||
|
"app.api.endpoints.anthropic -> app.runtime.tasks",
|
||||||
"app.api.endpoints.anthropic -> app.schemas",
|
"app.api.endpoints.anthropic -> app.schemas",
|
||||||
"app.api.endpoints.anthropic -> app.schemas.openai",
|
"app.api.endpoints.anthropic -> app.schemas.openai",
|
||||||
"app.api.endpoints.auth -> app.api",
|
"app.api.endpoints.auth -> app.api",
|
||||||
@@ -1833,6 +1839,7 @@
|
|||||||
"app.api.endpoints.history -> app.runtime.config",
|
"app.api.endpoints.history -> app.runtime.config",
|
||||||
"app.api.endpoints.history -> app.runtime.log",
|
"app.api.endpoints.history -> app.runtime.log",
|
||||||
"app.api.endpoints.history -> app.runtime.progress",
|
"app.api.endpoints.history -> app.runtime.progress",
|
||||||
|
"app.api.endpoints.history -> app.runtime.tasks",
|
||||||
"app.api.endpoints.history -> app.schemas",
|
"app.api.endpoints.history -> app.schemas",
|
||||||
"app.api.endpoints.history -> app.schemas.common",
|
"app.api.endpoints.history -> app.schemas.common",
|
||||||
"app.api.endpoints.history -> app.schemas.history",
|
"app.api.endpoints.history -> app.schemas.history",
|
||||||
@@ -1945,6 +1952,7 @@
|
|||||||
"app.api.endpoints.message -> app.adapters.web.security",
|
"app.api.endpoints.message -> app.adapters.web.security",
|
||||||
"app.api.endpoints.message -> app.adapters.web.security.access",
|
"app.api.endpoints.message -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.message -> app.api",
|
"app.api.endpoints.message -> app.api",
|
||||||
|
"app.api.endpoints.message -> app.api.context",
|
||||||
"app.api.endpoints.message -> app.api.dependencies",
|
"app.api.endpoints.message -> app.api.dependencies",
|
||||||
"app.api.endpoints.message -> app.api.dependencies.agent",
|
"app.api.endpoints.message -> app.api.dependencies.agent",
|
||||||
"app.api.endpoints.message -> app.api.dependencies.auth",
|
"app.api.endpoints.message -> app.api.dependencies.auth",
|
||||||
@@ -1961,6 +1969,7 @@
|
|||||||
"app.api.endpoints.message -> app.runtime.extensions",
|
"app.api.endpoints.message -> app.runtime.extensions",
|
||||||
"app.api.endpoints.message -> app.runtime.extensions.service_config",
|
"app.api.endpoints.message -> app.runtime.extensions.service_config",
|
||||||
"app.api.endpoints.message -> app.runtime.log",
|
"app.api.endpoints.message -> app.runtime.log",
|
||||||
|
"app.api.endpoints.message -> app.runtime.tasks",
|
||||||
"app.api.endpoints.message -> app.schemas",
|
"app.api.endpoints.message -> app.schemas",
|
||||||
"app.api.endpoints.message -> app.schemas.message",
|
"app.api.endpoints.message -> app.schemas.message",
|
||||||
"app.api.endpoints.message -> app.schemas.response",
|
"app.api.endpoints.message -> app.schemas.response",
|
||||||
@@ -2029,11 +2038,14 @@
|
|||||||
"app.api.endpoints.openai -> app.agent.contracts",
|
"app.api.endpoints.openai -> app.agent.contracts",
|
||||||
"app.api.endpoints.openai -> app.agent.runtime_loader",
|
"app.api.endpoints.openai -> app.agent.runtime_loader",
|
||||||
"app.api.endpoints.openai -> app.api",
|
"app.api.endpoints.openai -> app.api",
|
||||||
|
"app.api.endpoints.openai -> app.api.context",
|
||||||
"app.api.endpoints.openai -> app.api.openai_utils",
|
"app.api.endpoints.openai -> app.api.openai_utils",
|
||||||
"app.api.endpoints.openai -> app.api.presentation",
|
"app.api.endpoints.openai -> app.api.presentation",
|
||||||
"app.api.endpoints.openai -> app.api.presentation.sse",
|
"app.api.endpoints.openai -> app.api.presentation.sse",
|
||||||
"app.api.endpoints.openai -> app.application",
|
"app.api.endpoints.openai -> app.application",
|
||||||
"app.api.endpoints.openai -> app.application.configuration",
|
"app.api.endpoints.openai -> app.application.configuration",
|
||||||
|
"app.api.endpoints.openai -> app.runtime",
|
||||||
|
"app.api.endpoints.openai -> app.runtime.tasks",
|
||||||
"app.api.endpoints.openai -> app.schemas",
|
"app.api.endpoints.openai -> app.schemas",
|
||||||
"app.api.endpoints.openai -> app.schemas.openai",
|
"app.api.endpoints.openai -> app.schemas.openai",
|
||||||
"app.api.endpoints.openai -> app.schemas.types",
|
"app.api.endpoints.openai -> app.schemas.types",
|
||||||
@@ -2048,6 +2060,7 @@
|
|||||||
"app.api.endpoints.plugin -> app.adapters.web.security",
|
"app.api.endpoints.plugin -> app.adapters.web.security",
|
||||||
"app.api.endpoints.plugin -> app.adapters.web.security.access",
|
"app.api.endpoints.plugin -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.plugin -> app.api",
|
"app.api.endpoints.plugin -> app.api",
|
||||||
|
"app.api.endpoints.plugin -> app.api.context",
|
||||||
"app.api.endpoints.plugin -> app.api.dependencies",
|
"app.api.endpoints.plugin -> app.api.dependencies",
|
||||||
"app.api.endpoints.plugin -> app.api.dependencies.auth",
|
"app.api.endpoints.plugin -> app.api.dependencies.auth",
|
||||||
"app.api.endpoints.plugin -> app.api.dependencies.plugin",
|
"app.api.endpoints.plugin -> app.api.dependencies.plugin",
|
||||||
@@ -2069,6 +2082,7 @@
|
|||||||
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
|
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
|
||||||
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
|
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
|
||||||
"app.api.endpoints.plugin -> app.runtime.log",
|
"app.api.endpoints.plugin -> app.runtime.log",
|
||||||
|
"app.api.endpoints.plugin -> app.runtime.tasks",
|
||||||
"app.api.endpoints.plugin -> app.schemas",
|
"app.api.endpoints.plugin -> app.schemas",
|
||||||
"app.api.endpoints.plugin -> app.schemas.common",
|
"app.api.endpoints.plugin -> app.schemas.common",
|
||||||
"app.api.endpoints.plugin -> app.schemas.exception",
|
"app.api.endpoints.plugin -> app.schemas.exception",
|
||||||
@@ -2122,6 +2136,7 @@
|
|||||||
"app.api.endpoints.site -> app.adapters.web.security",
|
"app.api.endpoints.site -> app.adapters.web.security",
|
||||||
"app.api.endpoints.site -> app.adapters.web.security.access",
|
"app.api.endpoints.site -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.site -> app.api",
|
"app.api.endpoints.site -> app.api",
|
||||||
|
"app.api.endpoints.site -> app.api.context",
|
||||||
"app.api.endpoints.site -> app.api.dependencies",
|
"app.api.endpoints.site -> app.api.dependencies",
|
||||||
"app.api.endpoints.site -> app.api.dependencies.auth",
|
"app.api.endpoints.site -> app.api.dependencies.auth",
|
||||||
"app.api.endpoints.site -> app.api.dependencies.site",
|
"app.api.endpoints.site -> app.api.dependencies.site",
|
||||||
@@ -2145,6 +2160,7 @@
|
|||||||
"app.api.endpoints.site -> app.domain.site",
|
"app.api.endpoints.site -> app.domain.site",
|
||||||
"app.api.endpoints.site -> app.runtime",
|
"app.api.endpoints.site -> app.runtime",
|
||||||
"app.api.endpoints.site -> app.runtime.log",
|
"app.api.endpoints.site -> app.runtime.log",
|
||||||
|
"app.api.endpoints.site -> app.runtime.tasks",
|
||||||
"app.api.endpoints.site -> app.schemas",
|
"app.api.endpoints.site -> app.schemas",
|
||||||
"app.api.endpoints.site -> app.schemas.common",
|
"app.api.endpoints.site -> app.schemas.common",
|
||||||
"app.api.endpoints.site -> app.schemas.response",
|
"app.api.endpoints.site -> app.schemas.response",
|
||||||
@@ -2180,6 +2196,7 @@
|
|||||||
"app.api.endpoints.subscribe -> app.adapters.web.security",
|
"app.api.endpoints.subscribe -> app.adapters.web.security",
|
||||||
"app.api.endpoints.subscribe -> app.adapters.web.security.access",
|
"app.api.endpoints.subscribe -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.subscribe -> app.api",
|
"app.api.endpoints.subscribe -> app.api",
|
||||||
|
"app.api.endpoints.subscribe -> app.api.context",
|
||||||
"app.api.endpoints.subscribe -> app.api.dependencies",
|
"app.api.endpoints.subscribe -> app.api.dependencies",
|
||||||
"app.api.endpoints.subscribe -> app.api.dependencies.auth",
|
"app.api.endpoints.subscribe -> app.api.dependencies.auth",
|
||||||
"app.api.endpoints.subscribe -> app.api.dependencies.subscription",
|
"app.api.endpoints.subscribe -> app.api.dependencies.subscription",
|
||||||
@@ -2201,6 +2218,7 @@
|
|||||||
"app.api.endpoints.subscribe -> app.domain.metainfo",
|
"app.api.endpoints.subscribe -> app.domain.metainfo",
|
||||||
"app.api.endpoints.subscribe -> app.runtime",
|
"app.api.endpoints.subscribe -> app.runtime",
|
||||||
"app.api.endpoints.subscribe -> app.runtime.events",
|
"app.api.endpoints.subscribe -> app.runtime.events",
|
||||||
|
"app.api.endpoints.subscribe -> app.runtime.tasks",
|
||||||
"app.api.endpoints.subscribe -> app.schemas",
|
"app.api.endpoints.subscribe -> app.schemas",
|
||||||
"app.api.endpoints.subscribe -> app.schemas.common",
|
"app.api.endpoints.subscribe -> app.schemas.common",
|
||||||
"app.api.endpoints.subscribe -> app.schemas.event",
|
"app.api.endpoints.subscribe -> app.schemas.event",
|
||||||
@@ -2347,9 +2365,12 @@
|
|||||||
"app.api.endpoints.webhook -> app.adapters.web.security",
|
"app.api.endpoints.webhook -> app.adapters.web.security",
|
||||||
"app.api.endpoints.webhook -> app.adapters.web.security.access",
|
"app.api.endpoints.webhook -> app.adapters.web.security.access",
|
||||||
"app.api.endpoints.webhook -> app.api",
|
"app.api.endpoints.webhook -> app.api",
|
||||||
|
"app.api.endpoints.webhook -> app.api.context",
|
||||||
"app.api.endpoints.webhook -> app.api.response",
|
"app.api.endpoints.webhook -> app.api.response",
|
||||||
"app.api.endpoints.webhook -> app.chain",
|
"app.api.endpoints.webhook -> app.chain",
|
||||||
"app.api.endpoints.webhook -> app.chain.webhook",
|
"app.api.endpoints.webhook -> app.chain.webhook",
|
||||||
|
"app.api.endpoints.webhook -> app.runtime",
|
||||||
|
"app.api.endpoints.webhook -> app.runtime.tasks",
|
||||||
"app.api.endpoints.webhook -> app.schemas",
|
"app.api.endpoints.webhook -> app.schemas",
|
||||||
"app.api.endpoints.webhook -> app.schemas.response",
|
"app.api.endpoints.webhook -> app.schemas.response",
|
||||||
"app.api.endpoints.workflow -> app.adapters",
|
"app.api.endpoints.workflow -> app.adapters",
|
||||||
@@ -2555,6 +2576,8 @@
|
|||||||
"app.application.mediaserver -> app.schemas.mediaserver",
|
"app.application.mediaserver -> app.schemas.mediaserver",
|
||||||
"app.application.mediaserver -> app.schemas.system",
|
"app.application.mediaserver -> app.schemas.system",
|
||||||
"app.application.mediaserver -> app.schemas.types",
|
"app.application.mediaserver -> app.schemas.types",
|
||||||
|
"app.application.messaging.agent -> app.runtime",
|
||||||
|
"app.application.messaging.agent -> app.runtime.tasks",
|
||||||
"app.application.messaging.agent -> app.schemas",
|
"app.application.messaging.agent -> app.schemas",
|
||||||
"app.application.messaging.agent -> app.schemas.types",
|
"app.application.messaging.agent -> app.schemas.types",
|
||||||
"app.application.messaging.chat -> app.application",
|
"app.application.messaging.chat -> app.application",
|
||||||
@@ -3619,7 +3642,6 @@
|
|||||||
"app.db.oper.passkey -> app.db.base",
|
"app.db.oper.passkey -> app.db.base",
|
||||||
"app.db.oper.passkey -> app.db.models",
|
"app.db.oper.passkey -> app.db.models",
|
||||||
"app.db.oper.passkey -> app.db.models.passkey",
|
"app.db.oper.passkey -> app.db.models.passkey",
|
||||||
"app.db.oper.passkey -> app.db.uow",
|
|
||||||
"app.db.oper.plugindata -> app.db",
|
"app.db.oper.plugindata -> app.db",
|
||||||
"app.db.oper.plugindata -> app.db.base",
|
"app.db.oper.plugindata -> app.db.base",
|
||||||
"app.db.oper.plugindata -> app.db.models",
|
"app.db.oper.plugindata -> app.db.models",
|
||||||
@@ -4300,6 +4322,7 @@
|
|||||||
"app.modules.imdb.api -> app.runtime.cache",
|
"app.modules.imdb.api -> app.runtime.cache",
|
||||||
"app.modules.imdb.api -> app.runtime.log",
|
"app.modules.imdb.api -> app.runtime.log",
|
||||||
"app.modules.imdb.api -> app.runtime.settings",
|
"app.modules.imdb.api -> app.runtime.settings",
|
||||||
|
"app.modules.imdb.api -> app.runtime.tasks",
|
||||||
"app.modules.indexer -> app.application",
|
"app.modules.indexer -> app.application",
|
||||||
"app.modules.indexer -> app.application.site",
|
"app.modules.indexer -> app.application.site",
|
||||||
"app.modules.indexer -> app.application.site.health",
|
"app.modules.indexer -> app.application.site.health",
|
||||||
@@ -5626,6 +5649,7 @@
|
|||||||
"app.runtime.extensions.plugin_manager -> app.foundation.version",
|
"app.runtime.extensions.plugin_manager -> app.foundation.version",
|
||||||
"app.runtime.extensions.plugin_manager -> app.runtime",
|
"app.runtime.extensions.plugin_manager -> app.runtime",
|
||||||
"app.runtime.extensions.plugin_manager -> app.runtime.events",
|
"app.runtime.extensions.plugin_manager -> app.runtime.events",
|
||||||
|
"app.runtime.extensions.plugin_manager -> app.runtime.execution",
|
||||||
"app.runtime.extensions.plugin_manager -> app.runtime.extensions",
|
"app.runtime.extensions.plugin_manager -> app.runtime.extensions",
|
||||||
"app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin",
|
"app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin",
|
||||||
"app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.access",
|
"app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.access",
|
||||||
@@ -5994,6 +6018,8 @@
|
|||||||
"app.startup.context -> app.application.subscription.identity",
|
"app.startup.context -> app.application.subscription.identity",
|
||||||
"app.startup.context -> app.application.subscription.mutation",
|
"app.startup.context -> app.application.subscription.mutation",
|
||||||
"app.startup.context -> app.application.workflow",
|
"app.startup.context -> app.application.workflow",
|
||||||
|
"app.startup.context -> app.runtime",
|
||||||
|
"app.startup.context -> app.runtime.tasks",
|
||||||
"app.startup.database -> app.adapters",
|
"app.startup.database -> app.adapters",
|
||||||
"app.startup.database -> app.adapters.system",
|
"app.startup.database -> app.adapters.system",
|
||||||
"app.startup.database -> app.adapters.system.backup",
|
"app.startup.database -> app.adapters.system.backup",
|
||||||
@@ -6057,6 +6083,7 @@
|
|||||||
"app.startup.lifecycle -> app.runtime.log",
|
"app.startup.lifecycle -> app.runtime.log",
|
||||||
"app.startup.lifecycle -> app.runtime.settings",
|
"app.startup.lifecycle -> app.runtime.settings",
|
||||||
"app.startup.lifecycle -> app.runtime.state",
|
"app.startup.lifecycle -> app.runtime.state",
|
||||||
|
"app.startup.lifecycle -> app.runtime.tasks",
|
||||||
"app.startup.lifecycle -> app.runtime.topology",
|
"app.startup.lifecycle -> app.runtime.topology",
|
||||||
"app.startup.lifecycle -> app.startup",
|
"app.startup.lifecycle -> app.startup",
|
||||||
"app.startup.lifecycle -> app.startup.cache_initializer",
|
"app.startup.lifecycle -> app.startup.cache_initializer",
|
||||||
@@ -6171,6 +6198,7 @@
|
|||||||
"app.startup.modules_initializer -> app.runtime.observability",
|
"app.startup.modules_initializer -> app.runtime.observability",
|
||||||
"app.startup.modules_initializer -> app.runtime.settings",
|
"app.startup.modules_initializer -> app.runtime.settings",
|
||||||
"app.startup.modules_initializer -> app.runtime.state",
|
"app.startup.modules_initializer -> app.runtime.state",
|
||||||
|
"app.startup.modules_initializer -> app.runtime.tasks",
|
||||||
"app.startup.modules_initializer -> app.runtime.thread",
|
"app.startup.modules_initializer -> app.runtime.thread",
|
||||||
"app.startup.modules_initializer -> app.scheduler",
|
"app.startup.modules_initializer -> app.scheduler",
|
||||||
"app.startup.modules_initializer -> app.schemas",
|
"app.startup.modules_initializer -> app.schemas",
|
||||||
@@ -6468,7 +6496,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 799,
|
"module_count": 800,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -7161,6 +7189,7 @@
|
|||||||
"app.runtime.scheduling",
|
"app.runtime.scheduling",
|
||||||
"app.runtime.settings",
|
"app.runtime.settings",
|
||||||
"app.runtime.state",
|
"app.runtime.state",
|
||||||
|
"app.runtime.tasks",
|
||||||
"app.runtime.thread",
|
"app.runtime.thread",
|
||||||
"app.runtime.topology",
|
"app.runtime.topology",
|
||||||
"app.scheduler",
|
"app.scheduler",
|
||||||
|
|||||||
@@ -127,9 +127,15 @@ def _add_agent_task(trigger_type: str, trigger_value: str, prefix: str):
|
|||||||
def _build_agent_task_scheduler(reconcile: bool = False) -> Scheduler:
|
def _build_agent_task_scheduler(reconcile: bool = False) -> Scheduler:
|
||||||
"""构造不启动后台线程的 Agent 任务调度器。"""
|
"""构造不启动后台线程的 Agent 任务调度器。"""
|
||||||
scheduler = object.__new__(Scheduler)
|
scheduler = object.__new__(Scheduler)
|
||||||
|
scheduler._event = threading.Event()
|
||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
scheduler._agent_task_interruptions_reconciled = False
|
scheduler._agent_task_interruptions_reconciled = False
|
||||||
if reconcile:
|
if reconcile:
|
||||||
scheduler._reconcile_agent_task_interruptions()
|
scheduler._reconcile_agent_task_interruptions()
|
||||||
@@ -316,6 +322,11 @@ def test_scheduler_registers_and_removes_agent_task_job() -> None:
|
|||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
|
|
||||||
next_run_at = scheduler.update_agent_task_job(task.id)
|
next_run_at = scheduler.update_agent_task_job(task.id)
|
||||||
job_id = scheduler._get_agent_task_job_id(task.id)
|
job_id = scheduler._get_agent_task_job_id(task.id)
|
||||||
@@ -642,6 +653,63 @@ async def test_scheduler_config_reload_does_not_interrupt_running_agent_task() -
|
|||||||
manager.process_message.assert_not_awaited()
|
manager.process_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_scheduler_config_reload_preserves_active_agent_task(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""配置热重载只替换后续计划,已开始的 AgentTask 仍按真实结果收口。"""
|
||||||
|
task = _add_agent_task("cron", "0 * * * *", "reload-active")
|
||||||
|
scheduler = _build_agent_task_scheduler()
|
||||||
|
scheduler.init_agent_task_jobs()
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def process_message(**_kwargs) -> str:
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
return "执行完成"
|
||||||
|
|
||||||
|
manager = SimpleNamespace(
|
||||||
|
execute_scheduled_task=AgentManager.execute_scheduled_task,
|
||||||
|
process_message=process_message,
|
||||||
|
)
|
||||||
|
manager.execute_scheduled_task = AgentManager.execute_scheduled_task.__get__(manager)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.agent.runtime_loader.get_running_agent_manager",
|
||||||
|
lambda: manager,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scheduler,
|
||||||
|
"init",
|
||||||
|
Mock(side_effect=lambda **_kwargs: setattr(
|
||||||
|
scheduler,
|
||||||
|
"_lifecycle_state",
|
||||||
|
"running",
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
job_id = scheduler._get_agent_task_job_id(task.id)
|
||||||
|
assert scheduler.start(job_id) is True
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
assert AgentTaskOper().get(task.id).last_status == "running"
|
||||||
|
|
||||||
|
await scheduler.on_config_changed()
|
||||||
|
assert AgentTaskOper().get(task.id).last_status == "running"
|
||||||
|
assert scheduler._handles
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
async def wait_until_released() -> None:
|
||||||
|
while scheduler._handles:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||||
|
|
||||||
|
completed = AgentTaskOper().get(task.id)
|
||||||
|
assert completed.last_status == "success"
|
||||||
|
assert completed.last_result == "执行完成"
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_restart_keeps_interrupted_cron_future_schedule() -> None:
|
def test_scheduler_restart_keeps_interrupted_cron_future_schedule() -> None:
|
||||||
"""周期任务中断后只保留下次正常调度,不抹掉本轮中断事实。"""
|
"""周期任务中断后只保留下次正常调度,不抹掉本轮中断事实。"""
|
||||||
task = _add_agent_task("cron", "0 * * * *", "restart-cron")
|
task = _add_agent_task("cron", "0 * * * *", "restart-cron")
|
||||||
@@ -699,6 +767,11 @@ def test_scheduler_starts_registered_agent_task_without_waiting() -> None:
|
|||||||
"running": False,
|
"running": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
scheduler.start = Mock()
|
scheduler.start = Mock()
|
||||||
|
|
||||||
assert scheduler.start_agent_task(7) is True
|
assert scheduler.start_agent_task(7) is True
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""数据库备份与宿主调度器的接入合同。"""
|
"""数据库备份与宿主调度器的接入合同。"""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import threading
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
@@ -27,6 +28,12 @@ def _scheduler() -> Scheduler:
|
|||||||
scheduler = object.__new__(Scheduler)
|
scheduler = object.__new__(Scheduler)
|
||||||
scheduler._scheduler = _SchedulerStub()
|
scheduler._scheduler = _SchedulerStub()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
|
scheduler._lock = threading.RLock()
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"""事件调度订阅快照的并发回归测试。"""
|
"""事件调度订阅快照和生命周期回归测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.runtime.config import global_vars
|
||||||
|
from app.runtime import events as events_module
|
||||||
from app.runtime.events import Event, eventmanager
|
from app.runtime.events import Event, eventmanager
|
||||||
from app.schemas.types import ChainEventType, EventType
|
from app.schemas.types import ChainEventType, EventType
|
||||||
|
|
||||||
@@ -37,6 +42,26 @@ def isolated_eventmanager(monkeypatch):
|
|||||||
"_EventManager__executor",
|
"_EventManager__executor",
|
||||||
_ImmediateExecutor(),
|
_ImmediateExecutor(),
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__event",
|
||||||
|
threading.Event(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__consumer_threads",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__lifecycle_state",
|
||||||
|
"new",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__async_handles",
|
||||||
|
{},
|
||||||
|
)
|
||||||
return eventmanager
|
return eventmanager
|
||||||
|
|
||||||
|
|
||||||
@@ -158,3 +183,149 @@ async def test_async_chain_dispatch_uses_subscription_snapshot(
|
|||||||
calls.clear()
|
calls.clear()
|
||||||
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
|
assert await dispatch(Event(ChainEventType.NameRecognize, {})) is True
|
||||||
assert calls == ["mutating", "late"]
|
assert calls == ["mutating", "late"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_broadcast_handles_are_cancelled_on_shutdown(isolated_eventmanager):
|
||||||
|
"""事件总线关闭时必须取消并收口已投递的异步广播处理器。"""
|
||||||
|
global_vars.set_loop(asyncio.get_running_loop())
|
||||||
|
handler_count = 5
|
||||||
|
active = 0
|
||||||
|
cancelled = 0
|
||||||
|
all_active = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(_event):
|
||||||
|
nonlocal active, cancelled
|
||||||
|
active += 1
|
||||||
|
if active == handler_count:
|
||||||
|
all_active.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
isolated_eventmanager.add_event_listener(EventType.ConfigChanged, handler)
|
||||||
|
isolated_eventmanager.start()
|
||||||
|
consumer_threads = tuple(isolated_eventmanager._EventManager__consumer_threads)
|
||||||
|
for _ in range(handler_count):
|
||||||
|
isolated_eventmanager.send_event(
|
||||||
|
EventType.ConfigChanged,
|
||||||
|
{"key": {"shutdown"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.wait_for(all_active.wait(), timeout=2)
|
||||||
|
assert len(isolated_eventmanager._EventManager__async_handles) == handler_count
|
||||||
|
|
||||||
|
await isolated_eventmanager.stop_async()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert cancelled == handler_count
|
||||||
|
assert isolated_eventmanager._EventManager__async_handles == {}
|
||||||
|
assert not any(
|
||||||
|
thread.is_alive()
|
||||||
|
for thread in consumer_threads
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_broadcast_shutdown_waits_for_handler_cleanup(
|
||||||
|
isolated_eventmanager,
|
||||||
|
) -> None:
|
||||||
|
"""提交代理变为 cancelled 后仍须等待处理器 finally 真正完成。"""
|
||||||
|
global_vars.set_loop(asyncio.get_running_loop())
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelling = asyncio.Event()
|
||||||
|
cleanup_release = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(_event):
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelling.set()
|
||||||
|
await cleanup_release.wait()
|
||||||
|
raise
|
||||||
|
|
||||||
|
isolated_eventmanager.add_event_listener(EventType.ConfigChanged, handler)
|
||||||
|
isolated_eventmanager.start()
|
||||||
|
isolated_eventmanager.send_event(EventType.ConfigChanged, {"key": {"shutdown"}})
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=2)
|
||||||
|
|
||||||
|
stop_task = asyncio.create_task(isolated_eventmanager.stop_async())
|
||||||
|
await asyncio.wait_for(cancelling.wait(), timeout=1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert not stop_task.done()
|
||||||
|
assert isolated_eventmanager._EventManager__lifecycle_state == "stopping"
|
||||||
|
|
||||||
|
stop_task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await stop_task
|
||||||
|
assert isolated_eventmanager._EventManager__async_handles
|
||||||
|
assert isolated_eventmanager._EventManager__lifecycle_state == "stopping"
|
||||||
|
|
||||||
|
cleanup_release.set()
|
||||||
|
|
||||||
|
async def wait_until_released() -> None:
|
||||||
|
while isolated_eventmanager._EventManager__async_handles:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||||
|
await isolated_eventmanager.stop_async()
|
||||||
|
assert isolated_eventmanager._EventManager__async_handles == {}
|
||||||
|
assert isolated_eventmanager._EventManager__lifecycle_state == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_broadcast_submission_is_registered_before_stop_snapshot(
|
||||||
|
isolated_eventmanager,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""事件处理器提交和 owner 登记不得被关闭快照从中切开。"""
|
||||||
|
global_vars.set_loop(asyncio.get_running_loop())
|
||||||
|
submission_entered = threading.Event()
|
||||||
|
submission_release = threading.Event()
|
||||||
|
real_submit = events_module.asyncio.run_coroutine_threadsafe
|
||||||
|
|
||||||
|
def delayed_submit(coroutine, loop):
|
||||||
|
handle = real_submit(coroutine, loop)
|
||||||
|
submission_entered.set()
|
||||||
|
submission_release.wait(timeout=1)
|
||||||
|
return handle
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
events_module.asyncio,
|
||||||
|
"run_coroutine_threadsafe",
|
||||||
|
delayed_submit,
|
||||||
|
)
|
||||||
|
isolated_eventmanager.start()
|
||||||
|
|
||||||
|
async def handler() -> None:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
submit_thread = threading.Thread(
|
||||||
|
target=isolated_eventmanager._EventManager__register_async_handle,
|
||||||
|
args=(handler(),),
|
||||||
|
)
|
||||||
|
submit_thread.start()
|
||||||
|
assert await asyncio.to_thread(submission_entered.wait, 1)
|
||||||
|
|
||||||
|
stop_result = []
|
||||||
|
stop_thread = threading.Thread(
|
||||||
|
target=lambda: stop_result.append(
|
||||||
|
isolated_eventmanager._EventManager__begin_stop()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stop_thread.start()
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert stop_thread.is_alive()
|
||||||
|
|
||||||
|
submission_release.set()
|
||||||
|
await asyncio.to_thread(submit_thread.join, 1)
|
||||||
|
await asyncio.to_thread(stop_thread.join, 1)
|
||||||
|
assert not submit_thread.is_alive()
|
||||||
|
assert not stop_thread.is_alive()
|
||||||
|
assert len(isolated_eventmanager._EventManager__async_handles) == 1
|
||||||
|
|
||||||
|
await isolated_eventmanager.stop_async()
|
||||||
|
assert isolated_eventmanager._EventManager__async_handles == {}
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
|||||||
dependencies = {}
|
dependencies = {}
|
||||||
for name, method_name in (
|
for name, method_name in (
|
||||||
("ModuleManager", "shutdown"),
|
("ModuleManager", "shutdown"),
|
||||||
("EventManager", "stop"),
|
("EventManager", "stop_async"),
|
||||||
("DohHelper", "shutdown"),
|
("DohHelper", "shutdown"),
|
||||||
("ThreadHelper", "shutdown"),
|
("ThreadHelper", "shutdown"),
|
||||||
("RedisHelper", "close"),
|
("RedisHelper", "close"),
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ def _build_scheduler_for_plugin_reload(jobs: dict, backend) -> Scheduler:
|
|||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._jobs = jobs
|
scheduler._jobs = jobs
|
||||||
scheduler._scheduler = backend
|
scheduler._scheduler = backend
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ def test_scheduler_initializer_stop_preserves_sync_abi(monkeypatch):
|
|||||||
|
|
||||||
assert scheduler_initializer.stop_scheduler() is None
|
assert scheduler_initializer.stop_scheduler() is None
|
||||||
scheduler.stop.assert_called_once_with()
|
scheduler.stop.assert_called_once_with()
|
||||||
scheduler.async_stop.assert_not_called()
|
scheduler.stop_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
|
def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
|
||||||
"""生命周期事件循环中的停止入口应返回可等待的异步收口。"""
|
"""生命周期事件循环中的停止入口应返回可等待的异步收口。"""
|
||||||
scheduler = Mock()
|
scheduler = Mock()
|
||||||
scheduler.async_stop = AsyncMock()
|
scheduler.stop_async = AsyncMock()
|
||||||
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
|
monkeypatch.setattr(scheduler_initializer, "Scheduler", Mock(return_value=scheduler))
|
||||||
|
|
||||||
async def scenario():
|
async def scenario():
|
||||||
@@ -69,7 +69,7 @@ def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
|
|||||||
await result
|
await result
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
scheduler.async_stop.assert_awaited_once_with()
|
scheduler.stop_async.assert_awaited_once_with()
|
||||||
scheduler.stop.assert_not_called()
|
scheduler.stop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +117,11 @@ def test_clear_cache_is_manual_only(monkeypatch):
|
|||||||
scheduler._event = threading.Event()
|
scheduler._event = threading.Event()
|
||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
|
scheduler._lifecycle_state = "new"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
scheduler._agent_task_interruptions_reconciled = True
|
scheduler._agent_task_interruptions_reconciled = True
|
||||||
scheduler._auth_count = 0
|
scheduler._auth_count = 0
|
||||||
scheduler._auth_message = False
|
scheduler._auth_message = False
|
||||||
|
|||||||
@@ -0,0 +1,724 @@
|
|||||||
|
"""Scheduler 任务句柄、generation 与 AgentTask reservation 回归。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import threading
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import scheduler as scheduler_module
|
||||||
|
from app.runtime.config import global_vars
|
||||||
|
from app.scheduler import Scheduler
|
||||||
|
|
||||||
|
|
||||||
|
class _ProgressStub:
|
||||||
|
"""隔离 scheduler 生命周期测试的同步进度后端。"""
|
||||||
|
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
"""接收进度键但不连接外部后端。"""
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""记录进度开始。"""
|
||||||
|
|
||||||
|
def update(self, **_kwargs) -> None:
|
||||||
|
"""忽略中间进度。"""
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""返回空的历史进度。"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncProgressStub:
|
||||||
|
"""隔离 scheduler 生命周期测试的异步进度后端。"""
|
||||||
|
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
"""接收进度键但不连接外部后端。"""
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
"""返回空的历史进度。"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update(self, **_kwargs) -> None:
|
||||||
|
"""忽略中间进度。"""
|
||||||
|
|
||||||
|
async def end(self, **_kwargs) -> None:
|
||||||
|
"""记录终态但不访问外部缓存。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler(job_id: str, func) -> Scheduler:
|
||||||
|
"""构造已启动但不拥有 APScheduler 线程的实例。"""
|
||||||
|
scheduler = object.__new__(Scheduler)
|
||||||
|
scheduler._scheduler = None
|
||||||
|
scheduler._event = threading.Event()
|
||||||
|
scheduler._lock = threading.RLock()
|
||||||
|
scheduler._jobs = {
|
||||||
|
job_id: {
|
||||||
|
"name": "生命周期测试",
|
||||||
|
"provider_name": "测试",
|
||||||
|
"func": func,
|
||||||
|
"running": False,
|
||||||
|
"_generation": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {job_id: 1}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stop_async_cancels_and_awaits_scheduler_owned_job(monkeypatch) -> None:
|
||||||
|
"""关闭后已投递协程必须取消并完成收尾,不得遗留 owner 句柄。"""
|
||||||
|
started = asyncio.Event()
|
||||||
|
cleaned = asyncio.Event()
|
||||||
|
|
||||||
|
async def job():
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
cleaned.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||||
|
scheduler = _scheduler("lifecycle-job", job)
|
||||||
|
|
||||||
|
assert scheduler.start("lifecycle-job") is True
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
assert scheduler._handles
|
||||||
|
|
||||||
|
await scheduler.stop_async()
|
||||||
|
|
||||||
|
assert cleaned.is_set()
|
||||||
|
assert scheduler._jobs["lifecycle-job"]["running"] is False
|
||||||
|
assert scheduler._jobs["lifecycle-job"]["last_error"] == "任务已取消"
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
assert scheduler._lifecycle_state == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_foreign_loop_submission_runs_on_main_loop_and_finishes_before_stop(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""自建事件循环提交的任务仍由应用主循环拥有并完成取消收尾。"""
|
||||||
|
main_loop = asyncio.get_running_loop()
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelling = asyncio.Event()
|
||||||
|
cleanup_release = asyncio.Event()
|
||||||
|
execution_loop = None
|
||||||
|
|
||||||
|
async def job() -> None:
|
||||||
|
nonlocal execution_loop
|
||||||
|
execution_loop = asyncio.get_running_loop()
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelling.set()
|
||||||
|
await cleanup_release.wait()
|
||||||
|
raise
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||||
|
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||||
|
scheduler = _scheduler("foreign-loop-job", job)
|
||||||
|
|
||||||
|
def submit_from_foreign_loop() -> bool:
|
||||||
|
async def submit() -> bool:
|
||||||
|
return scheduler.start("foreign-loop-job")
|
||||||
|
|
||||||
|
return asyncio.run(submit())
|
||||||
|
|
||||||
|
assert await asyncio.to_thread(submit_from_foreign_loop) is True
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
assert execution_loop is main_loop
|
||||||
|
|
||||||
|
stop_task = asyncio.create_task(scheduler.stop_async())
|
||||||
|
await asyncio.wait_for(cancelling.wait(), timeout=1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert not stop_task.done()
|
||||||
|
assert scheduler._lifecycle_state == "stopping"
|
||||||
|
|
||||||
|
stop_task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await stop_task
|
||||||
|
assert scheduler._handles
|
||||||
|
assert scheduler._lifecycle_state == "stopping"
|
||||||
|
|
||||||
|
cleanup_release.set()
|
||||||
|
|
||||||
|
async def wait_until_released() -> None:
|
||||||
|
while scheduler._handles:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||||
|
await scheduler.stop_async()
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
assert scheduler._lifecycle_state == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_thread_submission_is_registered_before_stop_snapshot(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""跨线程提交与 owner 登记必须对关闭快照表现为同一原子操作。"""
|
||||||
|
main_loop = asyncio.get_running_loop()
|
||||||
|
registration_entered = threading.Event()
|
||||||
|
registration_release = threading.Event()
|
||||||
|
|
||||||
|
async def job() -> None:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||||
|
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||||
|
scheduler = _scheduler("atomic-submit", job)
|
||||||
|
register_handle = scheduler._register_handle
|
||||||
|
|
||||||
|
def delayed_register(**kwargs) -> bool:
|
||||||
|
registration_entered.set()
|
||||||
|
registration_release.wait(timeout=1)
|
||||||
|
return register_handle(**kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "_register_handle", delayed_register)
|
||||||
|
submit_thread = threading.Thread(target=scheduler.start, args=("atomic-submit",))
|
||||||
|
submit_thread.start()
|
||||||
|
assert await asyncio.to_thread(registration_entered.wait, 1)
|
||||||
|
|
||||||
|
stop_result = []
|
||||||
|
stop_thread = threading.Thread(target=lambda: stop_result.append(scheduler._begin_stop()))
|
||||||
|
stop_thread.start()
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert stop_thread.is_alive()
|
||||||
|
|
||||||
|
registration_release.set()
|
||||||
|
await asyncio.to_thread(submit_thread.join, 1)
|
||||||
|
await asyncio.to_thread(stop_thread.join, 1)
|
||||||
|
assert not submit_thread.is_alive()
|
||||||
|
assert not stop_thread.is_alive()
|
||||||
|
assert len(stop_result[0][1]) == 1
|
||||||
|
|
||||||
|
for handle in stop_result[0][1]:
|
||||||
|
scheduler._cancel_handle(handle)
|
||||||
|
await scheduler._await_cancelled_handles(stop_result[0][1])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_submit_to_loop_tracks_internal_progress_or_finish_tasks() -> None:
|
||||||
|
"""进度和收尾协程也必须归 Scheduler 所有并可在关闭时收口。"""
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelled = asyncio.Event()
|
||||||
|
|
||||||
|
async def pending() -> None:
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
scheduler = _scheduler("internal-task", lambda: None)
|
||||||
|
scheduler._submit_to_loop(
|
||||||
|
pending(),
|
||||||
|
job_id="internal-task",
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
assert len(scheduler._handles) == 1
|
||||||
|
|
||||||
|
await scheduler.stop_async()
|
||||||
|
|
||||||
|
assert cancelled.is_set()
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> None:
|
||||||
|
"""同步任务回投的进度与收尾句柄都必须纳入关闭收口。"""
|
||||||
|
update_started = asyncio.Event()
|
||||||
|
finish_started = asyncio.Event()
|
||||||
|
gate = asyncio.Event()
|
||||||
|
cancelled = 0
|
||||||
|
|
||||||
|
class BlockingProgress:
|
||||||
|
"""让进度和收尾停在异步后端,便于验证 owner registry。"""
|
||||||
|
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def update(self, **_kwargs) -> None:
|
||||||
|
nonlocal cancelled
|
||||||
|
update_started.set()
|
||||||
|
try:
|
||||||
|
await gate.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
nonlocal cancelled
|
||||||
|
finish_started.set()
|
||||||
|
try:
|
||||||
|
await gate.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled += 1
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def end(self, **_kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
|
||||||
|
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", asyncio.get_running_loop())
|
||||||
|
|
||||||
|
def job(progress_callback) -> None:
|
||||||
|
progress_callback(value=50)
|
||||||
|
|
||||||
|
scheduler = _scheduler("callback-handles", job)
|
||||||
|
await asyncio.to_thread(scheduler.start, "callback-handles")
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(update_started.wait(), finish_started.wait()),
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(scheduler._handles) == 2
|
||||||
|
|
||||||
|
await scheduler.stop_async()
|
||||||
|
|
||||||
|
assert cancelled == 2
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
|
||||||
|
"""旧 generation 的延迟进度不得写入新注册的同 ID 任务。"""
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class RecordingProgress:
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def update(self, **kwargs) -> None:
|
||||||
|
updates.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", RecordingProgress)
|
||||||
|
scheduler = _scheduler("generation-progress", lambda: None)
|
||||||
|
old_job = scheduler._jobs["generation-progress"]
|
||||||
|
callback = scheduler._Scheduler__build_progress_callback(
|
||||||
|
"generation-progress",
|
||||||
|
old_job,
|
||||||
|
)
|
||||||
|
scheduler._jobs["generation-progress"] = {
|
||||||
|
"name": "新一代",
|
||||||
|
"provider_name": "测试",
|
||||||
|
"running": True,
|
||||||
|
"_generation": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(value=42, text="旧进度")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatch) -> None:
|
||||||
|
"""同 ID 新 generation 显示真实运行态,但不继承旧任务进度详情。"""
|
||||||
|
detail = {}
|
||||||
|
|
||||||
|
class RecordingProgress:
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, **kwargs) -> None:
|
||||||
|
detail.update(kwargs)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return detail
|
||||||
|
|
||||||
|
class RecordingAsyncProgress:
|
||||||
|
def __init__(self, _key: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(self):
|
||||||
|
return detail
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", RecordingProgress)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
scheduler_module,
|
||||||
|
"AsyncProgressHelper",
|
||||||
|
RecordingAsyncProgress,
|
||||||
|
)
|
||||||
|
scheduler = _scheduler("generation-cache", lambda: None)
|
||||||
|
old_job = scheduler._Scheduler__prepare_job("generation-cache")
|
||||||
|
assert old_job is not None
|
||||||
|
assert detail["data"]["_generation"] == 1
|
||||||
|
|
||||||
|
scheduler._jobs["generation-cache"] = {
|
||||||
|
"name": "新一代",
|
||||||
|
"provider_name": "测试",
|
||||||
|
"running": False,
|
||||||
|
"_generation": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
progress = scheduler.get_progress("generation-cache")
|
||||||
|
assert progress is not None
|
||||||
|
assert progress.status == "running"
|
||||||
|
assert progress.enable is True
|
||||||
|
assert progress.value == 0
|
||||||
|
assert "_generation" not in progress.data
|
||||||
|
async_progress = await scheduler.aget_progress("generation-cache")
|
||||||
|
assert async_progress is not None
|
||||||
|
assert async_progress.status == "running"
|
||||||
|
assert async_progress.enable is True
|
||||||
|
assert async_progress.value == 0
|
||||||
|
assert "_generation" not in async_progress.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stale_generation_cannot_finish_replaced_job(monkeypatch) -> None:
|
||||||
|
"""旧 generation 收尾不得改写同 ID 的新任务状态或进度。"""
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||||
|
scheduler = _scheduler("generation-job", lambda: None)
|
||||||
|
old_job = scheduler._jobs["generation-job"]
|
||||||
|
old_job["running"] = True
|
||||||
|
new_job = {
|
||||||
|
"name": "新一代",
|
||||||
|
"provider_name": "测试",
|
||||||
|
"running": True,
|
||||||
|
"_generation": 2,
|
||||||
|
}
|
||||||
|
scheduler._jobs["generation-job"] = new_job
|
||||||
|
|
||||||
|
await scheduler._Scheduler__finish_job(
|
||||||
|
job_id="generation-job",
|
||||||
|
job=old_job,
|
||||||
|
generation=1,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert new_job["running"] is True
|
||||||
|
assert "last_finished_at" not in new_job
|
||||||
|
assert old_job["running"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_task_manual_start_has_single_reservation() -> None:
|
||||||
|
"""并发手动触发同一 AgentTask 时只能有一个调用获得 reservation。"""
|
||||||
|
scheduler = _scheduler("agent-task-1", lambda: None)
|
||||||
|
scheduler._jobs["agent-task-1"].update(
|
||||||
|
name="AgentTask",
|
||||||
|
owner="agent",
|
||||||
|
)
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def start(*_args, **_kwargs):
|
||||||
|
entered.set()
|
||||||
|
release.wait(timeout=1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
scheduler.start = start
|
||||||
|
|
||||||
|
first = threading.Thread(
|
||||||
|
target=lambda: results.append(scheduler.start_agent_task(1)),
|
||||||
|
)
|
||||||
|
first.start()
|
||||||
|
assert entered.wait(timeout=1)
|
||||||
|
second = scheduler.start_agent_task(1)
|
||||||
|
release.set()
|
||||||
|
first.join(timeout=1)
|
||||||
|
|
||||||
|
assert second is False
|
||||||
|
assert results == [True]
|
||||||
|
assert scheduler._agent_task_reservations == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_rejects_new_submission_after_stop() -> None:
|
||||||
|
"""进入 stopping/stopped 后不得再从旧 scheduler 提交任务。"""
|
||||||
|
scheduler = _scheduler("stopped-job", lambda: None)
|
||||||
|
scheduler._lifecycle_state = "stopping"
|
||||||
|
|
||||||
|
assert scheduler.start("stopped-job") is False
|
||||||
|
assert scheduler._jobs["stopped-job"]["running"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_config_reload_does_not_restart_scheduler_during_shutdown() -> None:
|
||||||
|
"""系统关闭开始后到达的配置事件不得重新打开调度入口。"""
|
||||||
|
scheduler = _scheduler("shutdown-reload", lambda: None)
|
||||||
|
scheduler._lifecycle_state = "stopping"
|
||||||
|
scheduler.init = lambda **_kwargs: pytest.fail("关闭阶段不得重新初始化调度器")
|
||||||
|
|
||||||
|
await scheduler.on_config_changed()
|
||||||
|
|
||||||
|
assert scheduler._lifecycle_state == "stopping"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_concurrent_config_reload_waits_for_old_scheduler_shutdown(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""并发配置事件合并为一次重建,旧调度线程池结束前不得启动新实例。"""
|
||||||
|
shutdown_started = threading.Event()
|
||||||
|
shutdown_release = threading.Event()
|
||||||
|
|
||||||
|
class BlockingScheduler:
|
||||||
|
running = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_all_jobs() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def shutdown() -> None:
|
||||||
|
shutdown_started.set()
|
||||||
|
shutdown_release.wait(timeout=1)
|
||||||
|
|
||||||
|
scheduler = _scheduler("reload-once", lambda: None)
|
||||||
|
scheduler._scheduler = BlockingScheduler()
|
||||||
|
init_calls = 0
|
||||||
|
|
||||||
|
def init(**_kwargs) -> None:
|
||||||
|
nonlocal init_calls
|
||||||
|
init_calls += 1
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "init", init)
|
||||||
|
first = asyncio.create_task(scheduler.on_config_changed())
|
||||||
|
assert await asyncio.to_thread(shutdown_started.wait, 1)
|
||||||
|
|
||||||
|
await scheduler.on_config_changed()
|
||||||
|
assert init_calls == 0
|
||||||
|
assert scheduler._lifecycle_state == "reloading"
|
||||||
|
|
||||||
|
shutdown_release.set()
|
||||||
|
await asyncio.wait_for(first, timeout=1)
|
||||||
|
assert init_calls == 1
|
||||||
|
assert scheduler._lifecycle_state == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_config_reload_preserves_overlap_guard_across_job_generations(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""热重载替换任务定义后,同 ID 旧任务结束前不得启动新 generation。"""
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
finished = asyncio.Event()
|
||||||
|
run_count = 0
|
||||||
|
|
||||||
|
async def job() -> None:
|
||||||
|
nonlocal run_count
|
||||||
|
run_count += 1
|
||||||
|
if run_count == 1:
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
|
||||||
|
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
|
||||||
|
scheduler = _scheduler("reload-overlap", job)
|
||||||
|
|
||||||
|
class ActiveScheduler:
|
||||||
|
"""提供列表接口所需的最小 APScheduler 状态。"""
|
||||||
|
|
||||||
|
running = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_jobs() -> list:
|
||||||
|
"""当前用例只关注正在运行任务,不提供后续计划。"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def init(**_kwargs) -> None:
|
||||||
|
replacement = {
|
||||||
|
"name": "生命周期测试",
|
||||||
|
"provider_name": "测试",
|
||||||
|
"func": job,
|
||||||
|
"running": False,
|
||||||
|
}
|
||||||
|
scheduler._assign_job_generation("reload-overlap", replacement)
|
||||||
|
scheduler._jobs = {"reload-overlap": replacement}
|
||||||
|
scheduler._scheduler = ActiveScheduler()
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "init", init)
|
||||||
|
|
||||||
|
assert scheduler.start("reload-overlap") is True
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
await scheduler.on_config_changed()
|
||||||
|
|
||||||
|
progress = scheduler.get_progress("reload-overlap")
|
||||||
|
assert progress is not None
|
||||||
|
assert progress.status == "running"
|
||||||
|
assert progress.enable is True
|
||||||
|
listed = scheduler.list()
|
||||||
|
assert len(listed) == 1
|
||||||
|
assert listed[0].id == "reload-overlap"
|
||||||
|
assert listed[0].status == "正在运行"
|
||||||
|
assert scheduler.start("reload-overlap") is False
|
||||||
|
assert run_count == 1
|
||||||
|
assert len(scheduler._handles) == 1
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
await asyncio.wait_for(finished.wait(), timeout=1)
|
||||||
|
|
||||||
|
async def wait_until_released() -> None:
|
||||||
|
while scheduler._active_job_generations or scheduler._handles:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.wait_for(wait_until_released(), timeout=1)
|
||||||
|
assert scheduler.start("reload-overlap") is True
|
||||||
|
|
||||||
|
async def wait_until_second_run_finishes() -> None:
|
||||||
|
while scheduler._active_job_generations or scheduler._handles:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.wait_for(wait_until_second_run_finishes(), timeout=1)
|
||||||
|
assert run_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_between_prepare_and_submission_releases_active_generation(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""关闭插入准备与提交之间时,不得遗留未实际运行的 generation。"""
|
||||||
|
calls = 0
|
||||||
|
scheduler = _scheduler("stop-race", None)
|
||||||
|
|
||||||
|
async def job() -> None:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
|
||||||
|
scheduler._jobs["stop-race"]["func"] = job
|
||||||
|
original_prepare = scheduler._Scheduler__prepare_job
|
||||||
|
|
||||||
|
def prepare_then_stop(job_id: str):
|
||||||
|
prepared = original_prepare(job_id)
|
||||||
|
scheduler._begin_stop()
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_stop)
|
||||||
|
|
||||||
|
assert scheduler.start("stop-race") is False
|
||||||
|
assert calls == 0
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
assert scheduler._active_job_generations == {}
|
||||||
|
assert scheduler._jobs["stop-race"]["running"] is False
|
||||||
|
assert scheduler._jobs["stop-race"]["last_error"] == "任务未提交"
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", original_prepare)
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
assert scheduler.start("stop-race") is True
|
||||||
|
assert calls == 1
|
||||||
|
assert scheduler._active_job_generations == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""跨线程提交被关闭门禁拒绝时,包装与业务协程都必须释放。"""
|
||||||
|
main_loop = asyncio.get_running_loop()
|
||||||
|
prepared = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = 0
|
||||||
|
scheduler = _scheduler("cross-thread-stop-race", None)
|
||||||
|
|
||||||
|
async def job() -> None:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
|
||||||
|
scheduler._jobs["cross-thread-stop-race"]["func"] = job
|
||||||
|
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
|
||||||
|
original_prepare = scheduler._Scheduler__prepare_job
|
||||||
|
|
||||||
|
def prepare_then_wait(job_id: str):
|
||||||
|
result = original_prepare(job_id)
|
||||||
|
prepared.set()
|
||||||
|
release.wait(timeout=1)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_wait)
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as captured:
|
||||||
|
warnings.simplefilter("always", RuntimeWarning)
|
||||||
|
start_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(scheduler.start, "cross-thread-stop-race")
|
||||||
|
)
|
||||||
|
assert await asyncio.to_thread(prepared.wait, 1)
|
||||||
|
scheduler._begin_stop()
|
||||||
|
release.set()
|
||||||
|
assert await asyncio.wait_for(start_task, timeout=1) is False
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
assert calls == 0
|
||||||
|
assert scheduler._active_job_generations == {}
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
assert not any("was never awaited" in str(item.message) for item in captured)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_cross_thread_proxy_waits_for_target_loop_cleanup(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""跨线程代理提前取消后,真实完成信号必须等待目标循环清理。"""
|
||||||
|
target_loop = asyncio.new_event_loop()
|
||||||
|
loop_blocked = threading.Event()
|
||||||
|
loop_release = threading.Event()
|
||||||
|
loop_drained = threading.Event()
|
||||||
|
loop_errors = []
|
||||||
|
loop_thread = threading.Thread(target=target_loop.run_forever)
|
||||||
|
loop_thread.start()
|
||||||
|
|
||||||
|
def block_target_loop() -> None:
|
||||||
|
loop_blocked.set()
|
||||||
|
loop_release.wait(timeout=1)
|
||||||
|
|
||||||
|
target_loop.set_exception_handler(
|
||||||
|
lambda _loop, context: loop_errors.append(context)
|
||||||
|
)
|
||||||
|
target_loop.call_soon_threadsafe(block_target_loop)
|
||||||
|
assert loop_blocked.wait(timeout=1)
|
||||||
|
|
||||||
|
scheduler = _scheduler("cancel-before-start", None)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def business() -> None:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
|
||||||
|
scheduler._jobs["cancel-before-start"]["func"] = business
|
||||||
|
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", target_loop)
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as captured:
|
||||||
|
warnings.simplefilter("always", RuntimeWarning)
|
||||||
|
try:
|
||||||
|
assert scheduler.start("cancel-before-start") is True
|
||||||
|
scheduler_handle = next(iter(scheduler._handles.values()))
|
||||||
|
scheduler._cancel_handle(scheduler_handle)
|
||||||
|
assert not scheduler_handle.completion.done()
|
||||||
|
|
||||||
|
loop_release.set()
|
||||||
|
target_loop.call_soon_threadsafe(loop_drained.set)
|
||||||
|
assert loop_drained.wait(timeout=1)
|
||||||
|
assert scheduler_handle.completion.done()
|
||||||
|
gc.collect()
|
||||||
|
finally:
|
||||||
|
target_loop.call_soon_threadsafe(target_loop.stop)
|
||||||
|
loop_thread.join(timeout=1)
|
||||||
|
target_loop.close()
|
||||||
|
|
||||||
|
assert calls == 0
|
||||||
|
assert loop_errors == []
|
||||||
|
assert scheduler._active_job_generations == {}
|
||||||
|
assert scheduler._handles == {}
|
||||||
|
assert not any("was never awaited" in str(item.message) for item in captured)
|
||||||
@@ -14,8 +14,6 @@ def _build_scheduler(job_id, func):
|
|||||||
scheduler._scheduler = None
|
scheduler._scheduler = None
|
||||||
scheduler._event = threading.Event()
|
scheduler._event = threading.Event()
|
||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._async_tasks = set()
|
|
||||||
scheduler._accepting_async_tasks = True
|
|
||||||
scheduler._jobs = {
|
scheduler._jobs = {
|
||||||
job_id: {
|
job_id: {
|
||||||
"name": "测试定时服务",
|
"name": "测试定时服务",
|
||||||
@@ -24,6 +22,11 @@ def _build_scheduler(job_id, func):
|
|||||||
"running": False,
|
"running": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ def test_scheduler_records_cancelled_async_job_as_failed():
|
|||||||
async def run_task():
|
async def run_task():
|
||||||
job = scheduler._Scheduler__prepare_job(job_id)
|
job = scheduler._Scheduler__prepare_job(job_id)
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await scheduler._Scheduler__run_coro_job(task(), job_id, job)
|
await scheduler._Scheduler__run_coro_job(task, job_id, job)
|
||||||
|
|
||||||
scheduler = _build_scheduler(job_id, task)
|
scheduler = _build_scheduler(job_id, task)
|
||||||
asyncio.run(run_task())
|
asyncio.run(run_task())
|
||||||
@@ -173,7 +176,7 @@ def test_scheduler_records_cancelled_async_job_as_failed():
|
|||||||
assert progress.error == "任务已取消"
|
assert progress.error == "任务已取消"
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_async_stop_cancels_owned_async_jobs():
|
def test_scheduler_stop_async_cancels_owned_async_jobs():
|
||||||
"""Scheduler 关停应取消并等待自身登记的异步作业。"""
|
"""Scheduler 关停应取消并等待自身登记的异步作业。"""
|
||||||
job_id = f"test-owned-task-{uuid4()}"
|
job_id = f"test-owned-task-{uuid4()}"
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
@@ -194,13 +197,13 @@ def test_scheduler_async_stop_cancels_owned_async_jobs():
|
|||||||
"""在当前事件循环启动并收口异步作业。"""
|
"""在当前事件循环启动并收口异步作业。"""
|
||||||
scheduler.start(job_id)
|
scheduler.start(job_id)
|
||||||
await started.wait()
|
await started.wait()
|
||||||
assert len(scheduler._async_tasks) == 1
|
assert len(scheduler._handles) == 1
|
||||||
await scheduler.async_stop(timeout_seconds=1)
|
await scheduler.stop_async()
|
||||||
|
|
||||||
asyncio.run(run_task())
|
asyncio.run(run_task())
|
||||||
|
|
||||||
assert cancelled.is_set()
|
assert cancelled.is_set()
|
||||||
assert scheduler._async_tasks == set()
|
assert scheduler._handles == {}
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_returns_none_for_unknown_job():
|
def test_scheduler_returns_none_for_unknown_job():
|
||||||
@@ -209,5 +212,10 @@ def test_scheduler_returns_none_for_unknown_job():
|
|||||||
scheduler = object.__new__(Scheduler)
|
scheduler = object.__new__(Scheduler)
|
||||||
scheduler._lock = threading.RLock()
|
scheduler._lock = threading.RLock()
|
||||||
scheduler._jobs = {}
|
scheduler._jobs = {}
|
||||||
|
scheduler._lifecycle_state = "running"
|
||||||
|
scheduler._handles = {}
|
||||||
|
scheduler._job_generations = {}
|
||||||
|
scheduler._active_job_generations = {}
|
||||||
|
scheduler._agent_task_reservations = {}
|
||||||
|
|
||||||
assert scheduler.get_progress(job_id) is None
|
assert scheduler.get_progress(job_id) is None
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.endpoints import site as site_endpoint
|
||||||
|
from app.application.site.mutation import SiteMutationResult
|
||||||
|
from app.runtime.tasks import TaskRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskRegistry(TaskRegistry):
|
||||||
|
"""记录站点重置提交的同步后台任务。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""初始化任务记录。"""
|
||||||
|
super().__init__()
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
def create_sync(self, function, *args, owner: str, **kwargs) -> None:
|
||||||
|
"""保存函数、参数和 owner,避免端点测试启动真实任务。"""
|
||||||
|
self.calls.append((function, args, kwargs, owner))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_submits_cookiecloud_after_site_transaction(monkeypatch):
|
||||||
|
"""站点重置提交 CookieCloud 后台任务,不在请求事件循环内直接执行。"""
|
||||||
|
command = Mock()
|
||||||
|
command.reset = AsyncMock(return_value=SiteMutationResult(success=True))
|
||||||
|
task_registry = _TaskRegistry()
|
||||||
|
scheduler = Mock()
|
||||||
|
system_config = Mock()
|
||||||
|
system_config.async_set = AsyncMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(site_endpoint, "Scheduler", Mock(return_value=scheduler))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
site_endpoint,
|
||||||
|
"get_configured_system_config",
|
||||||
|
Mock(return_value=system_config),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await site_endpoint.reset(
|
||||||
|
task_registry=task_registry,
|
||||||
|
command=command,
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
command.reset.assert_awaited_once_with()
|
||||||
|
scheduler.start.assert_not_called()
|
||||||
|
system_config.async_set.assert_any_await(
|
||||||
|
site_endpoint.SystemConfigKey.IndexerSites, []
|
||||||
|
)
|
||||||
|
system_config.async_set.assert_any_await(site_endpoint.SystemConfigKey.RssSites, [])
|
||||||
|
assert task_registry.calls == [
|
||||||
|
(
|
||||||
|
scheduler.start,
|
||||||
|
(),
|
||||||
|
{"job_id": "cookiecloud", "manual": True},
|
||||||
|
"api.site.reset",
|
||||||
|
)
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user