Files
talkscore-asr/app/store.py
T
Vladimir BryzgalovandClaude Opus 5 be837bab0b Параллельная обработка вместо широких потоков
Замеры: распознавание даёт x52 на 4 потоках против x13 на 16, разделение
говорящих x33 против x8. Дальше четырёх потоков синхронизация съедает весь
выигрыш, поэтому ядра занимаются несколькими задачами сразу.

По умолчанию 4 потока на задачу и до 4 задач параллельно. Захват задачи
из очереди стал атомарным - без этого два воркера брали одну и ту же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:33:54 +05:00

154 lines
6.3 KiB
Python

"""Хранилище задач на SQLite.
SQLite, а не память: задачи должны переживать перезапуск сервиса, иначе
после обновления или падения клиент навсегда останется с id, по которому
ничего нет.
"""
import json
import sqlite3
import threading
import time
import uuid
from pathlib import Path
__all__ = ["JobStore", "JobStatus"]
class JobStatus:
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
duration_sec REAL NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL,
started_at REAL,
finished_at REAL,
result TEXT,
error TEXT,
options TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, created_at);
"""
class JobStore:
def __init__(self, path: Path):
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
# Один процесс, несколько потоков (веб + воркер), поэтому check_same_thread
# выключен, а доступ сериализуется собственным замком.
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._recover_stuck()
def _recover_stuck(self) -> None:
"""После аварийного перезапуска задачи «в работе» никто не доделает."""
with self._lock:
self._conn.execute(
"UPDATE jobs SET status=?, error=? WHERE status=?",
(JobStatus.FAILED, "сервис был перезапущен во время обработки", JobStatus.RUNNING),
)
self._conn.commit()
def create(self, filename: str, duration_sec: float, options: dict | None = None) -> str:
job_id = uuid.uuid4().hex
with self._lock:
self._conn.execute(
"INSERT INTO jobs (id, filename, duration_sec, status, created_at, options)"
" VALUES (?,?,?,?,?,?)",
(job_id, filename, duration_sec, JobStatus.QUEUED, time.time(),
json.dumps(options or {}, ensure_ascii=False)),
)
self._conn.commit()
return job_id
def get(self, job_id: str) -> dict | None:
with self._lock:
row = self._conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
return None
job = dict(row)
job["result"] = json.loads(job["result"]) if job["result"] else None
job["options"] = json.loads(job["options"]) if job["options"] else {}
return job
def claim_next(self) -> str | None:
"""Забирает самую старую задачу из очереди и сразу помечает её в работе.
Выборка и пометка выполняются одним оператором под общим замком: иначе
два воркера успевают увидеть одну и ту же задачу и берут её оба.
"""
with self._lock:
row = self._conn.execute(
"UPDATE jobs SET status=?, started_at=? WHERE id = ("
" SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1"
") RETURNING id",
(JobStatus.RUNNING, time.time(), JobStatus.QUEUED),
).fetchone()
self._conn.commit()
return row["id"] if row else None
def queue_position(self, job_id: str) -> int:
"""Сколько задач будет обработано до этой.
Считается и та, что уже выполняется: для клиента она тоже стоит впереди,
и ждать ему придётся вместе с ней. 0 означает «следующая на обработку».
"""
with self._lock:
row = self._conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE status IN (?,?) AND created_at <"
" (SELECT created_at FROM jobs WHERE id=?)",
(JobStatus.QUEUED, JobStatus.RUNNING, job_id),
).fetchone()
return row["n"] if row else 0
def mark_running(self, job_id: str) -> None:
self._update(job_id, status=JobStatus.RUNNING, started_at=time.time())
def mark_done(self, job_id: str, result: dict) -> None:
self._update(job_id, status=JobStatus.DONE, finished_at=time.time(),
result=json.dumps(result, ensure_ascii=False))
def mark_failed(self, job_id: str, error: str) -> None:
self._update(job_id, status=JobStatus.FAILED, finished_at=time.time(), error=error)
def _update(self, job_id: str, **fields) -> None:
assigns = ", ".join(f"{k}=?" for k in fields)
with self._lock:
self._conn.execute(f"UPDATE jobs SET {assigns} WHERE id=?",
(*fields.values(), job_id))
self._conn.commit()
def delete(self, job_id: str) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM jobs WHERE id=?", (job_id,))
self._conn.commit()
return cur.rowcount > 0
def cleanup(self, max_age_hours: float) -> int:
"""Удаляет завершённые задачи старше указанного возраста."""
cutoff = time.time() - max_age_hours * 3600
with self._lock:
cur = self._conn.execute(
"DELETE FROM jobs WHERE status IN (?,?) AND COALESCE(finished_at, created_at) <= ?",
(JobStatus.DONE, JobStatus.FAILED, cutoff),
)
self._conn.commit()
return cur.rowcount
def stats(self) -> dict:
with self._lock:
rows = self._conn.execute(
"SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").fetchall()
return {r["status"]: r["n"] for r in rows}