tasklite-engine 1.2.1__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.
Files changed (52) hide show
  1. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/PKG-INFO +6 -4
  2. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/README.md +5 -3
  3. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/__init__.py +1 -1
  4. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/backend/base.py +70 -2
  5. tasklite_engine-1.2.2/tasklite/backend/memory.py +333 -0
  6. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/backend/sqlite_backend.py +92 -22
  7. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/channel.py +41 -3
  8. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/completion.py +1 -0
  9. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/console.py +23 -1
  10. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/dispatch.py +5 -0
  11. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/governor.py +14 -0
  12. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/policy.py +15 -5
  13. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/recovery.py +106 -43
  14. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/runtime.py +46 -22
  15. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/store.py +6 -0
  16. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/types.py +5 -0
  17. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/models/context.py +20 -1
  18. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/models/job.py +18 -8
  19. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/models/state.py +28 -14
  20. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/pipeline.py +21 -2
  21. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/taxonomy.py +37 -5
  22. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/utils/injective.py +38 -7
  23. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/utils/ipc.py +35 -22
  24. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/utils/jsonutil.py +28 -7
  25. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/wrappers/http.py +188 -21
  26. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/PKG-INFO +6 -4
  27. tasklite_engine-1.2.1/tasklite/backend/memory.py +0 -224
  28. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/LICENSE +0 -0
  29. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/pyproject.toml +0 -0
  30. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/setup.cfg +0 -0
  31. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/backend/__init__.py +0 -0
  32. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/contrib/__init__.py +0 -0
  33. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/__init__.py +0 -0
  34. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/config.py +0 -0
  35. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/inflight.py +0 -0
  36. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/pacing.py +0 -0
  37. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/resource.py +0 -0
  38. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/scheduler.py +0 -0
  39. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/engine/session.py +0 -0
  40. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/exceptions.py +0 -0
  41. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/hooks.py +0 -0
  42. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/models/__init__.py +0 -0
  43. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/py.typed +0 -0
  44. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/utils/__init__.py +0 -0
  45. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/utils/lockfile.py +0 -0
  46. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/wrappers/__init__.py +0 -0
  47. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite/wrappers/discovery.py +0 -0
  48. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/SOURCES.txt +0 -0
  49. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/dependency_links.txt +0 -0
  50. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/requires.txt +0 -0
  51. {tasklite_engine-1.2.1 → tasklite_engine-1.2.2}/tasklite_engine.egg-info/top_level.txt +0 -0
  52. {tasklite_engine-1.2.1 → 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.1
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
- out_file = ctx.declare_output(f"./results/{job.job_id}.json", cleanup_on_fail=True)
281
- out_file.write_text(json.dumps(result, ensure_ascii=False))
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
- out_file = ctx.declare_output(f"./results/{job.job_id}.json", cleanup_on_fail=True)
252
- out_file.write_text(json.dumps(result, ensure_ascii=False))
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)
@@ -4,7 +4,7 @@ A lightweight, battle-hardened task orchestration engine with zero external
4
4
  dependencies, process isolation, and ACID persistence.
5
5
  """
6
6
 
7
- __version__ = "1.2.1"
7
+ __version__ = "1.2.2"
8
8
 
9
9
 
10
10
  # Public API - Core
@@ -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 Dict, Any, List, Optional, Tuple, Union
9
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
10
10
 
11
- from .base import AbstractStateBackend, classify_error_type
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 save_queue(self, jobs: List[Dict[str, Any]]) -> None:
241
- """全量重写队列(bootstrap / 崩溃恢复用,罕见 O(N))。
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
- conn.execute('DELETE FROM queue')
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],