Auto-Rename / bot /queue.py
github-actions[bot]
Automated sync from GitHub
6c3a31d
Raw History Blame Contribute Delete
6.38 kB
"""FIFO job queue with a small pool of workers."""
from __future__ import annotations
import asyncio
import itertools
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
log = logging.getLogger(__name__)
@dataclass
class Job:
id: int
user_id: int
chat_id: int
msg_id: int
file_name: str
size: int
new_name: str
fmt: Optional[Dict[str, Any]] = None # the saved format (auto or manual) this job uses, a snapshot taken when it was queued; None: no auto format
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
status_msg: Any = None
state: str = "queued" # queued | running | done | failed | cancelled
cancel: asyncio.Event = field(default_factory=asyncio.Event)
created: float = field(default_factory=time.time)
started: float = 0.0
Runner = Callable[[Job], Awaitable[None]]
SETTLE_CAP = 4 # an idle worker never waits longer than this many settle periods in a row
def _order_key(job: Job) -> tuple:
return (job.order, job.id)
class JobQueue:
"""Jobs run oldest-message-first by a small pool of workers.
Telethon handles messages that arrive together concurrently, so jobs are not necessarily added in the
order the files were sent. Picking by message id (not by insertion order) and letting an idle worker wait
until nothing new was queued for ``settle`` seconds (at most ``SETTLE_CAP`` times that) keeps a forwarded
batch in its original order.
"""
def __init__(self, runner: Runner, workers: int = 1, on_cancelled: Optional[Runner] = None, settle: float = 0.0):
self._runner = runner
self._on_cancelled = on_cancelled
self._workers = max(1, workers)
self._settle = max(0.0, settle)
self._wake = asyncio.Event()
self._ids = itertools.count(1)
self._tasks: List[asyncio.Task] = []
self.jobs: Dict[int, Job] = {}
def new_id(self) -> int:
return next(self._ids)
def start(self) -> None:
for _ in range(self._workers):
self._tasks.append(asyncio.ensure_future(self._worker()))
async def stop(self) -> None:
for job in list(self.jobs.values()):
job.cancel.set()
for task in self._tasks:
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
def add(self, job: Job) -> int:
"""Queue a job; returns how many jobs are ahead of it (running + waiting)."""
ahead = self.running + self.waiting
self.jobs[job.id] = job
self._wake.set()
return ahead
@property
def running(self) -> int:
return sum(1 for j in self.jobs.values() if j.state == "running")
@property
def waiting(self) -> int:
return sum(1 for j in self.jobs.values() if j.state == "queued")
def position(self, job: Job) -> int:
"""0 = running now, 1 = next up, ..."""
if job.state == "running":
return 0
waiting = sorted((j for j in self.jobs.values() if j.state == "queued"), key=_order_key)
try:
return waiting.index(job) + 1
except ValueError:
return 0
def for_user(self, user_id: int) -> List[Job]:
return sorted((j for j in self.jobs.values() if j.user_id == user_id), key=_order_key)
def cancel(self, job_id: int, user_id: Optional[int] = None) -> bool:
job = self.jobs.get(job_id)
if job is None or (user_id is not None and job.user_id != user_id):
return False
return self._cancel_job(job)
def cancel_user(self, user_id: int) -> int:
return sum(self._cancel_job(j) for j in self.for_user(user_id))
def cancel_all(self) -> int:
return sum(self._cancel_job(j) for j in list(self.jobs.values()))
def _cancel_job(self, job: Job) -> bool:
if job.state in ("done", "failed", "cancelled") or job.cancel.is_set():
return False # finished, or a cancel is already on its way
job.cancel.set()
if job.state == "queued":
job.state = "cancelled"
self.jobs.pop(job.id, None)
if self._on_cancelled is not None:
asyncio.ensure_future(self._safe(self._on_cancelled, job))
return True
async def _safe(self, fn: Runner, job: Job) -> None:
try:
await fn(job)
except Exception: # noqa: BLE001
log.exception("job callback failed")
def _claim(self) -> Optional[Job]:
"""Take the oldest queued job (no await in between, so two workers never take the same one)."""
queued = [j for j in self.jobs.values() if j.state == "queued"]
if not queued:
return None
job = min(queued, key=_order_key)
job.state = "running"
job.started = time.time()
return job
async def _settle_batch(self) -> None:
"""The rest of a forwarded batch may still be on its way: wait until nothing new was queued for a while."""
if not self._settle:
return
deadline = time.monotonic() + self._settle * SETTLE_CAP
while time.monotonic() < deadline:
self._wake.clear()
try:
await asyncio.wait_for(self._wake.wait(), timeout=self._settle)
except asyncio.TimeoutError:
return
async def _next(self) -> Job:
while True:
job = self._claim()
if job is not None:
return job
self._wake.clear()
await self._wake.wait()
await self._settle_batch()
async def _worker(self) -> None:
while True:
job = await self._next()
try:
await self._runner(job)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - the runner reports its own failures
log.exception("job %s crashed", job.id)
job.state = "failed"
finally:
if job.state == "running":
job.state = "done"
self.jobs.pop(job.id, None)