tasklite-engine 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,570 @@
1
+ import json
2
+ import logging
3
+ import sqlite3
4
+ from contextlib import contextmanager
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Dict, Any, List, Optional, Tuple, Union
8
+
9
+ from .base import AbstractStateBackend, classify_error_type
10
+ from ..models.state import uid_from_job_dict
11
+ from ..utils.jsonutil import dumps, loads
12
+
13
+ logger = logging.getLogger("tasklite")
14
+
15
+
16
+
17
+
18
+ _SCHEMA_VERSION = 1
19
+
20
+
21
+ class SQLiteStateBackend(AbstractStateBackend):
22
+ """Atomic SQLite state backend for tasklite.
23
+ Combines queue, wall_log, and failed_log into a single ACID-compliant database."""
24
+
25
+ def __init__(self, filepath: Union[str, Path]):
26
+ self.path = Path(filepath)
27
+ self.path.parent.mkdir(parents=True, exist_ok=True)
28
+ try:
29
+ self._init_db()
30
+ except Exception as e:
31
+ raise RuntimeError(f"SQLite backend initialization failed: {e}") from e
32
+
33
+ @contextmanager
34
+ def _get_conn(self):
35
+ """连接上下文管理器:成功时 commit,异常时 rollback,始终 close。
36
+
37
+ ``__exit__`` 中显式 ``conn.close()``——事务管理与连接关闭都由
38
+ 上下文管理器承担,调用方无法泄漏 fd。
39
+
40
+ ``synchronous=FULL`` 是 per-connection 设置,每次新建连接都需设置。
41
+ ``journal_mode=WAL`` 是 database-level,仅在 ``_init_db`` 中设置一次。
42
+
43
+ 持久化保障:NORMAL → FULL。WAL+NORMAL 下 commit 不 fsync WAL,
44
+ 断电可回滚最后若干事务——执行中 job 的回滚可被 at-least-once 重跑
45
+ 吸收,但 **enqueue 应答后断电**时 INSERT 事务消失且无 job 可重跑
46
+ (任务静默蒸发),文档「丢失的事务对应 job 重跑」对非队列事务不
47
+ 成立。FULL 保证已应答事务落盘;性能由 tests/perf/test_persistence_perf.py
48
+ 预算护栏验证(WAL 下 FULL 每次仅多一次 WAL fsync)。
49
+
50
+ 读-改-写事务纪律:``sqlite3`` 传统隔离模式(isolation_level 未显式
51
+ 配置)只为 DML 隐式开事务,SELECT 在 autocommit 下逐条取快照——
52
+ 「读 min/max(seq) → 分配 → INSERT」或「读 ``_attempt`` → 递增 →
53
+ REPLACE」若不显式开事务,两个连接会基于同一份旧快照各算各的,
54
+ 落盘后出现重复 seq(idx_queue_seq 非唯一,拦不住)或丢计数。此类
55
+ 方法必须在任何 SELECT 之前显式 ``BEGIN IMMEDIATE``:先取写锁再读,
56
+ 读与写在同一事务内;并发方要么先于本事务提交(本事务读到其结果),
57
+ 要么排队等锁(本事务提交后其读到本事务的结果)。锁等待由
58
+ connect(timeout=30.0) 的 busy timeout 承担——并发方等待写锁而非
59
+ 立即报 database is locked。
60
+ """
61
+ conn = sqlite3.connect(self.path, timeout=30.0)
62
+ try:
63
+ conn.execute('PRAGMA synchronous=FULL')
64
+ yield conn
65
+ conn.commit()
66
+ except Exception:
67
+ conn.rollback()
68
+ raise
69
+ finally:
70
+ conn.close()
71
+
72
+ def _init_db(self) -> None:
73
+ with self._get_conn() as conn:
74
+ # WAL 返回值必须验证——rollback journal
75
+ # (无 WAL)即使配 synchronous=FULL 也是 SQLite 文档标注的「断电
76
+ # 可损坏/性能陷阱」配置。当 WAL 无法生效时(NFS 无锁/只读介质/
77
+ # 被其他连接持有),PRAGMA 静默返回当前模式而不报错,引擎会以
78
+ # 「断电安全」招牌运行在可损坏配置下。
79
+ # fail-loud:宁可拒绝启动,不可带病运行(README 将 WAL 列为卖点)。
80
+ wal_row = conn.execute('PRAGMA journal_mode=WAL').fetchone()
81
+ if not wal_row or str(wal_row[0]).lower() != 'wal':
82
+ raise RuntimeError(
83
+ f"journal_mode=WAL could not be engaged on {self.path.name} "
84
+ f"(got {wal_row[0] if wal_row else None!r}). Refusing to run in "
85
+ f"a power-loss-corruptible configuration; check filesystem "
86
+ f"locking support (NFS) and that no other connection holds the DB."
87
+ )
88
+ user_ver = conn.execute("PRAGMA user_version").fetchone()[0]
89
+ if user_ver > _SCHEMA_VERSION:
90
+ raise RuntimeError(
91
+ f"Unsupported database schema version {user_ver} on {self.path.name}; "
92
+ f"this runtime only supports up to version {_SCHEMA_VERSION}."
93
+ )
94
+ conn.execute('''
95
+ CREATE TABLE IF NOT EXISTS wall (
96
+ uid TEXT PRIMARY KEY,
97
+ payload TEXT
98
+ )
99
+ ''')
100
+ conn.execute('''
101
+ CREATE TABLE IF NOT EXISTS failed_dlq (
102
+ uid TEXT PRIMARY KEY,
103
+ payload TEXT
104
+ )
105
+ ''')
106
+ self._ensure_queue_schema(conn)
107
+ conn.execute('''
108
+ CREATE TABLE IF NOT EXISTS cursors (
109
+ key TEXT PRIMARY KEY,
110
+ value TEXT
111
+ )
112
+ ''')
113
+ # fencing:框架级元数据表(last_run_id 等)
114
+ conn.execute('''
115
+ CREATE TABLE IF NOT EXISTS meta (
116
+ key TEXT PRIMARY KEY,
117
+ value TEXT NOT NULL
118
+ )
119
+ ''')
120
+ if user_ver < _SCHEMA_VERSION:
121
+ conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
122
+
123
+ def get_meta(self, key: str) -> Optional[str]:
124
+ if not self.path.exists():
125
+ return None
126
+ try:
127
+ with self._get_conn() as conn:
128
+ row = conn.execute('SELECT value FROM meta WHERE key = ?', (key,)).fetchone()
129
+ return row[0] if row else None
130
+ except (sqlite3.Error, OSError) as e:
131
+ raise RuntimeError(f"Failed to read meta '{key}' from {self.path.name}: {e}") from e
132
+
133
+ def set_meta(self, key: str, value: str) -> None:
134
+ try:
135
+ with self._get_conn() as conn:
136
+ conn.execute(
137
+ 'INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)',
138
+ (key, value),
139
+ )
140
+ except Exception as e:
141
+ logger.critical(f"Failed to write meta '{key}' in {self.path.name}: {e}")
142
+ raise
143
+
144
+ def _ensure_queue_schema(self, conn) -> None:
145
+ """确保 queue 表使用当前 schema(uid PK + seq 保序列)。
146
+
147
+ 只接受新 schema;旧 schema(``idx PK + job_data``)不再自动迁移,
148
+ 发现旧库直接 fail-loud,提示重建 state 库。
149
+ """
150
+ cols = conn.execute("PRAGMA table_info(queue)").fetchall()
151
+ if not cols:
152
+ conn.execute('''
153
+ CREATE TABLE queue (
154
+ uid TEXT PRIMARY KEY,
155
+ seq INTEGER NOT NULL,
156
+ job_data TEXT NOT NULL
157
+ )
158
+ ''')
159
+ conn.execute('CREATE INDEX IF NOT EXISTS idx_queue_seq ON queue(seq)')
160
+ return
161
+ col_names = {c[1] for c in cols}
162
+ if {'uid', 'seq', 'job_data'} <= col_names:
163
+ conn.execute('CREATE INDEX IF NOT EXISTS idx_queue_seq ON queue(seq)')
164
+ return
165
+ raise RuntimeError(
166
+ "Unsupported legacy queue schema; automatic migration has been "
167
+ "removed. Recreate the state database or migrate manually."
168
+ )
169
+
170
+ def _seq_range(self, conn, count: int, front: bool) -> List[int]:
171
+ """分配 count 个保序 seq:front 取 min_seq - count..min_seq-1,back 取 max_seq+1..。
172
+
173
+ 调用方必须已通过 ``BEGIN IMMEDIATE`` 持写事务:MIN/MAX 的读
174
+ 与调用方随后的 INSERT 必须原子——否则两个连接读到同一 min/max
175
+ 后各自分配,落盘重复 seq(idx_queue_seq 非唯一索引,拦不住),
176
+ 队头保序契约被破坏。
177
+ """
178
+ if count <= 0:
179
+ return []
180
+ min_seq, max_seq = conn.execute('SELECT MIN(seq), MAX(seq) FROM queue').fetchone()
181
+ if min_seq is None: # 空表
182
+ min_seq, max_seq = 0, -1
183
+ if front:
184
+ base = min_seq - count
185
+ return list(range(base, min_seq)) # 首条最小,保序
186
+ base = max_seq + 1
187
+ return list(range(base, base + count))
188
+
189
+ def load_wall(self) -> Dict[str, Dict[str, Any]]:
190
+ if not self.path.exists():
191
+ return {}
192
+ try:
193
+ with self._get_conn() as conn:
194
+ cursor = conn.execute('SELECT uid, payload FROM wall')
195
+ return {row[0]: loads(row[1]) for row in cursor}
196
+ except (sqlite3.Error, OSError) as e:
197
+ raise RuntimeError(f"Failed to load wall from {self.path.name}: {e}") from e
198
+ except json.JSONDecodeError as e:
199
+ raise RuntimeError(f"Corrupted wall payload in {self.path.name}: {e}") from e
200
+
201
+ def append_failed(self, uid: str, payload: Optional[Dict[str, Any]] = None) -> None:
202
+ try:
203
+ with self._get_conn() as conn:
204
+ # _attempt 读-递增-写必须持写事务串行化,否则并发 append
205
+ # 基于同一旧计数各写各的,丢失败历史(详见 _get_conn)。
206
+ conn.execute('BEGIN IMMEDIATE')
207
+ # 经 _write_dlq_row 单一出口:保留既有 _attempt 计数,
208
+ # 与 commit_* 路径的失败历史口径一致。
209
+ self._write_dlq_row(conn, uid, payload or {})
210
+ except Exception as e:
211
+ logger.error(f"Failed to append to DLQ in {self.path.name}: {e}")
212
+ raise
213
+
214
+ def load_failed(self) -> Dict[str, Dict[str, Any]]:
215
+ if not self.path.exists():
216
+ return {}
217
+ try:
218
+ with self._get_conn() as conn:
219
+ cursor = conn.execute('SELECT uid, payload FROM failed_dlq')
220
+ return {row[0]: loads(row[1]) for row in cursor}
221
+ except (sqlite3.Error, OSError) as e:
222
+ raise RuntimeError(f"Failed to load failed DLQ from {self.path.name}: {e}") from e
223
+ except json.JSONDecodeError as e:
224
+ raise RuntimeError(f"Corrupted failed DLQ payload in {self.path.name}: {e}") from e
225
+
226
+ def load_queue(self) -> List[Dict[str, Any]]:
227
+ if not self.path.exists():
228
+ return []
229
+ try:
230
+ with self._get_conn() as conn:
231
+ cursor = conn.execute('SELECT job_data FROM queue ORDER BY seq ASC')
232
+ return [loads(row[0]) for row in cursor]
233
+ except (sqlite3.Error, OSError) as e:
234
+ raise RuntimeError(f"Failed to load queue from {self.path.name}: {e}") from e
235
+ except json.JSONDecodeError as e:
236
+ raise RuntimeError(f"Corrupted queue payload in {self.path.name}: {e}") from e
237
+
238
+ def save_queue(self, jobs: List[Dict[str, Any]]) -> None:
239
+ """全量重写队列(bootstrap / 崩溃恢复用,罕见 O(N))。
240
+
241
+ 保存兜底去重:传入重复 uid 时保留首条 + 告警(而非 REPLACE 静默
242
+ 覆盖为最后一条)——与加载期去重策略一致,杜绝 `_queue_uids` set 与
243
+ queue list 的漂移。
244
+ """
245
+ try:
246
+ with self._get_conn() as conn:
247
+ conn.execute('DELETE FROM queue')
248
+ if jobs:
249
+ seen: set = set()
250
+ rows = []
251
+ for i, j in enumerate(jobs):
252
+ u = uid_from_job_dict(j)
253
+ if u in seen:
254
+ logger.warning(
255
+ f"save_queue: duplicate uid {u} dropped (kept first)."
256
+ )
257
+ continue
258
+ seen.add(u)
259
+ rows.append((u, len(rows), dumps(j)))
260
+ conn.executemany(
261
+ 'INSERT OR REPLACE INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
262
+ rows,
263
+ )
264
+ except Exception as e:
265
+ logger.critical(f"Failed to save queue to {self.path.name}: {e}")
266
+ raise
267
+
268
+ def enqueue_jobs(self, jobs: List[Dict[str, Any]], *, front: bool = False) -> List[str]:
269
+ """批量增量入队:单事务原子插入,跳过重复 uid。
270
+
271
+ 与 save_queue 的区别:不做 DELETE 全表重写——与 run() 的 delta
272
+ commit 并发时不覆盖其已提交变更(写入侧不覆盖,读-改-写窗口
273
+ 仍在)。返回实际插入的 uid 列表。
274
+ """
275
+ if not jobs:
276
+ return []
277
+ try:
278
+ with self._get_conn() as conn:
279
+ # 去重 SELECT 与 seq 分配读必须与后续 INSERT 同事务:
280
+ # 先取写锁再读,并发 front 入队才不会基于过期 min/max(seq)
281
+ # 分配出重复 seq(事务纪律详见 _get_conn docstring)。
282
+ conn.execute('BEGIN IMMEDIATE')
283
+ existing = {
284
+ row[0] for row in conn.execute('SELECT uid FROM queue')
285
+ }
286
+ fresh: List[Dict[str, Any]] = []
287
+ batch_seen: set = set()
288
+ for j in jobs:
289
+ u = uid_from_job_dict(j)
290
+ # 批次内重复(同一 enqueue 调用传相同 uid 两次)与
291
+ # 队列中已有的 uid 都跳过(首个存储,后续跳过)。
292
+ if u in existing or u in batch_seen:
293
+ continue
294
+ batch_seen.add(u)
295
+ fresh.append(j)
296
+ if not fresh:
297
+ return []
298
+ seqs = self._seq_range(conn, len(fresh), front=front)
299
+ conn.executemany(
300
+ 'INSERT INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
301
+ [(uid_from_job_dict(j), s, dumps(j))
302
+ for j, s in zip(fresh, seqs)],
303
+ )
304
+ return [uid_from_job_dict(j) for j in fresh]
305
+ except Exception as e:
306
+ logger.critical(f"Failed to enqueue jobs to {self.path.name}: {e}")
307
+ raise
308
+
309
+ def load_cursors(self) -> Dict[str, str]:
310
+ if not self.path.exists():
311
+ return {}
312
+ try:
313
+ with self._get_conn() as conn:
314
+ cursor = conn.execute('SELECT key, value FROM cursors')
315
+ return {row[0]: row[1] for row in cursor}
316
+ except (sqlite3.Error, OSError) as e:
317
+ raise RuntimeError(f"Failed to load cursors from {self.path.name}: {e}") from e
318
+
319
+ def commit_job_success(self, uid: str, result_meta: dict, *, spawned_jobs=(), cursor_updates: Optional[Dict[str, str]] = None) -> bool:
320
+ """原子 delta:写 wall + 删除 popped uid + 队头插入 spawned_jobs + 更新 cursors。
321
+
322
+ 失败时事务回滚,on-disk 队列不变(popped uid 仍在磁盘)。
323
+ spawned_jobs 用 INSERT OR IGNORE 防御重复 uid,不重排既有条目。
324
+ """
325
+ try:
326
+ with self._get_conn() as conn:
327
+ # spawned 队头插入的 seq 分配是读-改-写:先取写锁再读,
328
+ # 与并发的 front 入队互斥,否则两方读到同一 min_seq。
329
+ conn.execute('BEGIN IMMEDIATE')
330
+ conn.execute('INSERT OR REPLACE INTO wall (uid, payload) VALUES (?, ?)',
331
+ (uid, dumps(result_meta or {})))
332
+ conn.execute('DELETE FROM queue WHERE uid = ?', (uid,))
333
+ # 成功 commit 时清理 failed_dlq 同名残行——
334
+ # 否则同一 uid 永久「既成功又失败」(DLQ 失败历史与 wall 矛盾,
335
+ # _attempt 计数被陈旧行污染)。幂等,与「job 最终状态唯一」语义一致。
336
+ conn.execute('DELETE FROM failed_dlq WHERE uid = ?', (uid,))
337
+ if spawned_jobs:
338
+ seqs = self._seq_range(conn, len(spawned_jobs), front=True)
339
+ cur = conn.executemany(
340
+ 'INSERT OR IGNORE INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
341
+ [(uid_from_job_dict(j), s, dumps(j))
342
+ for j, s in zip(spawned_jobs, seqs)],
343
+ )
344
+ # 防止 INSERT OR IGNORE 静默丢弃 spawn:若某个
345
+ # spawned uid 与磁盘队列冲突(内存 is_known 预筛后的漂移
346
+ # 窗口),该 job 只存在于内存、磁盘从未持久化,进程崩溃
347
+ # 后静默丢失(at-least-once 违约)。executemany 的 rowcount
348
+ # 返回实际插入行数(全冲突=0/部分冲突=实际数),
349
+ # rowcount < len 即检测到漂移 → 返回 False 走崩溃契约
350
+ # (重启后 at-least-once 重扫),而非静默掩盖。
351
+ if cur.rowcount is not None and cur.rowcount < len(spawned_jobs):
352
+ logger.critical(
353
+ f"commit_job_success for {uid}: {len(spawned_jobs) - cur.rowcount} "
354
+ f"spawned job(s) silently dropped by INSERT OR IGNORE "
355
+ f"(disk/memory drift). Returning False to trigger crash contract."
356
+ )
357
+ raise RuntimeError(
358
+ f"spawned job uid conflict on disk for {uid}: "
359
+ f"inserted {cur.rowcount}/{len(spawned_jobs)}"
360
+ )
361
+ if cursor_updates:
362
+ # None 值表示删除游标;非 None 值为 str(set_cursor 校验保证)
363
+ for k, v in cursor_updates.items():
364
+ if v is None:
365
+ conn.execute('DELETE FROM cursors WHERE key = ?', (k,))
366
+ else:
367
+ conn.execute(
368
+ 'INSERT OR REPLACE INTO cursors (key, value) VALUES (?, ?)',
369
+ (k, v),
370
+ )
371
+ except Exception as e:
372
+ logger.critical(f"Failed to commit job success for {uid} in {self.path.name}: {e}")
373
+ return False
374
+ return True
375
+
376
+ def _write_dlq_row(self, conn, uid: str, meta: dict) -> None:
377
+ """DLQ 行写入的单一出口。
378
+
379
+ 读既有 ``_attempt`` 计数 → 递增或初始化 → INSERT OR REPLACE。
380
+ ``commit_job_failure`` / ``commit_bulk_failure`` / ``append_failed``
381
+ 三处共用单一出口,统一保留 ``_attempt`` 计数——同一 uid 的
382
+ 失败次数不取决于最先 DLQ 它的路径(README「失败历史可观测」契约)。
383
+
384
+ 写入计数语义:``_attempt`` 是**写入事件计数**而非
385
+ 逻辑失败次数——同一逻辑失败可被多条路径写入(如级联 bulk + 单条
386
+ failure)各计一次。这是有意简化:跨路径去重需要调用方标记同一失败
387
+ 批次,收益低于复杂度。读者应将 ``_attempt`` 解读为「该 uid 被
388
+ 写入 DLQ 的次数」(可观测性),而非精确的失败次数。
389
+
390
+ 统一补结构化字段:``error_type``(``classify_error_type``
391
+ 推导)与 ``failed_at``(UTC ISO 时间戳)。所有 DLQ 写入路径(含死锁/级联/
392
+ 重试耗尽)都带分类与时间,list_dlq() 查询可直接按类型过滤。
393
+
394
+ 调用方必须已通过 ``BEGIN IMMEDIATE`` 持写事务:SELECT 旧计数 →
395
+ 递增 → REPLACE 的序列在 autocommit 下会丢并发计数(两个连接
396
+ 各基于同一旧值 +1 落盘,只留一次)。
397
+ """
398
+ row = conn.execute(
399
+ 'SELECT payload FROM failed_dlq WHERE uid = ?', (uid,)
400
+ ).fetchone()
401
+ merged = dict(meta or {})
402
+ if "error_type" not in merged:
403
+ merged["error_type"] = classify_error_type(merged)
404
+ if "failed_at" not in merged:
405
+ merged["failed_at"] = datetime.now(timezone.utc).isoformat()
406
+ if row is not None:
407
+ try:
408
+ prev = loads(row[0])
409
+ if isinstance(prev, dict) and isinstance(prev.get("_attempt"), int):
410
+ merged["_attempt"] = prev["_attempt"] + 1
411
+ elif isinstance(prev, dict) and "_attempt" not in merged:
412
+ # 既有记录无 _attempt(旧路径写入)→ 初始化为 1
413
+ merged["_attempt"] = 1
414
+ except (json.JSONDecodeError, TypeError, ValueError):
415
+ merged["_attempt"] = 1 # 既有记录损坏:从 1 重新计数
416
+ else:
417
+ merged["_attempt"] = 1
418
+ conn.execute('INSERT OR REPLACE INTO failed_dlq (uid, payload) VALUES (?, ?)',
419
+ (uid, dumps(merged)))
420
+
421
+ def commit_job_failure(self, uid: str, result_meta: dict) -> bool:
422
+ """原子 delta:写 DLQ + 删除 popped uid + 清理 wall 旧记录。失败时 on-disk 队列不变。
423
+
424
+ 同 uid 多次失败时保留失败历史——DLQ 是 INSERT OR REPLACE
425
+ (PK 覆盖),直接覆盖会丢失「这是第几次失败」。读取既有记录的
426
+ ``_attempt`` 计数并递增后合并写入,让同一 uid 的失败历史可观测。
427
+ 写盘经 ``_write_dlq_row`` 单一出口(_attempt 计数路径收敛)。
428
+
429
+ 同一事务内 ``DELETE FROM wall``——rerun 任务重跑
430
+ 失败时 wall 里的旧成功记录必须作废(最终状态唯一);否则磁盘
431
+ wall∩failed 并存 → 下次 run DEBUG 断言崩。事务内单出口、原子、
432
+ 幂等(never 任务失败时 wall 本无该行,DELETE no-op)。
433
+ """
434
+ try:
435
+ with self._get_conn() as conn:
436
+ # _attempt 读-递增-写必须持写事务串行化(详见 _get_conn)。
437
+ conn.execute('BEGIN IMMEDIATE')
438
+ self._write_dlq_row(conn, uid, result_meta)
439
+ conn.execute('DELETE FROM queue WHERE uid = ?', (uid,))
440
+ conn.execute('DELETE FROM wall WHERE uid = ?', (uid,))
441
+ except Exception as e:
442
+ logger.critical(f"Failed to commit job failure for {uid} in {self.path.name}: {e}")
443
+ return False
444
+ return True
445
+
446
+ def commit_skip(self, uid: str) -> bool:
447
+ """Delta 删除队列中已完成/已失败(重复)的 uid。
448
+
449
+ 去重命中时调用——磁盘队列中该 uid 是残留条目,DELETE 清理以同步
450
+ 内存/磁盘。不写 wall/failed。失败时 on-disk 队列不变。
451
+ """
452
+ try:
453
+ with self._get_conn() as conn:
454
+ conn.execute('DELETE FROM queue WHERE uid = ?', (uid,))
455
+ except Exception as e:
456
+ logger.critical(f"Failed to commit skip for {uid} in {self.path.name}: {e}")
457
+ return False
458
+ return True
459
+
460
+ def commit_retry(self, popped_uid: str, requeued_job: Dict[str, Any], *, front: bool = False) -> bool:
461
+ """原子 delta:删除 popped_uid + 按 front 插入 requeued_job。不写 wall/DLQ。
462
+
463
+ popped_uid 已先删除,故同 uid 重插安全(用 INSERT 而非 REPLACE)。
464
+ 不用 INSERT OR IGNORE——若 requeued_job 的 uid 与队列中
465
+ 既有行冲突(本不该发生),静默丢弃会让 job 无声消失却返回 True;
466
+ 普通 INSERT 让真实冲突抛异常 → 返回 False → 走 crash-safe 路径。
467
+ 失败时 on-disk 队列不变(popped uid 仍在)。
468
+ """
469
+ try:
470
+ with self._get_conn() as conn:
471
+ # seq 分配(_seq_range)的读与随后的 INSERT 之间不能让并发方
472
+ # 插入提交:先取写锁再读(事务纪律详见 _get_conn docstring)。
473
+ conn.execute('BEGIN IMMEDIATE')
474
+ conn.execute('DELETE FROM queue WHERE uid = ?', (popped_uid,))
475
+ seqs = self._seq_range(conn, 1, front=front)
476
+ conn.execute('INSERT INTO queue (uid, seq, job_data) VALUES (?, ?, ?)',
477
+ (uid_from_job_dict(requeued_job), seqs[0],
478
+ dumps(requeued_job)))
479
+ except Exception as e:
480
+ logger.critical(f"Failed to commit retry for {popped_uid} in {self.path.name}: {e}")
481
+ return False
482
+ return True
483
+
484
+ def commit_bulk_failure(self, uids_metas: List[Tuple[str, dict]]) -> bool:
485
+ """原子 delta:批量写 DLQ + 批量删除这些 uid + 批量清理 wall 旧记录。失败时 on-disk 队列不变。
486
+
487
+ 删除是按 uid 精准删除,剩余条目保持原 seq 顺序。
488
+ 写盘经 ``_write_dlq_row`` 单一出口(_attempt 计数路径收敛,
489
+ 全部写入路径保留失败历史)。
490
+
491
+ 与 commit_job_failure 对称,同一事务内批量
492
+ ``DELETE FROM wall``——rerun 任务被级联/死锁批量 DLQ 时,wall 旧
493
+ 成功记录作废(最终状态唯一),避免磁盘 wall∩failed 并存。
494
+ """
495
+ try:
496
+ with self._get_conn() as conn:
497
+ # _attempt 读-递增-写必须持写事务串行化(详见 _get_conn)。
498
+ conn.execute('BEGIN IMMEDIATE')
499
+ for uid, meta in uids_metas:
500
+ self._write_dlq_row(conn, uid, meta)
501
+ conn.executemany('DELETE FROM queue WHERE uid = ?',
502
+ [(uid,) for uid, _ in uids_metas])
503
+ conn.executemany('DELETE FROM wall WHERE uid = ?',
504
+ [(uid,) for uid, _ in uids_metas])
505
+ except Exception as e:
506
+ logger.critical(f"Failed to commit bulk failure in {self.path.name}: {e}")
507
+ return False
508
+ return True
509
+
510
+ def delete_failed(self, uids: List[str]) -> int:
511
+ """从 DLQ 批量删除指定 uid(clear_dlq/clear_history 的后端)。"""
512
+ if not uids:
513
+ return 0
514
+ try:
515
+ with self._get_conn() as conn:
516
+ cur = conn.executemany(
517
+ 'DELETE FROM failed_dlq WHERE uid = ?', [(u,) for u in uids]
518
+ )
519
+ return cur.rowcount if cur.rowcount is not None else 0
520
+ except Exception as e:
521
+ logger.critical(f"Failed to delete failed entries in {self.path.name}: {e}")
522
+ raise
523
+
524
+ def delete_wall(self, uids: List[str]) -> int:
525
+ """从 wall 批量删除指定 uid(clear_history 的后端)。"""
526
+ if not uids:
527
+ return 0
528
+ try:
529
+ with self._get_conn() as conn:
530
+ cur = conn.executemany(
531
+ 'DELETE FROM wall WHERE uid = ?', [(u,) for u in uids]
532
+ )
533
+ return cur.rowcount if cur.rowcount is not None else 0
534
+ except Exception as e:
535
+ logger.critical(f"Failed to delete wall entries in {self.path.name}: {e}")
536
+ raise
537
+
538
+ def seed_wall(self, uids: List[str]) -> int:
539
+ """把 uid 批量写入 wall(meta 空 dict)——存档迁移标记「已处理」。
540
+
541
+ 幂等:已存在的 uid 被覆盖(meta 重置为空)。
542
+ """
543
+ if not uids:
544
+ return 0
545
+ try:
546
+ with self._get_conn() as conn:
547
+ cur = conn.executemany(
548
+ 'INSERT OR REPLACE INTO wall (uid, payload) VALUES (?, ?)',
549
+ [(u, dumps({})) for u in uids],
550
+ )
551
+ return cur.rowcount if cur.rowcount is not None else 0
552
+ except Exception as e:
553
+ logger.critical(f"Failed to seed wall in {self.path.name}: {e}")
554
+ raise
555
+
556
+ def seed_cursor(self, key: str, value: str) -> None:
557
+ """预填一个 cursor(UPSERT,幂等)。"""
558
+ if not isinstance(key, str) or not key:
559
+ raise TypeError(f"cursor key must be a non-empty str, got {key!r}")
560
+ if not isinstance(value, str):
561
+ raise TypeError(f"cursor value must be str, got {type(value).__name__}")
562
+ try:
563
+ with self._get_conn() as conn:
564
+ conn.execute(
565
+ 'INSERT OR REPLACE INTO cursors (key, value) VALUES (?, ?)',
566
+ (key, value),
567
+ )
568
+ except Exception as e:
569
+ logger.critical(f"Failed to seed cursor '{key}' in {self.path.name}: {e}")
570
+ raise
@@ -0,0 +1,6 @@
1
+ """生态扩展套件与参考实现目录(contrib)。
2
+
3
+ 通用任务编排与底层调度能力位于核心包中,不依赖本目录任何模块。
4
+ """
5
+
6
+ __all__ = []
@@ -0,0 +1,16 @@
1
+ """Engine subpackage for tasklite.
2
+
3
+ 执行机器模块(12 个):
4
+ - ``executor``: 子进程生命周期与 IPC 结果处理(落盘文件模型);
5
+ - ``scheduler``: 队列只读扫描,找下一个 runnable job;
6
+ - ``dispatch``: 派发预检关(去重/依赖/资源)与子进程派发;
7
+ - ``loop``: 事件驱动主循环(填池 → drain → 等待);
8
+ - ``completion``: 成功/retry/失败的事务性提交与内存 apply;
9
+ - ``failure``: 3-strike 崩溃契约 / 级联 / 死锁归因 / 宽限;
10
+ - ``recovery``: 崩溃恢复 / suspend 信号排空 / abort 分类消费;
11
+ - ``runtime``: ``RunContext``(一次 run 的运行态真相源);
12
+ - ``resource``: 资源抽象(限速/容量)与挂起语义;
13
+ - ``retry``: 退避计算与 rerun 策略判定(纯逻辑);
14
+ - ``deadlock``: 死锁细粒度归因拆分原语(纯逻辑);
15
+ - ``inflight``: in-flight job 的运行时条目(三机器共享数据类)。
16
+ """