fix: 修复订阅筛选清空与任务进度竞态

This commit is contained in:
jxxghp
2026-08-27 12:02:04 +08:00
parent cd4e79ef57
commit 49d30e5fdf
5 changed files with 155 additions and 9 deletions
+29
View File
@@ -71,6 +71,7 @@ class _SchedulerHandle:
loop: asyncio.AbstractEventLoop
handle: asyncio.Future[Any] | concurrent.futures.Future[Any]
completion: asyncio.Future[Any] | concurrent.futures.Future[Any]
kind: str
# Agent 自主定时任务前缀下沉到 application 门面,此处保留兼容导出。
@@ -250,6 +251,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
loop: asyncio.AbstractEventLoop,
handle: asyncio.Future[Any] | concurrent.futures.Future[Any],
completion: asyncio.Future[Any] | concurrent.futures.Future[Any] | None = None,
kind: str = "job",
) -> bool:
"""登记调度器拥有的句柄;关闭竞态下拒绝并取消新句柄。"""
if completion is None:
@@ -269,6 +271,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
loop=loop,
handle=handle,
completion=completion,
kind=kind,
)
completion.add_done_callback(self._remove_handle)
return True
@@ -321,6 +324,23 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
return_exceptions=True,
)
async def _await_progress_handles(self, job_id: str, generation: int) -> None:
"""等待同一轮任务已提交的进度更新,保证最终状态最后写入缓存。"""
with self._lock:
handles = tuple(
handle
for handle in self._handles.values()
if handle.job_id == job_id
and handle.generation == generation
and handle.kind == "progress"
)
if not handles:
return
await asyncio.gather(
*(self._wait_handle(handle) for handle in handles),
return_exceptions=True,
)
@staticmethod
def _track_cross_thread_completion(
coro: Any,
@@ -352,6 +372,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job_id: str,
generation: int,
on_unstarted_cancel: Optional[Callable[[], None]] = None,
kind: str = "job",
) -> bool:
"""向主循环提交协程,并以独立完成信号跟踪真实收尾。"""
completion: concurrent.futures.Future[Any] = concurrent.futures.Future()
@@ -415,6 +436,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
loop=target_loop,
handle=handle,
completion=completion,
kind=kind,
)
handle.add_done_callback(cancel_target_task)
return registered
@@ -921,6 +943,9 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
完成定时任务
"""
# 业务函数返回前提交的进度回调可能仍在等待 Redis I/O;先收敛它们,
# 避免迟到的 running 快照覆盖 success/failed 终态。
await self._await_progress_handles(job_id, generation)
finished_at = self._format_time()
with self._lock:
current_job = self._jobs.get(job_id)
@@ -1126,6 +1151,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
_update(),
job_id=job_id,
generation=job.get("_generation", 0),
kind="progress",
)
return update_progress
@@ -1360,6 +1386,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job_id: str,
generation: int = 0,
on_unstarted_cancel: Optional[Callable[[], None]] = None,
kind: str = "job",
) -> bool:
"""
把协程提交到事件循环执行,兼容以下调用环境:
@@ -1390,6 +1417,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
generation=generation,
loop=running_loop,
handle=handle,
kind=kind,
)
if on_unstarted_cancel:
handle.add_done_callback(
@@ -1407,6 +1435,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job_id=job_id,
generation=generation,
on_unstarted_cancel=on_unstarted_cancel,
kind=kind,
)
elif self._lifecycle_state in {"stopping", "stopped"}:
coro.close()
+9 -3
View File
@@ -48,6 +48,12 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int]
class Subscribe(OptionalMediaIdentityMixin, BaseModel):
"""订阅输入与响应模型,媒体身份必须为空对或完整有效对。"""
# 表单用空字符串表达“全部”时必须保留显式清空语义,更新接口才能覆盖存量规则。
CLEARABLE_FILTER_FIELDS: ClassVar[frozenset[str]] = frozenset({
"filter", "include", "exclude", "quality", "resolution", "effect",
"audio_quality", "audio_format",
})
# 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。
PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({
"id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode",
@@ -186,14 +192,14 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
音乐等媒体类型的 season、total_episode、episode_priority 等数值或容器字段
在表单中常以空字符串提交,而 Pydantic 不会把空字符串自动转为 None,会直接抛出
校验异常导致接口返回 422。这里把空字符串键移除,等价于该字段未提供,从而复用字段
默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。媒体身份键保留为
None,以便更新接口区分“未提交”与“显式清空完整身份对”。
默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。媒体身份键以及可清空
的筛选字段保留为 None,以便更新接口区分“未提交”与“显式清空”。
"""
if isinstance(data, dict):
data = dict(data)
for key, value in list(data.items()):
if isinstance(value, str) and value == "":
if key in {"media_source", "media_id"}:
if key in {"media_source", "media_id"} or key in cls.CLEARABLE_FILTER_FIELDS:
data[key] = None
else:
data.pop(key)