tasklite-engine 1.2.0__tar.gz → 1.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/PKG-INFO +6 -4
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/README.md +5 -3
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/__init__.py +1 -1
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/backend/base.py +70 -2
- tasklite_engine-1.2.2/tasklite/backend/memory.py +333 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/backend/sqlite_backend.py +92 -22
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/channel.py +41 -3
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/completion.py +1 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/console.py +23 -1
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/dispatch.py +5 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/governor.py +14 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/inflight.py +6 -2
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/policy.py +15 -5
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/recovery.py +106 -43
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/runtime.py +46 -22
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/scheduler.py +1 -1
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/store.py +16 -10
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/types.py +5 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/models/context.py +20 -1
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/models/job.py +18 -8
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/models/state.py +41 -18
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/pipeline.py +21 -2
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/taxonomy.py +37 -5
- tasklite_engine-1.2.2/tasklite/utils/injective.py +216 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/utils/ipc.py +35 -22
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/utils/jsonutil.py +28 -7
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/wrappers/http.py +188 -21
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/PKG-INFO +6 -4
- tasklite_engine-1.2.0/tasklite/backend/memory.py +0 -224
- tasklite_engine-1.2.0/tasklite/utils/injective.py +0 -146
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/LICENSE +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/pyproject.toml +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/setup.cfg +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/backend/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/contrib/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/config.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/pacing.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/resource.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/engine/session.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/exceptions.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/hooks.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/models/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/py.typed +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/utils/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/utils/lockfile.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/wrappers/__init__.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite/wrappers/discovery.py +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/SOURCES.txt +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/dependency_links.txt +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/requires.txt +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/top_level.txt +0 -0
- {tasklite_engine-1.2.0 → tasklite_engine-1.2.2}/tests/test_packaging.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasklite-engine
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: A lightweight task orchestration engine with zero external dependencies, process isolation, and ACID persistence
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/CaptainLi321/tasklite
|
|
@@ -269,6 +269,7 @@ def fetch_handler(job, ctx):
|
|
|
269
269
|
|
|
270
270
|
```python
|
|
271
271
|
import json
|
|
272
|
+
from pathlib import Path
|
|
272
273
|
from tasklite import TaskLite, Job, RateLimitResource
|
|
273
274
|
|
|
274
275
|
def agent_worker(job: Job, ctx):
|
|
@@ -276,9 +277,10 @@ def agent_worker(job: Job, ctx):
|
|
|
276
277
|
prompt = job.payload["prompt"]
|
|
277
278
|
result = call_llm(prompt)
|
|
278
279
|
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
# declare_output 返回解析后的绝对路径字符串(str),用 Path 包装后写文件,
|
|
281
|
+
# 保证写入位置与框架校验/清理位置一致
|
|
282
|
+
out_path = Path(ctx.declare_output(f"./results/{job.job_id}.json", cleanup_on_fail=True))
|
|
283
|
+
out_path.write_text(json.dumps(result, ensure_ascii=False))
|
|
282
284
|
return True, {"tokens": result.get("usage", 0)}
|
|
283
285
|
|
|
284
286
|
pipeline = TaskLite(name="agent_batch", state_dir="./agent_state", max_workers=8)
|
|
@@ -240,6 +240,7 @@ def fetch_handler(job, ctx):
|
|
|
240
240
|
|
|
241
241
|
```python
|
|
242
242
|
import json
|
|
243
|
+
from pathlib import Path
|
|
243
244
|
from tasklite import TaskLite, Job, RateLimitResource
|
|
244
245
|
|
|
245
246
|
def agent_worker(job: Job, ctx):
|
|
@@ -247,9 +248,10 @@ def agent_worker(job: Job, ctx):
|
|
|
247
248
|
prompt = job.payload["prompt"]
|
|
248
249
|
result = call_llm(prompt)
|
|
249
250
|
|
|
250
|
-
#
|
|
251
|
-
|
|
252
|
-
|
|
251
|
+
# declare_output 返回解析后的绝对路径字符串(str),用 Path 包装后写文件,
|
|
252
|
+
# 保证写入位置与框架校验/清理位置一致
|
|
253
|
+
out_path = Path(ctx.declare_output(f"./results/{job.job_id}.json", cleanup_on_fail=True))
|
|
254
|
+
out_path.write_text(json.dumps(result, ensure_ascii=False))
|
|
253
255
|
return True, {"tokens": result.get("usage", 0)}
|
|
254
256
|
|
|
255
257
|
pipeline = TaskLite(name="agent_batch", state_dir="./agent_state", max_workers=8)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
6
6
|
|
|
7
7
|
from ..taxonomy import (
|
|
8
8
|
ERR_COMMIT_FAILURE_DLQ,
|
|
@@ -28,6 +28,29 @@ from ..taxonomy import (
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def validate_queue_replacement(jobs: Any) -> None:
|
|
32
|
+
"""整表替换集统一形状校验(save_queue / replace_queue_atomic 共用)。
|
|
33
|
+
|
|
34
|
+
契约:替换集必须是 job dict 的 list/tuple(与 load_queue 行形态一致);
|
|
35
|
+
None(compute 漏写 return 的笔误形态)或非序列、元素非 dict 均属契约
|
|
36
|
+
违约,fail-loud 抛 TypeError。
|
|
37
|
+
不变式:双腿必须在任何写变(DELETE / 赋值)之前调用本校验——None 若
|
|
38
|
+
混过校验,SQLite 腿「DELETE 全表成功 + INSERT 全跳过」会静默清空整条
|
|
39
|
+
队列且事务正常提交,与 Memory 腿抛 TypeError 且队列原状形成行为分叉。
|
|
40
|
+
"""
|
|
41
|
+
if not isinstance(jobs, (list, tuple)):
|
|
42
|
+
raise TypeError(
|
|
43
|
+
f"queue replacement must be a list of job dicts, "
|
|
44
|
+
f"got {type(jobs).__name__}"
|
|
45
|
+
)
|
|
46
|
+
for i, job in enumerate(jobs):
|
|
47
|
+
if not isinstance(job, dict):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"queue replacement item #{i} must be a job dict, "
|
|
50
|
+
f"got {type(job).__name__}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
31
54
|
|
|
32
55
|
class AbstractStateBackend(ABC):
|
|
33
56
|
"""Abstract interface for pipeline state persistence.
|
|
@@ -49,7 +72,38 @@ class AbstractStateBackend(ABC):
|
|
|
49
72
|
def load_queue(self) -> List[Dict[str, Any]]: ...
|
|
50
73
|
|
|
51
74
|
@abstractmethod
|
|
52
|
-
def save_queue(self, jobs: List[Dict[str, Any]]) -> None:
|
|
75
|
+
def save_queue(self, jobs: List[Dict[str, Any]]) -> None:
|
|
76
|
+
"""整表重写队列(测试装配 / replace_queue_atomic 的事务内步骤)。
|
|
77
|
+
|
|
78
|
+
红线:这是「无读基准的整表覆盖」——跨进程并发的 enqueue/commit
|
|
79
|
+
若提交在本方法执行前的任意时刻,其已落盘行会被无条件抹除。进程内
|
|
80
|
+
任何「先读磁盘真相再决定写什么」的合并保存(崩溃恢复路径)严禁
|
|
81
|
+
直接调用本方法,必须走 ``replace_queue_atomic`` 把读-改-写收敛进
|
|
82
|
+
单个写事务。
|
|
83
|
+
|
|
84
|
+
替换集经 ``validate_queue_replacement`` 在写变前校验:None / 非序列 /
|
|
85
|
+
元素非 dict 抛 TypeError,队列保持调用前状态(双腿一致)。
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def replace_queue_atomic(
|
|
90
|
+
self,
|
|
91
|
+
compute: Callable[[List[Dict[str, Any]]], List[Dict[str, Any]]],
|
|
92
|
+
) -> None:
|
|
93
|
+
"""读-改-写收敛的整表替换:磁盘真相读取与写回在单个写事务内完成。
|
|
94
|
+
|
|
95
|
+
``compute`` 在写锁内接收按序排列的磁盘队列快照,返回替换后的完整
|
|
96
|
+
队列。不变式:并发方(跨进程 enqueue / delta commit)要么先于本
|
|
97
|
+
事务提交(其行进入磁盘真相、参与合并,绝不丢失),要么排队等本
|
|
98
|
+
事务提交后再落盘——「读真相 → 计算 → 写回」之间不存在无锁窗口,
|
|
99
|
+
陈旧快照永远无法覆盖窗口期他进程已应答的新写入。
|
|
100
|
+
|
|
101
|
+
``compute`` 必须是纯计算(锁内执行,不得调用任何后端写方法,否则
|
|
102
|
+
跨连接写请求会以写锁互斥死等)。单事务原子:``compute`` 抛异常或
|
|
103
|
+
写失败时整体回滚,磁盘保持调用前状态,异常向外传播。``compute``
|
|
104
|
+
返回的替换集经 ``validate_queue_replacement`` 在写变前校验(None /
|
|
105
|
+
非序列 / 元素非 dict 抛 TypeError,双腿一致,磁盘保持原状)。
|
|
106
|
+
"""
|
|
53
107
|
|
|
54
108
|
@abstractmethod
|
|
55
109
|
def commit_job_success(
|
|
@@ -138,6 +192,17 @@ class AbstractStateBackend(ABC):
|
|
|
138
192
|
返回 True 成功;False 时 on-disk 队列不变,调用方走崩溃契约。
|
|
139
193
|
"""
|
|
140
194
|
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def delete_queue_uids(self, uids: List[str]) -> int:
|
|
197
|
+
"""按 uid 定向批量删除队列行(加载期 repair 的差量落盘唯一出口)。
|
|
198
|
+
|
|
199
|
+
与 save_queue 的全表重写相对:只 DELETE 指定 uid 的行,其余行原样
|
|
200
|
+
保留。不变式:删除集在调用前确定,不在删除集内的行(含并发进程
|
|
201
|
+
刚入队的新行)无论与本事务先后提交都必然存活——陈旧加载快照永远
|
|
202
|
+
不会覆盖他进程的新写入。单事务原子;失败抛异常(不吞),磁盘保持
|
|
203
|
+
调用前状态(残留行由下次加载重新判定,天然幂等)。返回实际删除行数。
|
|
204
|
+
"""
|
|
205
|
+
|
|
141
206
|
@abstractmethod
|
|
142
207
|
def get_meta(self, key: str) -> Optional[str]:
|
|
143
208
|
"""读取一条框架级元数据(如 fencing 的 last_run_id)。无则返回 None。"""
|
|
@@ -189,6 +254,9 @@ class AbstractStateBackend(ABC):
|
|
|
189
254
|
用途:媒体/数据资产项目的存档迁移(硬链接 + wall 种子),
|
|
190
255
|
不必裸 SQL INSERT 框架内部表。返回实际写入行数。
|
|
191
256
|
幂等:已存在的 uid 被覆盖(meta 重置为空)。
|
|
257
|
+
不变式(wall/failed 全局互斥):已在 failed 的 uid 拒绝种子——
|
|
258
|
+
冲突整体拒绝(零写入、抛 ValueError),不静默清除 DLQ 记录;
|
|
259
|
+
持久层与管理 API(OpsConsole.seed_wall)两层同契约。
|
|
192
260
|
"""
|
|
193
261
|
|
|
194
262
|
@abstractmethod
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""纯内存状态后端适配器(InMemoryStateBackend)。
|
|
2
|
+
|
|
3
|
+
实现 AbstractStateBackend 完整契约,提供零文件系统 IO 的纯内存状态存储。
|
|
4
|
+
适用于瞬态管线、单元测试、沙盒执行与 CI 矩阵测试。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .base import (
|
|
15
|
+
AbstractStateBackend,
|
|
16
|
+
classify_error_type,
|
|
17
|
+
validate_queue_replacement,
|
|
18
|
+
)
|
|
19
|
+
from ..models.state import uid_from_job_dict
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("tasklite")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InMemoryStateBackend(AbstractStateBackend):
|
|
25
|
+
"""纯内存状态后端。提供快照隔离的 delta 事务语义,与 SQLite 后端行为完全对齐。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._lock = threading.RLock()
|
|
29
|
+
self._wall: Dict[str, Dict[str, Any]] = {}
|
|
30
|
+
self._failed: Dict[str, Dict[str, Any]] = {}
|
|
31
|
+
self._cursors: Dict[str, str] = {}
|
|
32
|
+
self._queue: List[Dict[str, Any]] = []
|
|
33
|
+
self._meta: Dict[str, str] = {}
|
|
34
|
+
|
|
35
|
+
def load_wall(self) -> Dict[str, Dict[str, Any]]:
|
|
36
|
+
with self._lock:
|
|
37
|
+
return copy.deepcopy(self._wall)
|
|
38
|
+
|
|
39
|
+
def load_failed(self) -> Dict[str, Dict[str, Any]]:
|
|
40
|
+
with self._lock:
|
|
41
|
+
return copy.deepcopy(self._failed)
|
|
42
|
+
|
|
43
|
+
def load_cursors(self) -> Dict[str, str]:
|
|
44
|
+
with self._lock:
|
|
45
|
+
return dict(self._cursors)
|
|
46
|
+
|
|
47
|
+
def load_queue(self) -> List[Dict[str, Any]]:
|
|
48
|
+
with self._lock:
|
|
49
|
+
return copy.deepcopy(self._queue)
|
|
50
|
+
|
|
51
|
+
def _dedup_copy(self, jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
52
|
+
"""整表替换行的单一出口(save_queue 与 replace_queue_atomic 共用)。
|
|
53
|
+
|
|
54
|
+
替换集先经 ``validate_queue_replacement`` 校验(与 SQLite 腿同一出口、
|
|
55
|
+
同一时机——赋值之前 fail-loud,`_queue` 保持调用前状态)。
|
|
56
|
+
保存兜底去重:重复 uid 保留首条 + 告警,与 SQLite 腿
|
|
57
|
+
_rewrite_queue_rows 同语义;条目一律 deepcopy,杜绝外部可变别名
|
|
58
|
+
穿透快照隔离。
|
|
59
|
+
"""
|
|
60
|
+
validate_queue_replacement(jobs)
|
|
61
|
+
seen = set()
|
|
62
|
+
clean = []
|
|
63
|
+
for j in jobs:
|
|
64
|
+
u = uid_from_job_dict(j)
|
|
65
|
+
if u in seen:
|
|
66
|
+
logger.warning(f"save_queue: duplicate uid {u} dropped (kept first).")
|
|
67
|
+
continue
|
|
68
|
+
seen.add(u)
|
|
69
|
+
clean.append(copy.deepcopy(j))
|
|
70
|
+
return clean
|
|
71
|
+
|
|
72
|
+
def save_queue(self, jobs: List[Dict[str, Any]]) -> None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._queue = self._dedup_copy(jobs)
|
|
75
|
+
|
|
76
|
+
def replace_queue_atomic(
|
|
77
|
+
self,
|
|
78
|
+
compute: Callable[[List[Dict[str, Any]]], List[Dict[str, Any]]],
|
|
79
|
+
) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
# 锁内读真相 → 纯计算 → 替换:compute 抛异常时 _queue 赋值
|
|
82
|
+
# 未发生,队列保持调用前状态(对齐 SQLite 腿事务回滚)。
|
|
83
|
+
self._queue = self._dedup_copy(compute(copy.deepcopy(self._queue)))
|
|
84
|
+
|
|
85
|
+
def _build_dlq_meta(self, meta: Optional[dict], prev: Any) -> Dict[str, Any]:
|
|
86
|
+
"""计算 DLQ 行终值(纯函数,不变更任何状态):_attempt 计数 + error_type + failed_at。
|
|
87
|
+
|
|
88
|
+
不变式:``_attempt`` 是写入事件计数而非逻辑失败次数;既有计数损坏
|
|
89
|
+
(非 dict 记录或非 int 计数)时静默重置为 1 并照常提交,绝不因脏计数
|
|
90
|
+
抛 TypeError——与 SQLite 后端「损坏行重置计数、写入成功」行为对齐。
|
|
91
|
+
``merged`` 已显式携带 ``_attempt`` 时保留调用方值(计数由最先 DLQ
|
|
92
|
+
该 uid 的路径权威给定)。
|
|
93
|
+
"""
|
|
94
|
+
merged = copy.deepcopy(meta or {})
|
|
95
|
+
if "error_type" not in merged:
|
|
96
|
+
merged["error_type"] = classify_error_type(merged)
|
|
97
|
+
if "failed_at" not in merged:
|
|
98
|
+
merged["failed_at"] = datetime.now(timezone.utc).isoformat()
|
|
99
|
+
if not isinstance(prev, dict):
|
|
100
|
+
merged["_attempt"] = 1
|
|
101
|
+
return merged
|
|
102
|
+
prev_attempt = prev.get("_attempt")
|
|
103
|
+
if isinstance(prev_attempt, int):
|
|
104
|
+
merged["_attempt"] = prev_attempt + 1
|
|
105
|
+
elif "_attempt" not in merged:
|
|
106
|
+
merged["_attempt"] = 1
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
def _write_dlq_entry(self, uid: str, meta: Optional[dict]) -> None:
|
|
110
|
+
"""DLQ 写入单一出口:终值经 _build_dlq_meta 计算后落变。"""
|
|
111
|
+
self._failed[uid] = self._build_dlq_meta(meta, self._failed.get(uid))
|
|
112
|
+
|
|
113
|
+
def commit_job_success(
|
|
114
|
+
self,
|
|
115
|
+
uid: str,
|
|
116
|
+
result_meta: dict,
|
|
117
|
+
*,
|
|
118
|
+
spawned_jobs: List[Dict[str, Any]] = (),
|
|
119
|
+
cursor_updates: Optional[Dict[str, str]] = None,
|
|
120
|
+
) -> bool:
|
|
121
|
+
with self._lock:
|
|
122
|
+
try:
|
|
123
|
+
# 校验先行:全部 deepcopy 与冲突判定在任何变更前完成。
|
|
124
|
+
# 不变式:返回 False / 抛异常 ⇒ wall/queue/failed/cursors 与
|
|
125
|
+
# 调用前完全一致(对齐 SQLite 事务回滚;store 的 3-strike
|
|
126
|
+
# 崩溃契约以「后端未变」为前提做重启重建)。
|
|
127
|
+
wall_meta = copy.deepcopy(result_meta or {})
|
|
128
|
+
spawned_copy = [copy.deepcopy(j) for j in spawned_jobs]
|
|
129
|
+
remaining = [j for j in self._queue if uid_from_job_dict(j) != uid]
|
|
130
|
+
if spawned_copy:
|
|
131
|
+
remaining_uids = {uid_from_job_dict(j) for j in remaining}
|
|
132
|
+
seen: set = set()
|
|
133
|
+
for sj in spawned_copy:
|
|
134
|
+
suid = uid_from_job_dict(sj)
|
|
135
|
+
# spawned uid 撞上删除 popped 后的队列既有条目,或
|
|
136
|
+
# 批内自相重复,均判定内存/磁盘漂移 → False 走崩溃
|
|
137
|
+
# 契约,绝不产出重复队列条目(对齐 SQLite
|
|
138
|
+
# INSERT OR IGNORE + rowcount 漂移检测)。
|
|
139
|
+
if suid in remaining_uids or suid in seen:
|
|
140
|
+
logger.critical(
|
|
141
|
+
f"commit_job_success for {uid}: spawned job {suid} "
|
|
142
|
+
f"conflicts with queue (drift). Returning False."
|
|
143
|
+
)
|
|
144
|
+
return False
|
|
145
|
+
seen.add(suid)
|
|
146
|
+
cursor_sets: Dict[str, str] = {}
|
|
147
|
+
cursor_dels: set = set()
|
|
148
|
+
if cursor_updates:
|
|
149
|
+
for k, v in cursor_updates.items():
|
|
150
|
+
if v is None:
|
|
151
|
+
cursor_dels.add(k)
|
|
152
|
+
else:
|
|
153
|
+
cursor_sets[k] = str(v)
|
|
154
|
+
# 校验全部通过,统一落变(落变段不再调用任何可失败操作)
|
|
155
|
+
self._wall[uid] = wall_meta
|
|
156
|
+
self._queue = spawned_copy + remaining # 队首插入 spawned 并保序
|
|
157
|
+
# 成功 commit 清理 failed 同名残行:与「job 最终状态唯一」
|
|
158
|
+
# 语义一致,防 wall∩failed 并存污染 _attempt 计数。
|
|
159
|
+
self._failed.pop(uid, None)
|
|
160
|
+
self._cursors.update(cursor_sets)
|
|
161
|
+
for k in cursor_dels:
|
|
162
|
+
self._cursors.pop(k, None)
|
|
163
|
+
return True
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.critical(f"Failed to commit job success for {uid}: {e}")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def commit_job_failure(self, uid: str, result_meta: dict) -> bool:
|
|
169
|
+
with self._lock:
|
|
170
|
+
try:
|
|
171
|
+
# 校验先行:DLQ 终值计算与队列 uid 提取全部在变更前完成,
|
|
172
|
+
# 任一步失败 ⇒ failed/queue/wall 整体不变(对齐 SQLite 回滚)。
|
|
173
|
+
new_meta = self._build_dlq_meta(result_meta, self._failed.get(uid))
|
|
174
|
+
remaining = [j for j in self._queue if uid_from_job_dict(j) != uid]
|
|
175
|
+
self._failed[uid] = new_meta
|
|
176
|
+
self._queue = remaining
|
|
177
|
+
# 同一事务语义内清理 wall 旧记录:rerun 任务重跑失败时旧成功
|
|
178
|
+
# 记录作废(最终状态唯一),防 wall∩failed 并存。
|
|
179
|
+
self._wall.pop(uid, None)
|
|
180
|
+
return True
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.critical(f"Failed to commit job failure for {uid}: {e}")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
def commit_retry(
|
|
186
|
+
self,
|
|
187
|
+
popped_uid: str,
|
|
188
|
+
requeued_job: Dict[str, Any],
|
|
189
|
+
*,
|
|
190
|
+
front: bool = False,
|
|
191
|
+
) -> bool:
|
|
192
|
+
with self._lock:
|
|
193
|
+
try:
|
|
194
|
+
requeued_copy = copy.deepcopy(requeued_job)
|
|
195
|
+
ruid = uid_from_job_dict(requeued_copy)
|
|
196
|
+
remaining = [j for j in self._queue if uid_from_job_dict(j) != popped_uid]
|
|
197
|
+
# 对齐 SQLite 普通 INSERT 冲突回滚:requeued uid 撞上删除
|
|
198
|
+
# popped 后的既有条目 → False 走崩溃契约,绝不产出重复条目;
|
|
199
|
+
# uid == popped_uid 属同 job 重插,安全放行。
|
|
200
|
+
if ruid != popped_uid and any(uid_from_job_dict(j) == ruid for j in remaining):
|
|
201
|
+
logger.critical(
|
|
202
|
+
f"commit_retry for {popped_uid}: requeued uid {ruid} "
|
|
203
|
+
f"conflicts with existing queue entry. Returning False."
|
|
204
|
+
)
|
|
205
|
+
return False
|
|
206
|
+
if front:
|
|
207
|
+
self._queue = [requeued_copy] + remaining
|
|
208
|
+
else:
|
|
209
|
+
self._queue = remaining + [requeued_copy]
|
|
210
|
+
return True
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.critical(f"Failed to commit retry for {popped_uid}: {e}")
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def commit_bulk_failure(self, uids_metas: List[Tuple[str, dict]]) -> bool:
|
|
216
|
+
with self._lock:
|
|
217
|
+
try:
|
|
218
|
+
# 校验先行:全部 DLQ 行计算在任何变更前完成——任一行失败则
|
|
219
|
+
# queue/wall/failed 整体不变(对齐 SQLite 事务回滚,杜绝
|
|
220
|
+
# 「队列已整体删除、DLQ 未落」的半成品失败态)。
|
|
221
|
+
new_entries: List[Tuple[str, Dict[str, Any]]] = []
|
|
222
|
+
overlay: Dict[str, Any] = {}
|
|
223
|
+
for uid, meta in uids_metas:
|
|
224
|
+
# 批内同 uid 多次出现时模拟 SQLite 同事务顺序写:
|
|
225
|
+
# 后一行读取前一行结果,_attempt 连续递增。
|
|
226
|
+
prev = overlay.get(uid, self._failed.get(uid))
|
|
227
|
+
entry = self._build_dlq_meta(meta, prev)
|
|
228
|
+
overlay[uid] = entry
|
|
229
|
+
new_entries.append((uid, entry))
|
|
230
|
+
fail_uids = {u for u, _ in uids_metas}
|
|
231
|
+
remaining = [j for j in self._queue if uid_from_job_dict(j) not in fail_uids]
|
|
232
|
+
for uid, entry in new_entries:
|
|
233
|
+
self._failed[uid] = entry
|
|
234
|
+
# rerun 任务被级联/死锁批量 DLQ 时 wall 旧成功记录作废
|
|
235
|
+
# (最终状态唯一),与 commit_job_failure 对称。
|
|
236
|
+
self._wall.pop(uid, None)
|
|
237
|
+
self._queue = remaining
|
|
238
|
+
return True
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.critical(f"Failed to commit bulk failure: {e}")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def append_failed(self, uid: str, payload: Optional[Dict[str, Any]] = None) -> None:
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._write_dlq_entry(uid, payload or {})
|
|
246
|
+
|
|
247
|
+
def commit_skip(self, uid: str) -> bool:
|
|
248
|
+
with self._lock:
|
|
249
|
+
self._queue = [j for j in self._queue if uid_from_job_dict(j) != uid]
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def delete_queue_uids(self, uids: List[str]) -> int:
|
|
253
|
+
"""按 uid 定向批量删除队列条目,与 SQLite 腿同语义:不触碰其余条目。"""
|
|
254
|
+
if not uids:
|
|
255
|
+
return 0
|
|
256
|
+
with self._lock:
|
|
257
|
+
del_set = set(uids)
|
|
258
|
+
kept = [j for j in self._queue if uid_from_job_dict(j) not in del_set]
|
|
259
|
+
removed = len(self._queue) - len(kept)
|
|
260
|
+
self._queue = kept
|
|
261
|
+
return removed
|
|
262
|
+
|
|
263
|
+
def get_meta(self, key: str) -> Optional[str]:
|
|
264
|
+
with self._lock:
|
|
265
|
+
return self._meta.get(key)
|
|
266
|
+
|
|
267
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._meta[key] = str(value)
|
|
270
|
+
|
|
271
|
+
def enqueue_jobs(self, jobs: List[Dict[str, Any]], *, front: bool = False) -> List[str]:
|
|
272
|
+
if not jobs:
|
|
273
|
+
return []
|
|
274
|
+
with self._lock:
|
|
275
|
+
existing = {uid_from_job_dict(j) for j in self._queue}
|
|
276
|
+
fresh: List[Dict[str, Any]] = []
|
|
277
|
+
batch_seen = set()
|
|
278
|
+
for j in jobs:
|
|
279
|
+
u = uid_from_job_dict(j)
|
|
280
|
+
if u in existing or u in batch_seen:
|
|
281
|
+
continue
|
|
282
|
+
batch_seen.add(u)
|
|
283
|
+
fresh.append(copy.deepcopy(j))
|
|
284
|
+
if not fresh:
|
|
285
|
+
return []
|
|
286
|
+
if front:
|
|
287
|
+
self._queue[0:0] = fresh
|
|
288
|
+
else:
|
|
289
|
+
self._queue.extend(fresh)
|
|
290
|
+
return [uid_from_job_dict(j) for j in fresh]
|
|
291
|
+
|
|
292
|
+
def delete_failed(self, uids: List[str]) -> int:
|
|
293
|
+
with self._lock:
|
|
294
|
+
del_set = set(uids)
|
|
295
|
+
count = 0
|
|
296
|
+
for u in del_set:
|
|
297
|
+
if u in self._failed:
|
|
298
|
+
del self._failed[u]
|
|
299
|
+
count += 1
|
|
300
|
+
return count
|
|
301
|
+
|
|
302
|
+
def delete_wall(self, uids: List[str]) -> int:
|
|
303
|
+
with self._lock:
|
|
304
|
+
del_set = set(uids)
|
|
305
|
+
count = 0
|
|
306
|
+
for u in del_set:
|
|
307
|
+
if u in self._wall:
|
|
308
|
+
del self._wall[u]
|
|
309
|
+
count += 1
|
|
310
|
+
return count
|
|
311
|
+
|
|
312
|
+
def seed_wall(self, uids: List[str]) -> int:
|
|
313
|
+
"""把 uid 批量写入 wall(meta 空 dict)。
|
|
314
|
+
|
|
315
|
+
不变式:wall/failed 全局互斥——已在 failed 的 uid 拒绝种子;
|
|
316
|
+
先查后写(同锁内),冲突整体拒绝、零写入,不静默清除 DLQ 记录。
|
|
317
|
+
"""
|
|
318
|
+
with self._lock:
|
|
319
|
+
conflict = sorted({u for u in uids if u in self._failed})
|
|
320
|
+
if conflict:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"seed_wall refuses uid(s) already in failed: {conflict}; "
|
|
323
|
+
f"wall/failed must stay disjoint"
|
|
324
|
+
)
|
|
325
|
+
count = 0
|
|
326
|
+
for u in uids:
|
|
327
|
+
self._wall[u] = {}
|
|
328
|
+
count += 1
|
|
329
|
+
return count
|
|
330
|
+
|
|
331
|
+
def seed_cursor(self, key: str, value: str) -> None:
|
|
332
|
+
with self._lock:
|
|
333
|
+
self._cursors[key] = str(value)
|
|
@@ -6,9 +6,13 @@ import sqlite3
|
|
|
6
6
|
from contextlib import contextmanager
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
10
10
|
|
|
11
|
-
from .base import
|
|
11
|
+
from .base import (
|
|
12
|
+
AbstractStateBackend,
|
|
13
|
+
classify_error_type,
|
|
14
|
+
validate_queue_replacement,
|
|
15
|
+
)
|
|
12
16
|
from ..models.state import uid_from_job_dict
|
|
13
17
|
from ..utils.jsonutil import dumps, loads
|
|
14
18
|
|
|
@@ -237,36 +241,71 @@ class SQLiteStateBackend(AbstractStateBackend):
|
|
|
237
241
|
except json.JSONDecodeError as e:
|
|
238
242
|
raise RuntimeError(f"Corrupted queue payload in {self.path.name}: {e}") from e
|
|
239
243
|
|
|
240
|
-
def
|
|
241
|
-
"""
|
|
244
|
+
def _rewrite_queue_rows(self, conn, jobs: List[Dict[str, Any]]) -> None:
|
|
245
|
+
"""queue 表整表重写的单一出口(save_queue 与 replace_queue_atomic 共用)。
|
|
246
|
+
|
|
247
|
+
写变前先经 ``validate_queue_replacement`` 校验替换集(None/非法形状
|
|
248
|
+
fail-loud 抛 TypeError,不触发 DELETE)——否则 None 会让「DELETE
|
|
249
|
+
全表 + INSERT 全跳」静默清空队列且事务正常提交。
|
|
242
250
|
|
|
243
251
|
保存兜底去重:传入重复 uid 时保留首条 + 告警(而非 REPLACE 静默
|
|
244
252
|
覆盖为最后一条)——与加载期去重策略一致,杜绝 `_queue_uids` set 与
|
|
245
|
-
queue list
|
|
253
|
+
queue list 的漂移。调用方必须已持写事务(显式或隐式)。
|
|
254
|
+
"""
|
|
255
|
+
validate_queue_replacement(jobs)
|
|
256
|
+
conn.execute('DELETE FROM queue')
|
|
257
|
+
if jobs:
|
|
258
|
+
seen: set = set()
|
|
259
|
+
rows = []
|
|
260
|
+
for i, j in enumerate(jobs):
|
|
261
|
+
u = uid_from_job_dict(j)
|
|
262
|
+
if u in seen:
|
|
263
|
+
logger.warning(
|
|
264
|
+
f"save_queue: duplicate uid {u} dropped (kept first)."
|
|
265
|
+
)
|
|
266
|
+
continue
|
|
267
|
+
seen.add(u)
|
|
268
|
+
rows.append((u, len(rows), dumps(j)))
|
|
269
|
+
conn.executemany(
|
|
270
|
+
'INSERT OR REPLACE INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
|
|
271
|
+
rows,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def save_queue(self, jobs: List[Dict[str, Any]]) -> None:
|
|
275
|
+
"""全量重写队列(测试装配 / replace_queue_atomic 事务内步骤,罕见 O(N))。
|
|
276
|
+
|
|
277
|
+
红线:无读基准的整表覆盖——崩溃恢复路径的「合并保存」严禁直接
|
|
278
|
+
调用本方法(读-改-写窗口会抹除窗口期他进程已应答的入队),
|
|
279
|
+
必须经 replace_queue_atomic 收敛进单个写事务。
|
|
246
280
|
"""
|
|
247
281
|
try:
|
|
248
282
|
with self._get_conn() as conn:
|
|
249
|
-
|
|
250
|
-
if jobs:
|
|
251
|
-
seen: set = set()
|
|
252
|
-
rows = []
|
|
253
|
-
for i, j in enumerate(jobs):
|
|
254
|
-
u = uid_from_job_dict(j)
|
|
255
|
-
if u in seen:
|
|
256
|
-
logger.warning(
|
|
257
|
-
f"save_queue: duplicate uid {u} dropped (kept first)."
|
|
258
|
-
)
|
|
259
|
-
continue
|
|
260
|
-
seen.add(u)
|
|
261
|
-
rows.append((u, len(rows), dumps(j)))
|
|
262
|
-
conn.executemany(
|
|
263
|
-
'INSERT OR REPLACE INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
|
|
264
|
-
rows,
|
|
265
|
-
)
|
|
283
|
+
self._rewrite_queue_rows(conn, jobs)
|
|
266
284
|
except Exception as e:
|
|
267
285
|
logger.critical(f"Failed to save queue to {self.path.name}: {e}")
|
|
268
286
|
raise
|
|
269
287
|
|
|
288
|
+
def replace_queue_atomic(
|
|
289
|
+
self,
|
|
290
|
+
compute: Callable[[List[Dict[str, Any]]], List[Dict[str, Any]]],
|
|
291
|
+
) -> None:
|
|
292
|
+
try:
|
|
293
|
+
with self._get_conn() as conn:
|
|
294
|
+
# 读-改-写事务纪律:先取写锁再读磁盘真相,compute 与写回
|
|
295
|
+
# 同事务——并发 enqueue 要么先于本事务提交(进入磁盘真相、
|
|
296
|
+
# 参与合并),要么等本事务提交后再落盘,绝无中间态覆盖。
|
|
297
|
+
conn.execute('BEGIN IMMEDIATE')
|
|
298
|
+
disk_q = [
|
|
299
|
+
loads(row[0])
|
|
300
|
+
for row in conn.execute(
|
|
301
|
+
'SELECT job_data FROM queue ORDER BY seq ASC'
|
|
302
|
+
)
|
|
303
|
+
]
|
|
304
|
+
self._rewrite_queue_rows(conn, compute(disk_q))
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logger.critical(f"Failed to replace queue atomically in {self.path.name}: {e}")
|
|
307
|
+
raise
|
|
308
|
+
|
|
270
309
|
def enqueue_jobs(self, jobs: List[Dict[str, Any]], *, front: bool = False) -> List[str]:
|
|
271
310
|
"""批量增量入队:单事务原子插入,跳过重复 uid。
|
|
272
311
|
|
|
@@ -459,6 +498,20 @@ class SQLiteStateBackend(AbstractStateBackend):
|
|
|
459
498
|
return False
|
|
460
499
|
return True
|
|
461
500
|
|
|
501
|
+
def delete_queue_uids(self, uids: List[str]) -> int:
|
|
502
|
+
"""按 uid 定向批量删除队列行(repair 差量落盘),不触碰其余行。"""
|
|
503
|
+
if not uids:
|
|
504
|
+
return 0
|
|
505
|
+
try:
|
|
506
|
+
with self._get_conn() as conn:
|
|
507
|
+
cur = conn.executemany(
|
|
508
|
+
'DELETE FROM queue WHERE uid = ?', [(u,) for u in uids]
|
|
509
|
+
)
|
|
510
|
+
return cur.rowcount if cur.rowcount is not None else 0
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.critical(f"Failed to delete queue uids in {self.path.name}: {e}")
|
|
513
|
+
raise
|
|
514
|
+
|
|
462
515
|
def commit_retry(self, popped_uid: str, requeued_job: Dict[str, Any], *, front: bool = False) -> bool:
|
|
463
516
|
"""原子 delta:删除 popped_uid + 按 front 插入 requeued_job。不写 wall/DLQ。
|
|
464
517
|
|
|
@@ -541,11 +594,28 @@ class SQLiteStateBackend(AbstractStateBackend):
|
|
|
541
594
|
"""把 uid 批量写入 wall(meta 空 dict)——存档迁移标记「已处理」。
|
|
542
595
|
|
|
543
596
|
幂等:已存在的 uid 被覆盖(meta 重置为空)。
|
|
597
|
+
不变式:wall/failed 全局互斥——已在 failed_dlq 的 uid 拒绝种子;
|
|
598
|
+
冲突检查与写入收敛进同一 ``BEGIN IMMEDIATE`` 写事务(先取写锁再读,
|
|
599
|
+
详见 _get_conn),冲突时零写入(整体拒绝),不静默清除 DLQ 记录。
|
|
544
600
|
"""
|
|
545
601
|
if not uids:
|
|
546
602
|
return 0
|
|
603
|
+
unique_uids = list(dict.fromkeys(uids))
|
|
547
604
|
try:
|
|
548
605
|
with self._get_conn() as conn:
|
|
606
|
+
conn.execute('BEGIN IMMEDIATE')
|
|
607
|
+
placeholders = ','.join('?' * len(unique_uids))
|
|
608
|
+
conflict = sorted(
|
|
609
|
+
row[0] for row in conn.execute(
|
|
610
|
+
f'SELECT uid FROM failed_dlq WHERE uid IN ({placeholders})',
|
|
611
|
+
unique_uids,
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
if conflict:
|
|
615
|
+
raise ValueError(
|
|
616
|
+
f"seed_wall refuses uid(s) already in failed: {conflict}; "
|
|
617
|
+
f"wall/failed must stay disjoint"
|
|
618
|
+
)
|
|
549
619
|
cur = conn.executemany(
|
|
550
620
|
'INSERT OR REPLACE INTO wall (uid, payload) VALUES (?, ?)',
|
|
551
621
|
[(u, dumps({})) for u in uids],
|