753 lines
30 KiB
Python
753 lines
30 KiB
Python
"""Persistent state: video records, state transitions, folder cache, cursors.
|
|
|
|
Two backends share one SQL dialect subset:
|
|
|
|
* PostgreSQL (``DATABASE_URL``) — durable production storage, e.g. Heroku Postgres.
|
|
* SQLite (``DATABASE_PATH``) — local development. On Heroku the filesystem is
|
|
ephemeral, so SQLite there would lose all history on every dyno restart; the
|
|
configuration layer refuses that unless explicitly overridden.
|
|
|
|
SQL is written with ``?`` placeholders and translated to ``%s`` for psycopg.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from models import (
|
|
IN_PROGRESS_STATES,
|
|
MediaInfo,
|
|
ParsedCaption,
|
|
SourceMessage,
|
|
State,
|
|
TaskStatus,
|
|
UploadTask,
|
|
VideoRecord,
|
|
can_transition,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Each entry is one schema version; {id_pk} differs between backends.
|
|
MIGRATIONS: list[list[str]] = [
|
|
[
|
|
"""CREATE TABLE IF NOT EXISTS videos (
|
|
channel_id BIGINT NOT NULL,
|
|
message_id BIGINT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
caption TEXT,
|
|
indexes TEXT NOT NULL DEFAULT '[]',
|
|
title TEXT,
|
|
topic TEXT,
|
|
batch TEXT,
|
|
extracted_by TEXT,
|
|
warnings TEXT NOT NULL DEFAULT '[]',
|
|
media_kind TEXT,
|
|
media_id BIGINT,
|
|
mime_type TEXT,
|
|
original_filename TEXT,
|
|
expected_size BIGINT,
|
|
message_date TEXT,
|
|
sequence_no BIGINT,
|
|
local_path TEXT,
|
|
local_size BIGINT,
|
|
sha256 TEXT,
|
|
md5 TEXT,
|
|
final_name TEXT,
|
|
drive_folder_id TEXT,
|
|
drive_file_id TEXT,
|
|
upload_session_uri TEXT,
|
|
upload_session_created_at TEXT,
|
|
download_status TEXT,
|
|
upload_status TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
error_kind TEXT,
|
|
next_retry_at TEXT,
|
|
skip_reason TEXT,
|
|
discovered_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
download_started_at TEXT,
|
|
downloaded_at TEXT,
|
|
upload_started_at TEXT,
|
|
completed_at TEXT,
|
|
PRIMARY KEY (channel_id, message_id)
|
|
)""",
|
|
"CREATE INDEX IF NOT EXISTS idx_videos_state ON videos(state)",
|
|
"CREATE INDEX IF NOT EXISTS idx_videos_media ON videos(media_id)",
|
|
"""CREATE TABLE IF NOT EXISTS folders (
|
|
parent_id TEXT NOT NULL,
|
|
name_key TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
folder_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (parent_id, name_key)
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS transitions (
|
|
id {id_pk},
|
|
channel_id BIGINT NOT NULL,
|
|
message_id BIGINT NOT NULL,
|
|
from_state TEXT,
|
|
to_state TEXT NOT NULL,
|
|
at TEXT NOT NULL,
|
|
note TEXT
|
|
)""",
|
|
"CREATE INDEX IF NOT EXISTS idx_transitions_msg ON transitions(channel_id, message_id)",
|
|
],
|
|
# v2: interactive /upload tasks
|
|
[
|
|
"ALTER TABLE videos ADD COLUMN task_id BIGINT",
|
|
"CREATE INDEX IF NOT EXISTS idx_videos_task ON videos(task_id)",
|
|
"""CREATE TABLE IF NOT EXISTS upload_tasks (
|
|
id {id_pk},
|
|
admin_user_id BIGINT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
channel_ref TEXT,
|
|
channel_id BIGINT,
|
|
channel_title TEXT,
|
|
start_message_id BIGINT,
|
|
end_message_id BIGINT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
confirmed_at TEXT,
|
|
started_at TEXT,
|
|
scanned_at TEXT,
|
|
completed_at TEXT,
|
|
current_message_id BIGINT,
|
|
examined_count INTEGER NOT NULL DEFAULT 0,
|
|
eligible_count INTEGER NOT NULL DEFAULT 0,
|
|
uploaded_count INTEGER NOT NULL DEFAULT 0,
|
|
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
|
non_video_count INTEGER NOT NULL DEFAULT 0,
|
|
skipped_count INTEGER NOT NULL DEFAULT 0,
|
|
missing_count INTEGER NOT NULL DEFAULT 0,
|
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
deleted_files_count INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT
|
|
)""",
|
|
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON upload_tasks(status)",
|
|
"CREATE INDEX IF NOT EXISTS idx_tasks_admin ON upload_tasks(admin_user_id)",
|
|
"""CREATE TABLE IF NOT EXISTS task_items (
|
|
task_id BIGINT NOT NULL,
|
|
channel_id BIGINT NOT NULL,
|
|
message_id BIGINT NOT NULL,
|
|
result TEXT NOT NULL,
|
|
folder TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (task_id, channel_id, message_id)
|
|
)""",
|
|
],
|
|
]
|
|
|
|
_COLUMNS = frozenset(VideoRecord.column_names())
|
|
_MUTABLE = _COLUMNS - {"channel_id", "message_id", "state", "discovered_at"}
|
|
|
|
# download_status / upload_status mirrored from the state for the metadata record.
|
|
_STAGE_STATUS = {
|
|
State.PENDING: ("pending", "waiting"),
|
|
State.DOWNLOADING: ("in_progress", "waiting"),
|
|
State.DOWNLOADED: ("done", "waiting"),
|
|
State.UPLOADING: ("done", "in_progress"),
|
|
State.COMPLETED: ("done", "done"),
|
|
State.SKIPPED: ("skipped", "skipped"),
|
|
}
|
|
|
|
_TASK_MUTABLE = frozenset(UploadTask.column_names()) - {"id", "admin_user_id", "created_at"}
|
|
_TASK_COUNTERS = frozenset({"deleted_files_count"})
|
|
TERMINAL_VIDEO_STATES = ("COMPLETED", "SKIPPED", "FAILED")
|
|
|
|
_UPSERT_KV = "INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value"
|
|
|
|
|
|
class InvalidTransition(RuntimeError):
|
|
pass
|
|
|
|
|
|
class DatabaseError(RuntimeError):
|
|
"""Backend-neutral wrapper so callers need not import sqlite3/psycopg."""
|
|
|
|
|
|
class InstanceLockError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def iso(dt: datetime) -> str:
|
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def retry_time(seconds: float) -> str:
|
|
return iso(utcnow() + timedelta(seconds=seconds))
|
|
|
|
|
|
# -- backends --------------------------------------------------------------------------
|
|
|
|
|
|
class _SQLiteBackend:
|
|
name = "sqlite"
|
|
id_pk = "INTEGER PRIMARY KEY AUTOINCREMENT"
|
|
for_update = ""
|
|
errors: tuple[type[BaseException], ...] = (sqlite3.Error,)
|
|
|
|
def __init__(self, path: Path):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.path = path
|
|
self.conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False, timeout=30)
|
|
self.conn.row_factory = sqlite3.Row
|
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
self.conn.execute("PRAGMA synchronous=FULL")
|
|
|
|
def execute(self, sql: str, params: tuple = ()):
|
|
return self.conn.execute(sql, params)
|
|
|
|
@contextmanager
|
|
def transaction(self):
|
|
self.conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
yield
|
|
except BaseException:
|
|
self.conn.execute("ROLLBACK")
|
|
raise
|
|
else:
|
|
self.conn.execute("COMMIT")
|
|
|
|
def close(self) -> None:
|
|
self.conn.close()
|
|
|
|
def describe(self) -> str:
|
|
return f"SQLite ({self.path.name})"
|
|
|
|
def insert_returning_id(self, sql: str, params: tuple) -> int:
|
|
return self.conn.execute(sql, params).lastrowid
|
|
|
|
|
|
class _PostgresBackend:
|
|
name = "postgres"
|
|
id_pk = "BIGSERIAL PRIMARY KEY"
|
|
for_update = " FOR UPDATE"
|
|
|
|
def __init__(self, url: str):
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
self._psycopg = psycopg
|
|
self._dict_row = dict_row
|
|
self.url = url
|
|
self.errors = (psycopg.Error,)
|
|
self.conn = self._connect()
|
|
|
|
def _connect(self):
|
|
return self._psycopg.connect(self.url, autocommit=True, row_factory=self._dict_row, connect_timeout=15)
|
|
|
|
def _ensure_connected(self) -> None:
|
|
if self.conn.closed or self.conn.broken:
|
|
log.warning("PostgreSQL connection lost; reconnecting")
|
|
self.conn = self._connect()
|
|
|
|
def execute(self, sql: str, params: tuple = ()):
|
|
self._ensure_connected()
|
|
return self.conn.execute(sql.replace("?", "%s"), params)
|
|
|
|
@contextmanager
|
|
def transaction(self):
|
|
self._ensure_connected()
|
|
with self.conn.transaction():
|
|
yield
|
|
|
|
def close(self) -> None:
|
|
self.conn.close()
|
|
|
|
def describe(self) -> str:
|
|
return "PostgreSQL"
|
|
|
|
def insert_returning_id(self, sql: str, params: tuple) -> int:
|
|
return self.execute(sql + " RETURNING id", params).fetchone()["id"]
|
|
|
|
|
|
# -- database --------------------------------------------------------------------------
|
|
|
|
|
|
class Database:
|
|
def __init__(self, *, path: Path | str | None = None, url: str | None = None):
|
|
if url:
|
|
self._backend: Any = _PostgresBackend(url)
|
|
elif path:
|
|
self._backend = _SQLiteBackend(Path(path))
|
|
else:
|
|
raise ValueError("either a SQLite path or a PostgreSQL URL is required")
|
|
self.backend_name = self._backend.name
|
|
self.errors = self._backend.errors
|
|
self._lock = threading.RLock()
|
|
self._migrate()
|
|
|
|
@classmethod
|
|
def from_config(cls, config) -> "Database":
|
|
return cls(url=config.database_url) if config.database_url else cls(path=config.database_path)
|
|
|
|
def describe(self) -> str:
|
|
return self._backend.describe()
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
self._backend.close()
|
|
|
|
def _migrate(self) -> None:
|
|
with self._lock, self._backend.transaction():
|
|
self._backend.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)")
|
|
row = self._backend.execute("SELECT value FROM kv WHERE key='schema_version'").fetchone()
|
|
version = int(row["value"]) if row else 0
|
|
for number, statements in enumerate(MIGRATIONS, start=1):
|
|
if number <= version:
|
|
continue
|
|
for statement in statements:
|
|
self._backend.execute(statement.format(id_pk=self._backend.id_pk))
|
|
self._backend.execute(_UPSERT_KV, ("schema_version", str(number)))
|
|
log.info("Database schema migrated to version %d", number)
|
|
|
|
@contextmanager
|
|
def _tx(self) -> Iterator[Any]:
|
|
with self._lock, self._backend.transaction():
|
|
yield self._backend
|
|
|
|
def _query(self, sql: str, params: tuple = ()) -> list[Any]:
|
|
with self._lock:
|
|
return self._backend.execute(sql, params).fetchall()
|
|
|
|
# -- videos ---------------------------------------------------------------------
|
|
|
|
def insert_video(
|
|
self,
|
|
msg: SourceMessage,
|
|
parsed: ParsedCaption,
|
|
*,
|
|
state: State = State.PENDING,
|
|
skip_reason: str | None = None,
|
|
sequence_no: int | None = None,
|
|
task_id: int | None = None,
|
|
) -> bool:
|
|
"""Insert a newly discovered video. Returns False if it was already known."""
|
|
media: MediaInfo = msg.media # type: ignore[assignment]
|
|
now = iso(utcnow())
|
|
dl, ul = _STAGE_STATUS[state]
|
|
with self._tx() as db:
|
|
cur = db.execute(
|
|
"""INSERT INTO videos (
|
|
channel_id, message_id, state, caption, indexes, title, topic, batch, extracted_by,
|
|
warnings, media_kind, media_id, mime_type, original_filename, expected_size,
|
|
message_date, sequence_no, skip_reason, download_status, upload_status,
|
|
discovered_at, updated_at, task_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT (channel_id, message_id) DO NOTHING""",
|
|
(
|
|
msg.channel_id,
|
|
msg.message_id,
|
|
state.value,
|
|
msg.caption,
|
|
json.dumps(parsed.indexes, ensure_ascii=False),
|
|
parsed.title,
|
|
parsed.topic,
|
|
parsed.batch,
|
|
parsed.extracted_by,
|
|
json.dumps(parsed.warnings, ensure_ascii=False),
|
|
media.kind,
|
|
media.media_id,
|
|
media.mime_type,
|
|
media.file_name,
|
|
media.size,
|
|
iso(msg.date) if msg.date else None,
|
|
sequence_no,
|
|
skip_reason,
|
|
dl,
|
|
ul,
|
|
now,
|
|
now,
|
|
task_id,
|
|
),
|
|
)
|
|
inserted = cur.rowcount == 1
|
|
if inserted:
|
|
db.execute(
|
|
"INSERT INTO transitions (channel_id, message_id, from_state, to_state, at, note) VALUES (?,?,?,?,?,?)",
|
|
(msg.channel_id, msg.message_id, None, state.value, now, skip_reason or "discovered"),
|
|
)
|
|
return inserted
|
|
|
|
def exists(self, channel_id: int, message_id: int) -> bool:
|
|
return bool(self._query("SELECT 1 FROM videos WHERE channel_id=? AND message_id=?", (channel_id, message_id)))
|
|
|
|
def get(self, key: tuple[int, int]) -> VideoRecord | None:
|
|
rows = self._query("SELECT * FROM videos WHERE channel_id=? AND message_id=?", tuple(key))
|
|
return VideoRecord.from_row(rows[0]) if rows else None
|
|
|
|
def find_by_message_id(self, message_id: int) -> list[VideoRecord]:
|
|
rows = self._query("SELECT * FROM videos WHERE message_id=? ORDER BY channel_id", (message_id,))
|
|
return [VideoRecord.from_row(r) for r in rows]
|
|
|
|
def find_by_media_id(self, media_id: int) -> list[VideoRecord]:
|
|
rows = self._query("SELECT * FROM videos WHERE media_id=? ORDER BY message_id", (media_id,))
|
|
return [VideoRecord.from_row(r) for r in rows]
|
|
|
|
def transition(self, key: tuple[int, int], new_state: State, *, note: str | None = None, **fields) -> VideoRecord:
|
|
"""Atomically move an item to ``new_state`` (validated) and update fields."""
|
|
bad = set(fields) - _MUTABLE
|
|
if bad:
|
|
raise ValueError(f"unknown or immutable fields: {sorted(bad)}")
|
|
now = iso(utcnow())
|
|
with self._tx() as db:
|
|
row = db.execute(
|
|
f"SELECT state FROM videos WHERE channel_id=? AND message_id=?{self._backend.for_update}", tuple(key)
|
|
).fetchone()
|
|
if row is None:
|
|
raise KeyError(f"unknown video {key}")
|
|
current = State(row["state"])
|
|
if not can_transition(current, new_state):
|
|
raise InvalidTransition(f"{key}: {current} -> {new_state} is not allowed")
|
|
fields.setdefault("updated_at", now)
|
|
if new_state in _STAGE_STATUS:
|
|
fields.setdefault("download_status", _STAGE_STATUS[new_state][0])
|
|
fields.setdefault("upload_status", _STAGE_STATUS[new_state][1])
|
|
else:
|
|
failed_stage = "upload_status" if current in (State.DOWNLOADED, State.UPLOADING) else "download_status"
|
|
fields.setdefault(failed_stage, "failed" if new_state == State.FAILED else "retry_wait")
|
|
self._update(db, key, state=new_state.value, **fields)
|
|
db.execute(
|
|
"INSERT INTO transitions (channel_id, message_id, from_state, to_state, at, note) VALUES (?,?,?,?,?,?)",
|
|
(key[0], key[1], current.value, new_state.value, now, note),
|
|
)
|
|
return self.get(key) # type: ignore[return-value]
|
|
|
|
def update_fields(self, key: tuple[int, int], **fields) -> None:
|
|
bad = set(fields) - _MUTABLE
|
|
if bad:
|
|
raise ValueError(f"unknown or immutable fields: {sorted(bad)}")
|
|
fields.setdefault("updated_at", iso(utcnow()))
|
|
with self._tx() as db:
|
|
self._update(db, key, **fields)
|
|
|
|
@staticmethod
|
|
def _update(db, key: tuple[int, int], **fields) -> None:
|
|
for name in VideoRecord.JSON_FIELDS:
|
|
if name in fields and not isinstance(fields[name], str):
|
|
fields[name] = json.dumps(fields[name], ensure_ascii=False)
|
|
assignments = ", ".join(f"{name}=?" for name in fields)
|
|
db.execute(
|
|
f"UPDATE videos SET {assignments} WHERE channel_id=? AND message_id=?",
|
|
(*[v.value if isinstance(v, State) else v for v in fields.values()], *key),
|
|
)
|
|
|
|
def history(self, key: tuple[int, int]) -> list[tuple[str | None, str, str | None]]:
|
|
rows = self._query(
|
|
"SELECT from_state, to_state, note FROM transitions WHERE channel_id=? AND message_id=? ORDER BY id", tuple(key)
|
|
)
|
|
return [(r["from_state"], r["to_state"], r["note"]) for r in rows]
|
|
|
|
def next_item(self, now: datetime | None = None, *, task_id: int | None = None) -> VideoRecord | None:
|
|
"""Next item to work on: unfinished in-progress items first, then oldest message.
|
|
|
|
With ``task_id`` only that task's items are considered. Without it, items
|
|
belonging to a paused or queued /upload task are left alone.
|
|
"""
|
|
if task_id is not None:
|
|
scope, params = "task_id = ?", (task_id,)
|
|
else:
|
|
scope = (
|
|
"(task_id IS NULL OR task_id NOT IN "
|
|
"(SELECT id FROM upload_tasks WHERE status IN ('PAUSED','QUEUED')))"
|
|
)
|
|
params = ()
|
|
rows = self._query(
|
|
f"""SELECT * FROM videos
|
|
WHERE {scope}
|
|
AND (state IN ('UPLOADING','DOWNLOADED','DOWNLOADING','PENDING')
|
|
OR (state='RETRY_WAIT' AND next_retry_at <= ?))
|
|
ORDER BY CASE state WHEN 'UPLOADING' THEN 0 WHEN 'DOWNLOADED' THEN 1
|
|
WHEN 'DOWNLOADING' THEN 2 ELSE 3 END,
|
|
message_id
|
|
LIMIT 1""",
|
|
(*params, iso(now or utcnow())),
|
|
)
|
|
return VideoRecord.from_row(rows[0]) if rows else None
|
|
|
|
def earliest_retry_at(self) -> datetime | None:
|
|
rows = self._query("SELECT MIN(next_retry_at) AS t FROM videos WHERE state='RETRY_WAIT'")
|
|
value = rows[0]["t"] if rows else None
|
|
return datetime.fromisoformat(value) if value else None
|
|
|
|
def recover_stale(self) -> dict[str, int]:
|
|
"""Called once at startup, after the single-instance lock is held.
|
|
|
|
DOWNLOADING items go back to PENDING; a partial file, if it survived, is resumed.
|
|
UPLOADING items are left as they are: the queue reconciles them against Drive
|
|
(and resumes the saved upload session) before doing anything else.
|
|
"""
|
|
recovered = {}
|
|
for state in sorted(IN_PROGRESS_STATES):
|
|
rows = self._query("SELECT channel_id, message_id FROM videos WHERE state=?", (state.value,))
|
|
recovered[state.value] = len(rows)
|
|
if state == State.DOWNLOADING:
|
|
for r in rows:
|
|
self.transition((r["channel_id"], r["message_id"]), State.PENDING, note="recovered after restart")
|
|
if any(recovered.values()):
|
|
log.warning("Recovered interrupted items after restart: %s", recovered)
|
|
return recovered
|
|
|
|
def reset_failed(self, *, has_local) -> int:
|
|
"""Move FAILED items back into the queue with a fresh attempt budget."""
|
|
rows = self._query("SELECT * FROM videos WHERE state='FAILED' ORDER BY message_id")
|
|
for row in rows:
|
|
rec = VideoRecord.from_row(row)
|
|
target = State.DOWNLOADED if has_local(rec) else State.PENDING
|
|
self.transition(rec.key, target, note="manual retry", attempts=0, next_retry_at=None, error_kind=None)
|
|
return len(rows)
|
|
|
|
def counts(self) -> dict[str, int]:
|
|
counts = {s.value: 0 for s in State}
|
|
for r in self._query("SELECT state, COUNT(*) AS n FROM videos GROUP BY state"):
|
|
counts[r["state"]] = int(r["n"])
|
|
return counts
|
|
|
|
def list_by_state(self, state: State, limit: int = 20) -> list[VideoRecord]:
|
|
rows = self._query("SELECT * FROM videos WHERE state=? ORDER BY message_id LIMIT ?", (state.value, limit))
|
|
return [VideoRecord.from_row(r) for r in rows]
|
|
|
|
def topic_counts(self, limit: int = 30) -> list[tuple[str, int]]:
|
|
rows = self._query(
|
|
"""SELECT COALESCE(topic, '') AS topic, COUNT(*) AS n FROM videos WHERE state='COMPLETED'
|
|
GROUP BY COALESCE(topic, '') ORDER BY n DESC, topic LIMIT ?""",
|
|
(limit,),
|
|
)
|
|
return [(r["topic"], int(r["n"])) for r in rows]
|
|
|
|
def videos_for_task(self, task_id: int) -> list[VideoRecord]:
|
|
rows = self._query("SELECT * FROM videos WHERE task_id=? ORDER BY message_id", (task_id,))
|
|
return [VideoRecord.from_row(r) for r in rows]
|
|
|
|
def task_unfinished_count(self, task_id: int) -> int:
|
|
rows = self._query(
|
|
"SELECT COUNT(*) AS n FROM videos WHERE task_id=? AND state NOT IN ('COMPLETED','SKIPPED','FAILED')",
|
|
(task_id,),
|
|
)
|
|
return int(rows[0]["n"])
|
|
|
|
# -- upload tasks -----------------------------------------------------------------
|
|
|
|
def create_task(self, admin_user_id: int) -> UploadTask:
|
|
now = iso(utcnow())
|
|
with self._tx() as db:
|
|
task_id = db.insert_returning_id(
|
|
"INSERT INTO upload_tasks (admin_user_id, status, created_at, updated_at) VALUES (?,?,?,?)",
|
|
(admin_user_id, TaskStatus.AWAITING_START.value, now, now),
|
|
)
|
|
return self.get_task(task_id) # type: ignore[return-value]
|
|
|
|
def get_task(self, task_id: int) -> UploadTask | None:
|
|
rows = self._query("SELECT * FROM upload_tasks WHERE id=?", (task_id,))
|
|
return UploadTask.from_row(rows[0]) if rows else None
|
|
|
|
def update_task(self, task_id: int, **fields) -> UploadTask:
|
|
bad = set(fields) - _TASK_MUTABLE
|
|
if bad:
|
|
raise ValueError(f"unknown or immutable task fields: {sorted(bad)}")
|
|
fields.setdefault("updated_at", iso(utcnow()))
|
|
values = [v.value if isinstance(v, TaskStatus) else v for v in fields.values()]
|
|
assignments = ", ".join(f"{name}=?" for name in fields)
|
|
with self._tx() as db:
|
|
db.execute(f"UPDATE upload_tasks SET {assignments} WHERE id=?", (*values, task_id))
|
|
return self.get_task(task_id) # type: ignore[return-value]
|
|
|
|
def add_task_counter(self, task_id: int, column: str, amount: int) -> None:
|
|
if column not in _TASK_COUNTERS:
|
|
raise ValueError(column)
|
|
with self._tx() as db:
|
|
db.execute(f"UPDATE upload_tasks SET {column} = {column} + ? WHERE id=?", (amount, task_id))
|
|
|
|
def tasks_with_status(self, *statuses: TaskStatus, admin_user_id: int | None = None) -> list[UploadTask]:
|
|
marks = ",".join("?" for _ in statuses)
|
|
sql = f"SELECT * FROM upload_tasks WHERE status IN ({marks})"
|
|
params: tuple = tuple(s.value for s in statuses)
|
|
if admin_user_id is not None:
|
|
sql += " AND admin_user_id=?"
|
|
params += (admin_user_id,)
|
|
rows = self._query(sql + " ORDER BY id", params)
|
|
return [UploadTask.from_row(r) for r in rows]
|
|
|
|
def next_runnable_task(self) -> UploadTask | None:
|
|
"""The task the worker should run: an already RUNNING one, else the oldest QUEUED."""
|
|
rows = self._query(
|
|
"""SELECT * FROM upload_tasks WHERE status IN ('RUNNING','QUEUED')
|
|
ORDER BY CASE status WHEN 'RUNNING' THEN 0 ELSE 1 END, id LIMIT 1"""
|
|
)
|
|
return UploadTask.from_row(rows[0]) if rows else None
|
|
|
|
def recent_tasks(self, limit: int = 10) -> list[UploadTask]:
|
|
rows = self._query(
|
|
"SELECT * FROM upload_tasks WHERE status NOT IN ('AWAITING_START','AWAITING_END','AWAITING_CONFIRMATION') "
|
|
"ORDER BY id DESC LIMIT ?",
|
|
(limit,),
|
|
)
|
|
return [UploadTask.from_row(r) for r in rows]
|
|
|
|
def set_task_status(self, old: TaskStatus, new: TaskStatus) -> int:
|
|
with self._tx() as db:
|
|
cur = db.execute(
|
|
"UPDATE upload_tasks SET status=?, updated_at=? WHERE status=?", (new.value, iso(utcnow()), old.value)
|
|
)
|
|
return cur.rowcount
|
|
|
|
def add_task_item(self, task_id: int, channel_id: int, message_id: int, result: str) -> None:
|
|
"""Record a message examined by a task; an existing result is kept (rescans are idempotent)."""
|
|
with self._tx() as db:
|
|
db.execute(
|
|
"""INSERT INTO task_items (task_id, channel_id, message_id, result, updated_at) VALUES (?,?,?,?,?)
|
|
ON CONFLICT (task_id, channel_id, message_id) DO NOTHING""",
|
|
(task_id, channel_id, message_id, result, iso(utcnow())),
|
|
)
|
|
|
|
def set_task_item_result(
|
|
self, task_id: int, channel_id: int, message_id: int, result: str, *, folder: str | None = None
|
|
) -> None:
|
|
with self._tx() as db:
|
|
db.execute(
|
|
"""UPDATE task_items SET result=?, folder=COALESCE(?, folder), updated_at=?
|
|
WHERE task_id=? AND channel_id=? AND message_id=?""",
|
|
(result, folder, iso(utcnow()), task_id, channel_id, message_id),
|
|
)
|
|
|
|
def task_item_results(self, task_id: int) -> dict[str, int]:
|
|
rows = self._query("SELECT result, COUNT(*) AS n FROM task_items WHERE task_id=? GROUP BY result", (task_id,))
|
|
return {r["result"]: int(r["n"]) for r in rows}
|
|
|
|
def task_items(self, task_id: int) -> list[tuple[int, str]]:
|
|
rows = self._query("SELECT message_id, result FROM task_items WHERE task_id=? ORDER BY message_id", (task_id,))
|
|
return [(int(r["message_id"]), r["result"]) for r in rows]
|
|
|
|
def task_folders(self, task_id: int) -> list[str]:
|
|
rows = self._query(
|
|
"SELECT DISTINCT folder FROM task_items WHERE task_id=? AND folder IS NOT NULL ORDER BY folder", (task_id,)
|
|
)
|
|
return [r["folder"] for r in rows]
|
|
|
|
# -- folders --------------------------------------------------------------------
|
|
|
|
def get_folder(self, parent_id: str, name_key: str) -> str | None:
|
|
rows = self._query("SELECT folder_id FROM folders WHERE parent_id=? AND name_key=?", (parent_id, name_key))
|
|
return rows[0]["folder_id"] if rows else None
|
|
|
|
def put_folder(self, parent_id: str, name_key: str, name: str, folder_id: str) -> None:
|
|
with self._tx() as db:
|
|
db.execute(
|
|
"""INSERT INTO folders (parent_id, name_key, name, folder_id, created_at) VALUES (?,?,?,?,?)
|
|
ON CONFLICT (parent_id, name_key) DO UPDATE
|
|
SET name = excluded.name, folder_id = excluded.folder_id, created_at = excluded.created_at""",
|
|
(parent_id, name_key, name, folder_id, iso(utcnow())),
|
|
)
|
|
|
|
def delete_folder(self, parent_id: str, name_key: str) -> None:
|
|
with self._tx() as db:
|
|
db.execute("DELETE FROM folders WHERE parent_id=? AND name_key=?", (parent_id, name_key))
|
|
|
|
def folder_count(self) -> int:
|
|
return int(self._query("SELECT COUNT(*) AS n FROM folders")[0]["n"])
|
|
|
|
# -- key/value --------------------------------------------------------------------
|
|
|
|
def get_kv(self, key: str, default: str | None = None) -> str | None:
|
|
rows = self._query("SELECT value FROM kv WHERE key=?", (key,))
|
|
return rows[0]["value"] if rows else default
|
|
|
|
def set_kv(self, key: str, value: str) -> None:
|
|
with self._tx() as db:
|
|
db.execute(_UPSERT_KV, (key, value))
|
|
|
|
def get_int(self, key: str, default: int = 0) -> int:
|
|
value = self.get_kv(key)
|
|
return int(value) if value is not None else default
|
|
|
|
def raise_int(self, key: str, value: int) -> None:
|
|
"""Store ``value`` only if it is larger than the current one (cursor semantics)."""
|
|
with self._tx() as db:
|
|
row = db.execute(f"SELECT value FROM kv WHERE key=?{self._backend.for_update}", (key,)).fetchone()
|
|
if row is None or int(row["value"]) < value:
|
|
db.execute(_UPSERT_KV, (key, str(value)))
|
|
|
|
def next_sequence(self) -> int:
|
|
with self._tx() as db:
|
|
row = db.execute(f"SELECT value FROM kv WHERE key='sequence_counter'{self._backend.for_update}").fetchone()
|
|
value = (int(row["value"]) if row else 0) + 1
|
|
db.execute(_UPSERT_KV, ("sequence_counter", str(value)))
|
|
return value
|
|
|
|
|
|
# -- single-instance lock ----------------------------------------------------------------
|
|
|
|
_ADVISORY_LOCK_ID = 0x7464_6272 # arbitrary constant: "tdbr"
|
|
|
|
|
|
class InstanceLock:
|
|
"""Guarantee that only one worker processes the queue.
|
|
|
|
PostgreSQL: a session-level advisory lock held on a dedicated connection, so it
|
|
also protects against two Heroku dynos (e.g. worker scaled to 2, or an old dyno
|
|
still shutting down during a deploy). SQLite: an exclusive ``flock`` on a lock
|
|
file, which only protects processes on the same machine.
|
|
"""
|
|
|
|
def __init__(self, config, *, wait_seconds: float = 0, poll: float = 5.0):
|
|
self._config = config
|
|
self._wait = wait_seconds
|
|
self._poll = poll
|
|
self._conn = None
|
|
self._fh = None
|
|
|
|
def acquire(self) -> None:
|
|
deadline = time.monotonic() + self._wait
|
|
while True:
|
|
if self._try_acquire():
|
|
return
|
|
if time.monotonic() >= deadline:
|
|
raise InstanceLockError(
|
|
"Another worker is already processing this queue. Run exactly one worker "
|
|
"(Heroku: `heroku ps:scale worker=1`)."
|
|
)
|
|
log.warning("Another worker holds the queue lock; waiting...")
|
|
time.sleep(self._poll)
|
|
|
|
def _try_acquire(self) -> bool:
|
|
if self._config.database_url:
|
|
import psycopg
|
|
|
|
if self._conn is None or self._conn.closed:
|
|
self._conn = psycopg.connect(self._config.database_url, autocommit=True, connect_timeout=15)
|
|
return bool(self._conn.execute("SELECT pg_try_advisory_lock(%s)", (_ADVISORY_LOCK_ID,)).fetchone()[0])
|
|
import fcntl
|
|
|
|
path = Path(str(self._config.database_path) + ".lock")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if self._fh is None:
|
|
self._fh = open(path, "w")
|
|
try:
|
|
fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return True
|
|
except BlockingIOError:
|
|
return False
|
|
|
|
def release(self) -> None:
|
|
if self._conn is not None:
|
|
try:
|
|
self._conn.close() # closing the session releases the advisory lock
|
|
finally:
|
|
self._conn = None
|
|
if self._fh is not None:
|
|
self._fh.close()
|
|
self._fh = None
|