Files
imagefind/backend/imagefind/jobs.py
T

874 lines
37 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from .database import Database, DatabaseTransientError, utcnow
logger = logging.getLogger(__name__)
JobHandler = Callable[[str, dict], Awaitable[None]]
JOB_LANES = {
"index_video": "ai",
"transcribe_audio": "ai",
"suggest_tags": "ai",
"install_models": "ai",
"prepare_ai_runtime": "ai",
"transfer_upload": "transfer",
"refresh_path": "transfer",
"aria2_download": "download",
"scan_source": "scan",
"backup_export": "transfer",
}
LANE_KINDS = {
lane: frozenset(kind for kind, value in JOB_LANES.items() if value == lane)
for lane in ("ai", "transfer", "download", "scan")
}
JOB_PRIORITIES = {
"prepare_ai_runtime": -10,
"install_models": -10,
"refresh_path": 10,
# A user-visible upload should start before passive source scans when a
# caller claims across lanes (for example during recovery or diagnostics).
"transfer_upload": 0,
"aria2_download": 10,
"scan_source": 10,
# Extract visual/OCR/face metadata before the much longer audio backlog so
# freshly uploaded videos become useful in the library promptly.
"index_video": 10,
"transcribe_audio": 20,
"suggest_tags": 40,
}
def job_lane(kind: str) -> str:
return JOB_LANES.get(kind, "ai")
class JobCancelled(RuntimeError):
pass
class JobRetry(RuntimeError):
def __init__(self, message: str, delay_seconds: float):
super().__init__(message)
self.delay_seconds = max(0.0, delay_seconds)
class JobQueue:
def __init__(self, db: Database, poll_seconds: float = 1.0, governor=None, events=None):
self.db = db
self.poll_seconds = poll_seconds
self.governor = governor
self.events = events
self.handlers: dict[str, JobHandler] = {}
self._stopping = asyncio.Event()
self.owner = f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
self._progress_guard = threading.Lock()
self._progress_state: dict[str, tuple[float, float]] = {}
self._pause_state: dict[str, tuple[str | None, float]] = {}
def register(self, kind: str, handler: JobHandler) -> None:
self.handlers[kind] = handler
@staticmethod
def _resources(payload: dict) -> list[tuple[str, str]]:
resources: list[tuple[str, str]] = []
for key, resource_type in (
("video_id", "video"),
("source_id", "source"),
("upload_id", "upload"),
("collection_id", "collection"),
("backup_id", "backup"),
):
value = payload.get(key)
if isinstance(value, str) and value:
resources.append((resource_type, value))
video_ids = payload.get("video_ids")
if isinstance(video_ids, list):
resources.extend(("video", value) for value in video_ids if isinstance(value, str) and value)
return list(dict.fromkeys(resources))
def enqueue(
self,
kind: str,
payload: dict,
*,
dedupe_key: str | None = None,
delay_seconds: float = 0,
priority: int | None = None,
connection=None,
) -> str:
job_id = str(uuid.uuid4())
run_after = datetime.now(UTC) + timedelta(seconds=delay_seconds)
resolved_priority = int(priority if priority is not None else JOB_PRIORITIES.get(kind, 50))
def insert(conn) -> str:
inserted = conn.execute(
"INSERT INTO jobs(id,kind,payload_json,dedupe_key,priority,run_after,created_at) "
"VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING",
(job_id, kind, json.dumps(payload), dedupe_key, resolved_priority, run_after.isoformat(), utcnow()),
).rowcount
if not inserted and dedupe_key:
row = conn.execute(
"SELECT id,status,priority FROM jobs WHERE dedupe_key=? AND status IN ('queued','running')",
(dedupe_key,),
).fetchone()
if row:
# A warm-cache follow-up can intentionally enqueue the same
# task at a higher priority. Preserve the running worker,
# but promote a queued duplicate instead of silently losing
# the caller's scheduling intent.
if row["status"] == "queued" and int(row["priority"]) > resolved_priority:
conn.execute(
"UPDATE jobs SET priority=? WHERE id=? AND status='queued' AND priority>?",
(resolved_priority, row["id"], resolved_priority),
)
return row["id"]
if not inserted:
raise sqlite3.IntegrityError("任务写入冲突,且未找到可复用的活动任务")
resources = self._resources(payload)
if resources:
conn.executemany(
"INSERT INTO job_resources(job_id,resource_type,resource_id) VALUES(?,?,?) "
"ON CONFLICT DO NOTHING",
((job_id, resource_type, resource_id) for resource_type, resource_id in resources),
)
return job_id
if connection is not None:
result = insert(connection)
else:
with self.db.transaction() as conn:
result = insert(conn)
if self.events is not None:
self.events.publish("jobs", job_id=result, action="queued", kind=kind)
return result
@staticmethod
def _public(row) -> dict:
item = dict(row)
item["lane"] = job_lane(item["kind"])
raw_diagnostics = item.pop("diagnostics_json", "{}")
try:
diagnostics = json.loads(raw_diagnostics or "{}")
except (TypeError, json.JSONDecodeError):
diagnostics = {}
item["inference_diagnostics"] = diagnostics if isinstance(diagnostics, dict) else {}
return item
def list(
self,
limit: int = 100,
*,
lane: str | None = None,
status: str | None = None,
) -> list[dict]:
filters: list[str] = []
parameters: list[object] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(kinds))
if status:
filters.append("status=?")
parameters.append(status)
where = f" WHERE {' AND '.join(filters)}" if filters else ""
with self.db.read() as conn:
rows = conn.execute(
"SELECT id,kind,status,progress,message,attempts,created_at,started_at,finished_at,error,"
f"heartbeat_at,pause_reason,cancel_requested,diagnostics_json FROM jobs{where} "
"ORDER BY created_at DESC LIMIT ?",
(*parameters, min(max(limit, 1), 500)),
).fetchall()
return [self._public(row) for row in rows]
def paginate(
self,
page: int = 1,
page_size: int = 10,
*,
lane: str | None = None,
status: str | None = None,
) -> dict:
page = max(1, page)
page_size = min(max(page_size, 1), 50)
filters: list[str] = []
parameters: list[str] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(kinds))
if status:
filters.append("status=?")
parameters.append(status)
where = f" WHERE {' AND '.join(filters)}" if filters else ""
with self.db.read() as conn:
total = int(conn.execute(f"SELECT count(*) FROM jobs{where}", parameters).fetchone()[0])
retryable_failed_count = len(self._retryable_failed_rows(conn, lane=lane))
pages = max(1, (total + page_size - 1) // page_size)
page = min(page, pages)
rows = conn.execute(
"SELECT id,kind,status,progress,message,attempts,created_at,started_at,finished_at,error,"
f"heartbeat_at,pause_reason,cancel_requested,diagnostics_json FROM jobs{where} "
"ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(*parameters, page_size, (page - 1) * page_size),
).fetchall()
return {
"items": [self._public(row) for row in rows],
"page": page,
"page_size": page_size,
"total": total,
"pages": pages,
"retryable_failed_count": retryable_failed_count,
}
def _retryable_failed_rows(self, conn, *, lane: str | None = None, lock: bool = False):
filters = ["j.status='failed'"]
parameters: list[object] = []
if lane:
kinds = LANE_KINDS[lane]
placeholders = ",".join("?" for _ in kinds)
filters.append(f"j.kind IN ({placeholders})")
parameters.extend(sorted(kinds))
filters.append(
"NOT EXISTS(SELECT 1 FROM jobs newer WHERE newer.id<>j.id AND "
"((j.dedupe_key IS NOT NULL AND newer.dedupe_key=j.dedupe_key) OR "
"(j.dedupe_key IS NULL AND newer.dedupe_key IS NULL AND newer.kind=j.kind "
"AND newer.payload_json=j.payload_json)) AND "
"(newer.status IN ('queued','running') OR newer.created_at>j.created_at))"
)
suffix = " FOR UPDATE OF j SKIP LOCKED" if lock else ""
rows = conn.execute(
"SELECT j.id,j.kind,j.payload_json,j.dedupe_key,j.priority,j.created_at FROM jobs j WHERE "
f"{' AND '.join(filters)} ORDER BY j.created_at,j.id{suffix}",
parameters,
).fetchall()
result = []
for row in rows:
if row["kind"] not in self.handlers:
continue
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
if isinstance(payload, dict):
result.append((row, payload))
return result
def retry_failed(self, *, lane: str | None = None) -> dict:
"""Retry the latest unresolved failure for each logical task."""
if lane is not None and lane not in LANE_KINDS:
raise ValueError("未知的后台任务通道")
retried_ids: list[str] = []
with self.db.transaction() as conn:
rows = self._retryable_failed_rows(conn, lane=lane, lock=True)
for row, payload in rows:
retried_ids.append(
self.enqueue(
row["kind"],
payload,
dedupe_key=row["dedupe_key"] or f"bulk-retry:{row['id']}",
priority=int(row["priority"]),
connection=conn,
)
)
return {
"lane": lane or "all",
"retried": len(retried_ids),
"skipped": 0,
"job_ids": retried_ids,
}
def retry(self, job_id: str) -> str:
"""Create a new queued attempt while preserving the failed job record."""
with self.db.read() as conn:
row = conn.execute(
"SELECT kind,payload_json,dedupe_key,status,priority FROM jobs WHERE id=?",
(job_id,),
).fetchone()
if row is None:
raise KeyError(job_id)
if row["status"] != "failed":
raise ValueError("只有失败的后台任务可以重试")
if row["kind"] not in self.handlers:
raise ValueError("该任务类型当前不可重试")
try:
payload = json.loads(row["payload_json"])
except (TypeError, json.JSONDecodeError) as exc:
raise ValueError("原任务参数损坏,无法重试") from exc
if not isinstance(payload, dict):
raise ValueError("原任务参数损坏,无法重试")
return self.enqueue(
row["kind"], payload, dedupe_key=row["dedupe_key"], priority=int(row["priority"])
)
def update(self, job_id: str, progress: float, message: str = "") -> None:
value = min(max(progress, 0), 1)
now = time.monotonic()
with self._progress_guard:
previous_at, previous_value = self._progress_state.get(job_id, (0.0, -1.0))
if value < 1 and now - previous_at < 2 and abs(value - previous_value) < 0.01:
return
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET progress=?,message=?,heartbeat_at=? WHERE id=?",
(value, message[:500], utcnow(), job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped progress update after a transient PostgreSQL conflict for job %s", job_id)
else:
with self._progress_guard:
self._progress_state[job_id] = (now, value)
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="progress", progress=value)
def set_diagnostics(self, job_id: str, diagnostics: dict) -> None:
"""Persist privacy-safe inference metadata for task and UI diagnostics."""
safe = {
str(key): value
for key, value in dict(diagnostics or {}).items()
if key
in {
"requested_device",
"actual_device",
"primary_backend",
"language_candidates",
"selected_language",
"fallback_scope",
"fallback_reason",
"gpu_quality_score",
"cpu_verification_quality",
}
}
payload = json.dumps(safe, ensure_ascii=False, separators=(",", ":"), default=str)
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET diagnostics_json=?,heartbeat_at=? WHERE id=?",
(payload, utcnow(), job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
# Diagnostics improve observability but are not part of the media
# result. A short PostgreSQL conflict must not turn an otherwise
# successful inference into a failed background job.
logger.warning("skipped diagnostics update after a transient PostgreSQL conflict for job %s", job_id)
def set_pause_reason(self, job_id: str, reason: str | None) -> None:
now = time.monotonic()
with self._progress_guard:
previous = self._pause_state.get(job_id)
if previous and previous[0] == reason and now - previous[1] < 15:
return
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET pause_reason=?,heartbeat_at=?,message=coalesce(?,message) WHERE id=?",
(reason, utcnow(), reason, job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped pause heartbeat after a transient PostgreSQL conflict for job %s", job_id)
else:
with self._progress_guard:
self._pause_state[job_id] = (reason, now)
def _clear_runtime_state(self, job_id: str) -> None:
with self._progress_guard:
self._progress_state.pop(job_id, None)
self._pause_state.pop(job_id, None)
async def _write_critical(self, operation):
"""Persist a job state transition without blocking the ASGI loop.
PostgreSQL can abort a transaction on a deadlock or serialization
conflict. Keep retrying the complete state transition on a worker
thread instead of killing a lane worker or blocking the ASGI loop.
"""
while True:
try:
return await asyncio.to_thread(
self.db.write_with_retry,
operation,
timeout_seconds=5,
)
except DatabaseTransientError:
if self._stopping.is_set():
return None
logger.warning("retrying a job state after a transient PostgreSQL conflict")
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
@staticmethod
def _requeue_transient_job(conn, job_id: str) -> float:
row = conn.execute(
"SELECT attempts,cancel_requested FROM jobs WHERE id=?",
(job_id,),
).fetchone()
if row is None:
return 0
if row["cancel_requested"]:
conn.execute(
"UPDATE jobs SET status='cancelled',lease_owner=NULL,pause_reason=NULL,"
"finished_at=?,heartbeat_at=?,message='已取消' WHERE id=?",
(utcnow(), utcnow(), job_id),
)
return 0
attempts = max(1, int(row["attempts"] or 1))
delay = float(min(30, 5 * (2 ** min(attempts - 1, 3))))
run_after = datetime.now(UTC) + timedelta(seconds=delay)
conn.execute(
"UPDATE jobs SET status='queued',run_after=?,started_at=NULL,finished_at=NULL,error=NULL,"
"lease_owner=NULL,heartbeat_at=?,pause_reason=NULL,"
"message='数据库事务冲突,已自动恢复,稍后重试' WHERE id=?",
(run_after.isoformat(), utcnow(), job_id),
)
return delay
@staticmethod
def _finish_failed(conn, job_id: str, error: str) -> None:
"""Finish a worker failure without overriding a concurrent cancellation.
Video deletion deliberately removes the database row and remote object
immediately after requesting cancellation. A running media handler can
therefore observe ``video missing`` before reaching its next checkpoint.
Resolve the final state atomically so that the already-committed cancel
request wins over that expected teardown error.
"""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'failed' END,"
"error=CASE WHEN cancel_requested=1 THEN NULL ELSE ? END,finished_at=?,pause_reason=NULL,"
"lease_owner=NULL,heartbeat_at=?,message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE message END "
"WHERE id=?",
(error, now, now, job_id),
)
@staticmethod
def _finish_retry(conn, job_id: str, run_after: str, message: str) -> None:
"""Requeue a retry unless a concurrent caller has cancelled the job."""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,"
"run_after=?,started_at=NULL,"
"finished_at=CASE WHEN cancel_requested=1 THEN ? ELSE NULL END,error=NULL,lease_owner=NULL,"
"heartbeat_at=?,pause_reason=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE ? END WHERE id=?",
(run_after, now, now, message, job_id),
)
@staticmethod
def _finish_completed(conn, job_id: str) -> None:
"""Commit success unless cancellation was requested before finalization."""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'completed' END,"
"progress=CASE WHEN cancel_requested=1 THEN progress ELSE 1 END,finished_at=?,pause_reason=NULL,"
"lease_owner=NULL,heartbeat_at=?,error=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE message END WHERE id=?",
(now, now, job_id),
)
@staticmethod
def _finish_cancelled(conn, job_id: str) -> None:
"""Finish a cancellation unless its request was concurrently rolled back.
Deletion compensation can clear ``cancel_requested`` after a running
worker has already left its checkpoint. In that race the payload is
still valid again, so return the job to the queue instead of committing
a stale cancelled state after ``restore_cancelled`` has completed.
"""
now = utcnow()
conn.execute(
"UPDATE jobs SET status=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,"
"started_at=CASE WHEN cancel_requested=1 THEN started_at ELSE NULL END,"
"finished_at=CASE WHEN cancel_requested=1 THEN ? ELSE NULL END,lease_owner=NULL,"
"heartbeat_at=?,pause_reason=NULL,error=NULL,"
"message=CASE WHEN cancel_requested=1 THEN '已取消' ELSE '取消已回滚,任务重新排队' END "
"WHERE id=?",
(now, now, job_id),
)
def _job_state(self, job_id: str):
with self.db.read() as conn:
return conn.execute("SELECT kind,status,cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
def _cancel_requested(self, job_id: str) -> bool:
row = self._job_state(job_id)
# Direct service-level maintenance/tests may use an ephemeral progress
# id. A missing row is not a cancellation signal; real worker jobs are
# retained for their full lifecycle.
return bool(row and row["cancel_requested"])
def checkpoint(self, job_id: str, message: str | None = None, *, persist: bool = True) -> None:
state = self._job_state(job_id)
if state and state["cancel_requested"]:
raise JobCancelled("任务已取消")
def paused(reason: str) -> None:
self.set_pause_reason(job_id, reason)
def resumed() -> None:
self.set_pause_reason(job_id, None)
# Resource pressure must only park work that has actually been leased
# by the background worker. Direct maintenance calls and service-level
# tests can legitimately use a queued or ephemeral progress id and
# must not wait forever merely because their temporary filesystem is
# smaller than the NAS reserve policy.
if self.governor is not None and state is not None and state["status"] == "running":
if self._should_yield_to_model_maintenance(job_id):
raise JobCancelled("模型安装优先,音频识别已让路")
self.governor.wait_sync(
lane=job_lane(state["kind"]),
paused=paused,
resumed=resumed,
cancelled=lambda: (
self._cancel_requested(job_id)
or self._stopping.is_set()
or self._should_yield_to_model_maintenance(job_id)
),
)
if self._should_yield_to_model_maintenance(job_id):
raise JobCancelled("模型安装优先,音频识别已让路")
if self._cancel_requested(job_id):
raise JobCancelled("任务已取消")
if persist:
try:
self.db.write_with_retry(
lambda conn: conn.execute(
"UPDATE jobs SET pause_reason=NULL,heartbeat_at=?,message=coalesce(?,message) WHERE id=?",
(utcnow(), message, job_id),
),
timeout_seconds=5,
)
except DatabaseTransientError:
logger.warning("skipped checkpoint heartbeat after a transient conflict for job %s", job_id)
def request_cancel(self, job_id: str) -> None:
with self.db.transaction() as conn:
row = conn.execute("SELECT kind,status FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise KeyError(job_id)
if row["kind"] == "backup_export" and row["status"] == "running":
raise ValueError("备份已经开始生成,不能中途取消")
if row["status"] == "queued":
now = utcnow()
conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,message='已取消' WHERE id=?",
(now, job_id),
)
if row["kind"] == "backup_export":
conn.execute(
"UPDATE backup_exports SET status='failed',secret_blob=NULL,error='备份任务已取消',"
"finished_at=? WHERE id IN (SELECT resource_id FROM job_resources "
"WHERE job_id=? AND resource_type='backup')",
(now, job_id),
)
elif row["status"] == "running":
conn.execute("UPDATE jobs SET cancel_requested=1,message='正在安全停止' WHERE id=?", (job_id,))
else:
raise ValueError("该任务已经结束")
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="cancel")
def cancel_by_kind(self, kind: str) -> list[str]:
with self.db.transaction() as conn:
rows = conn.execute(
"SELECT id,status FROM jobs WHERE kind=? AND status IN ('queued','running') ORDER BY created_at",
(kind,),
).fetchall()
cancelled: list[str] = []
for row in rows:
if row["status"] == "queued":
conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,"
"message='模型安装优先,已取消' WHERE id=?",
(utcnow(), row["id"]),
)
else:
conn.execute(
"UPDATE jobs SET cancel_requested=1,message='模型安装优先,正在停止' WHERE id=?",
(row["id"],),
)
cancelled.append(row["id"])
return cancelled
def has_queued_model_maintenance(self) -> bool:
with self.db.read() as conn:
return bool(
conn.execute(
"SELECT 1 FROM jobs WHERE kind IN ('install_models','prepare_ai_runtime') "
"AND status='queued' LIMIT 1"
).fetchone()
)
def _should_yield_to_model_maintenance(self, job_id: str) -> bool:
with self.db.read() as conn:
row = conn.execute("SELECT kind,status FROM jobs WHERE id=?", (job_id,)).fetchone()
return bool(row and row["kind"] == "transcribe_audio" and row["status"] == "running") and (
self.has_queued_model_maintenance()
)
def cancel_for_video(
self,
video_id: str,
*,
exclude_job_ids: set[str] | None = None,
connection=None,
) -> list[str]:
excluded = exclude_job_ids or set()
def cancel(conn) -> list[str]:
rows = conn.execute(
"SELECT DISTINCT j.id,j.status FROM jobs j JOIN job_resources r ON r.job_id=j.id "
"WHERE r.resource_type='video' AND r.resource_id=? AND j.status IN ('queued','running')",
(video_id,),
).fetchall()
cancelled: list[str] = []
now = utcnow()
for row in rows:
if row["id"] in excluded:
continue
if row["status"] == "queued":
changed = conn.execute(
"UPDATE jobs SET status='cancelled',cancel_requested=1,finished_at=?,message='已取消' "
"WHERE id=? AND status='queued'",
(now, row["id"]),
).rowcount
else:
changed = conn.execute(
"UPDATE jobs SET cancel_requested=1,message='正在安全停止' "
"WHERE id=? AND status='running'",
(row["id"],),
).rowcount
if changed:
cancelled.append(row["id"])
return cancelled
if connection is not None:
return cancel(connection)
with self.db.transaction() as conn:
return cancel(conn)
def restore_cancelled(self, job_ids: list[str]) -> int:
"""Undo a deletion-time cancellation after the delete is compensated.
Queued jobs are returned to the queue with their original payload and
priority. A running worker may already have observed the cancellation,
so both the still-running and just-cancelled states are handled.
Completed/failed jobs are intentionally left as historical truth.
"""
identifiers = list(dict.fromkeys(str(value) for value in job_ids if value))
if not identifiers:
return 0
placeholders = ",".join("?" for _ in identifiers)
with self.db.transaction() as conn:
restored = conn.execute(
f"UPDATE jobs SET status=CASE WHEN status='cancelled' THEN 'queued' ELSE status END,"
"cancel_requested=0,finished_at=CASE WHEN status='cancelled' THEN NULL ELSE finished_at END,"
"lease_owner=CASE WHEN status='cancelled' THEN NULL ELSE lease_owner END,"
"pause_reason=NULL,error=CASE WHEN status='cancelled' THEN NULL ELSE error END,"
"message=CASE WHEN status='cancelled' THEN '删除已回滚,任务重新排队' ELSE message END "
f"WHERE id IN ({placeholders}) AND status IN ('queued','running','cancelled')",
identifiers,
).rowcount
return int(restored)
def cleanup_history(self, *, completed_days: int = 30, failed_days: int = 90) -> dict[str, int]:
completed_cutoff = (datetime.now(UTC) - timedelta(days=max(1, completed_days))).isoformat()
failed_cutoff = (datetime.now(UTC) - timedelta(days=max(1, failed_days))).isoformat()
with self.db.transaction() as conn:
completed = conn.execute(
"DELETE FROM jobs WHERE status IN ('completed','cancelled') "
"AND coalesce(finished_at,created_at)<?",
(completed_cutoff,),
).rowcount
failed = conn.execute(
"DELETE FROM jobs WHERE status='failed' AND coalesce(finished_at,created_at)<?",
(failed_cutoff,),
).rowcount
return {"completed_cancelled": int(completed), "failed": int(failed)}
def recover_stale(self, stale_seconds: int = 300) -> int:
cutoff = (datetime.now(UTC) - timedelta(seconds=stale_seconds)).isoformat()
with self.db.transaction() as conn:
return conn.execute(
"UPDATE jobs SET status='queued',started_at=NULL,lease_owner=NULL,heartbeat_at=NULL,"
"pause_reason=NULL,cancel_requested=0,message='应用重启,等待继续执行' "
"WHERE status='running' AND (lease_owner IS NULL OR lease_owner NOT LIKE ? "
"OR heartbeat_at IS NULL OR heartbeat_at<?)",
(f"{self.owner}:%", cutoff),
).rowcount
def _claim(
self,
*,
lane: str | None = None,
allowed_kinds: frozenset[str] | None = None,
excluded_kinds: frozenset[str] = frozenset(),
) -> tuple[str, str, dict] | None:
lane_name = lane or "ai"
if lane and allowed_kinds is None:
allowed_kinds = LANE_KINDS[lane]
if self.governor is not None and self.governor.pressure_reason(lane=lane_name):
return None
filters = ["status='queued'", "cancel_requested=0", "run_after<=?"]
parameters: list[str] = [utcnow()]
if allowed_kinds:
placeholders = ",".join("?" for _ in allowed_kinds)
filters.append(f"kind IN ({placeholders})")
parameters.extend(sorted(allowed_kinds))
if excluded_kinds:
placeholders = ",".join("?" for _ in excluded_kinds)
filters.append(f"kind NOT IN ({placeholders})")
parameters.extend(sorted(excluded_kinds))
with self.db.transaction() as conn:
row = conn.execute(
f"SELECT id,kind,payload_json FROM jobs WHERE {' AND '.join(filters)} "
"ORDER BY priority,created_at LIMIT 1 FOR UPDATE SKIP LOCKED",
parameters,
).fetchone()
if not row:
return None
changed = conn.execute(
"UPDATE jobs SET status='running',started_at=?,attempts=attempts+1,lease_owner=?,"
"heartbeat_at=?,pause_reason=NULL WHERE id=? AND status='queued' AND cancel_requested=0",
(utcnow(), f"{self.owner}:{lane_name}", utcnow(), row["id"]),
).rowcount
if not changed:
return None
return row["id"], row["kind"], json.loads(row["payload_json"])
async def _execute_claimed(self, job_id: str, kind: str, payload: dict) -> None:
handler = self.handlers.get(kind)
if handler is None:
error = f"没有任务处理器:{kind}"
await self._write_critical(
lambda conn, error=error, job_id=job_id: self._finish_failed(conn, job_id, error)
)
self._clear_runtime_state(job_id)
return
try:
await asyncio.to_thread(self.checkpoint, job_id)
await handler(job_id, payload)
except JobCancelled:
await self._write_critical(
lambda conn, job_id=job_id: self._finish_cancelled(conn, job_id)
)
except JobRetry as exc:
run_after = datetime.now(UTC) + timedelta(seconds=exc.delay_seconds)
retry_message = str(exc)[:500]
await self._write_critical(
lambda conn, run_after=run_after, retry_message=retry_message, job_id=job_id: self._finish_retry(
conn, job_id, run_after.isoformat(), retry_message
)
)
except DatabaseTransientError:
delay = await self._write_critical(
lambda conn, job_id=job_id: self._requeue_transient_job(conn, job_id)
)
logger.warning(
"job %s hit a transient PostgreSQL conflict and was requeued for %.0fs",
job_id,
delay or 0,
)
except asyncio.CancelledError:
def release(conn, job_id=job_id):
state = conn.execute("SELECT cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
if state and state["cancel_requested"]:
conn.execute(
"UPDATE jobs SET status='cancelled',lease_owner=NULL,pause_reason=NULL,"
"finished_at=?,message='已取消' WHERE id=?",
(utcnow(), job_id),
)
else:
conn.execute(
"UPDATE jobs SET status='queued',started_at=NULL,lease_owner=NULL,heartbeat_at=NULL,"
"pause_reason=NULL WHERE id=?",
(job_id,),
)
await self._write_critical(release)
raise
except Exception as exc:
logger.exception("job %s failed", job_id)
# Full exception details belong in the service log. API clients
# receive a short, actionable message without leaking paths,
# credentials or an implementation traceback.
error = str(exc).strip()[:1000] or "任务执行失败,请查看服务日志"
await self._write_critical(
lambda conn, error=error, job_id=job_id: self._finish_failed(conn, job_id, error)
)
else:
await self._write_critical(
lambda conn, job_id=job_id: self._finish_completed(conn, job_id)
)
finally:
self._clear_runtime_state(job_id)
if self.events is not None:
self.events.publish("jobs", job_id=job_id, action="finished")
async def run(
self,
*,
lane: str | None = None,
allowed_kinds: frozenset[str] | None = None,
excluded_kinds: frozenset[str] = frozenset(),
) -> None:
if lane and allowed_kinds is None:
allowed_kinds = LANE_KINDS[lane]
while not self._stopping.is_set():
try:
claimed = await asyncio.to_thread(
self._claim,
lane=lane,
allowed_kinds=allowed_kinds,
excluded_kinds=excluded_kinds,
)
except DatabaseTransientError:
logger.warning("job lane %s is retrying after a PostgreSQL conflict", lane or "ai")
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
continue
if not claimed:
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_seconds)
except TimeoutError:
pass
continue
await self._execute_claimed(*claimed)
def stop(self) -> None:
self._stopping.set()