|
19 | 19 | from dataclasses import dataclass |
20 | 20 | from pathlib import Path |
21 | 21 | from time import monotonic |
22 | | -from typing import Callable, Dict, List, Optional, Tuple, Union |
| 22 | +from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union |
23 | 23 |
|
24 | | -from pydantic import BaseModel, ConfigDict |
| 24 | +from pydantic import BaseModel, ConfigDict, PrivateAttr |
25 | 25 |
|
26 | | -from app.schemas.transfer import MetaInfo as _SchemaMetaInfo |
27 | | -from app.schemas.transfer import MusicInfo as _SchemaMusicInfo |
28 | | -from app.schemas.transfer import MusicMeta as _SchemaMusicMeta |
29 | | -from app.schemas.workflow import MediaInfo as _SchemaMediaInfo |
30 | 26 | from app.adapters.system.host import SystemUtils |
31 | 27 | from app.application.agent import get_prompt_manager, get_running_agent_manager |
32 | 28 | from app.domain.context import MediaInfo, MusicInfo |
|
40 | 36 | from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity |
41 | 37 | from app.schemas.system import TransferDirectoryConf |
42 | 38 | from app.schemas.tmdb import TmdbEpisode |
| 39 | +from app.schemas.transfer import MetaInfo as _SchemaMetaInfo |
| 40 | +from app.schemas.transfer import MusicInfo as _SchemaMusicInfo |
| 41 | +from app.schemas.transfer import MusicMeta as _SchemaMusicMeta |
43 | 42 | from app.schemas.transfer import TransferInfo, TransferJob, TransferJobTask |
44 | 43 | from app.schemas.types import ( |
45 | 44 | MUSIC_ENTITY_ALBUM, |
|
48 | 47 | MediaType, |
49 | 48 | ReplyMode, |
50 | 49 | ) |
51 | | - |
| 50 | +from app.schemas.workflow import MediaInfo as _SchemaMediaInfo |
52 | 51 |
|
53 | 52 |
|
54 | 53 | class TransferTask(OptionalMediaIdentityMixin, BaseModel): |
@@ -81,6 +80,16 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel): |
81 | 80 | manual: Optional[bool] = False |
82 | 81 | background: Optional[bool] = True |
83 | 82 | preview: Optional[bool] = False |
| 83 | + _admission_task_id: Optional[str] = PrivateAttr(default=None) |
| 84 | + |
| 85 | + @property |
| 86 | + def admission_task_id(self) -> Optional[str]: |
| 87 | + """返回仅供宿主持久准入和终态结算使用的内部任务标识。""" |
| 88 | + return self._admission_task_id |
| 89 | + |
| 90 | + def bind_admission_task_id(self, task_id: str) -> None: |
| 91 | + """绑定持久准入生成的稳定身份,不改变插件可见序列化字段。""" |
| 92 | + self._admission_task_id = task_id |
84 | 93 |
|
85 | 94 | def to_dict(self): |
86 | 95 | """ |
@@ -113,36 +122,86 @@ class TransferQueue(BaseModel): |
113 | 122 | result: Optional[TransferInfo] = None |
114 | 123 |
|
115 | 124 |
|
| 125 | +TRANSFER_ADMISSION_ACCEPTED = "accepted" |
| 126 | + |
| 127 | + |
| 128 | +@dataclass(frozen=True, slots=True) |
| 129 | +class TransferAdmission: |
| 130 | + """描述已经持久化、可在进程退出后恢复的整理任务准入事实。""" |
| 131 | + |
| 132 | + task_id: str |
| 133 | + storage: str |
| 134 | + src_path: str |
| 135 | + state: str |
| 136 | + created_at: str |
| 137 | + updated_at: str |
| 138 | + last_error: Optional[str] = None |
| 139 | + |
| 140 | + |
| 141 | +class TransferAdmissionRepository(Protocol): |
| 142 | + """整理任务 durable admission 所需的类型化持久化端口。""" |
| 143 | + |
| 144 | + def admit(self, *, storage: str, src_path: str) -> TransferAdmission: |
| 145 | + """幂等登记源文件并返回稳定任务身份。""" |
| 146 | + ... |
| 147 | + |
| 148 | + def list_accepted(self, limit: int = 5000) -> list[TransferAdmission]: |
| 149 | + """按登记顺序返回等待恢复或执行的任务。""" |
| 150 | + ... |
| 151 | + |
| 152 | + def record_enqueue_failure(self, *, task_id: str, error: str) -> None: |
| 153 | + """记录内存队列接收失败,保留任务供后续恢复。""" |
| 154 | + ... |
| 155 | + |
| 156 | + def discard_task(self, *, task_id: str) -> int: |
| 157 | + """按稳定任务身份删除已经到达终态的登记。""" |
| 158 | + ... |
| 159 | + |
| 160 | + |
116 | 161 | class TransferQueueService: |
117 | 162 | """协调整理任务登记、入队、移除和队列视图查询。""" |
118 | 163 |
|
119 | 164 | def __init__( |
120 | 165 | self, |
121 | 166 | *, |
122 | 167 | register_task: Callable[[TransferTask], bool], |
| 168 | + admit_task: Callable[[TransferTask], TransferAdmission], |
123 | 169 | enqueue: Callable[[TransferQueue], None], |
124 | 170 | before_enqueue: Callable[[TransferTask], None], |
125 | | - after_enqueue: Callable[[TransferTask], None], |
| 171 | + enqueue_failed: Callable[[TransferTask, Exception], None], |
126 | 172 | remove_task: Callable[[FileItem], None], |
127 | 173 | list_tasks: Callable[[], List[TransferJob]], |
128 | 174 | expire_tasks: Callable[[], None], |
129 | 175 | ) -> None: |
130 | 176 | """保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。""" |
131 | 177 | self._register_task = register_task |
| 178 | + self._admit_task = admit_task |
132 | 179 | self._enqueue = enqueue |
133 | 180 | self._before_enqueue = before_enqueue |
134 | | - self._after_enqueue = after_enqueue |
| 181 | + self._enqueue_failed = enqueue_failed |
135 | 182 | self._remove_task = remove_task |
136 | 183 | self._list_tasks = list_tasks |
137 | 184 | self._expire_tasks = expire_tasks |
138 | 185 |
|
139 | 186 | def put(self, task: TransferTask, callback: Callable) -> bool: |
140 | | - """登记并入队一个整理任务,保持原有副作用顺序。""" |
| 187 | + """先持久化准入事实再入队;任何前置失败都撤销内存作业视图。""" |
141 | 188 | if not task or not self._register_task(task): |
142 | 189 | return False |
143 | | - self._before_enqueue(task) |
144 | | - self._enqueue(TransferQueue(task=task, callback=callback)) |
145 | | - self._after_enqueue(task) |
| 190 | + try: |
| 191 | + admission = self._admit_task(task) |
| 192 | + task.bind_admission_task_id(admission.task_id) |
| 193 | + except Exception: |
| 194 | + self._remove_task(task.fileitem) |
| 195 | + raise |
| 196 | + try: |
| 197 | + self._before_enqueue(task) |
| 198 | + self._enqueue(TransferQueue(task=task, callback=callback)) |
| 199 | + except Exception as err: |
| 200 | + try: |
| 201 | + self._enqueue_failed(task, err) |
| 202 | + finally: |
| 203 | + self._remove_task(task.fileitem) |
| 204 | + raise |
146 | 205 | return True |
147 | 206 |
|
148 | 207 | def remove(self, fileitem: FileItem) -> None: |
|
0 commit comments