2026-06-14 09:48:10 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
2026-06-14 16:46:36 +02:00
|
|
|
import queue
|
|
|
|
|
import threading
|
2026-06-14 09:48:10 +02:00
|
|
|
from typing import Any, Callable, Optional
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
QUEUE_MAXSIZE = 10000
|
2026-06-14 16:46:36 +02:00
|
|
|
JOIN_TIMEOUT_SECONDS = 10
|
|
|
|
|
GET_TIMEOUT_SECONDS = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Stop:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_STOP = _Stop()
|
2026-06-14 09:48:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class BackgroundQueue:
|
|
|
|
|
def __init__(self, maxsize: int = QUEUE_MAXSIZE) -> None:
|
2026-06-14 16:46:36 +02:00
|
|
|
self._queue: "queue.Queue" = queue.Queue(maxsize=maxsize)
|
|
|
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
self._counters_lock = threading.Lock()
|
2026-06-14 09:48:10 +02:00
|
|
|
self._submitted = 0
|
|
|
|
|
self._processed = 0
|
|
|
|
|
self._failed = 0
|
|
|
|
|
self._inline = 0
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def running(self) -> bool:
|
2026-06-14 16:46:36 +02:00
|
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def loop(self) -> Optional[asyncio.AbstractEventLoop]:
|
|
|
|
|
return self._loop
|
2026-06-14 09:48:10 +02:00
|
|
|
|
|
|
|
|
def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
2026-06-14 16:46:36 +02:00
|
|
|
with self._counters_lock:
|
|
|
|
|
self._submitted += 1
|
2026-06-14 09:48:10 +02:00
|
|
|
if not self.running:
|
|
|
|
|
self._execute(fn, args, kwargs, inline=True)
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
self._queue.put_nowait((fn, args, kwargs))
|
2026-06-14 16:46:36 +02:00
|
|
|
except queue.Full:
|
2026-06-14 09:48:10 +02:00
|
|
|
logger.warning("background queue full; running task inline")
|
|
|
|
|
self._execute(fn, args, kwargs, inline=True)
|
|
|
|
|
|
|
|
|
|
def _execute(self, fn: Callable[..., Any], args: tuple, kwargs: dict, inline: bool = False) -> None:
|
|
|
|
|
try:
|
|
|
|
|
fn(*args, **kwargs)
|
2026-06-14 16:46:36 +02:00
|
|
|
with self._counters_lock:
|
|
|
|
|
self._processed += 1
|
|
|
|
|
if inline:
|
|
|
|
|
self._inline += 1
|
2026-06-14 09:48:10 +02:00
|
|
|
except Exception as exc:
|
2026-06-14 16:46:36 +02:00
|
|
|
with self._counters_lock:
|
|
|
|
|
self._failed += 1
|
|
|
|
|
if inline:
|
|
|
|
|
self._inline += 1
|
2026-06-14 09:48:10 +02:00
|
|
|
logger.warning("background task %s failed: %s", getattr(fn, "__name__", fn), exc)
|
2026-07-09 02:52:54 +02:00
|
|
|
finally:
|
|
|
|
|
self._release_db_lock()
|
|
|
|
|
|
|
|
|
|
def _release_db_lock(self) -> None:
|
|
|
|
|
try:
|
|
|
|
|
from devplacepy.database import refresh_snapshot
|
|
|
|
|
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("background queue could not release db lock: %s", exc)
|
2026-06-14 09:48:10 +02:00
|
|
|
|
|
|
|
|
async def start(self) -> None:
|
|
|
|
|
if self.running:
|
|
|
|
|
return
|
2026-06-14 16:46:36 +02:00
|
|
|
self._loop = asyncio.get_running_loop()
|
|
|
|
|
self._thread = threading.Thread(
|
|
|
|
|
target=self._worker, name="background-queue", daemon=True
|
|
|
|
|
)
|
|
|
|
|
self._thread.start()
|
|
|
|
|
logger.info("background task queue worker thread started")
|
2026-06-14 09:48:10 +02:00
|
|
|
|
|
|
|
|
async def stop(self) -> None:
|
2026-06-14 16:46:36 +02:00
|
|
|
thread = self._thread
|
|
|
|
|
self._thread = None
|
|
|
|
|
self._loop = None
|
|
|
|
|
if thread is not None:
|
|
|
|
|
self._queue.put(_STOP)
|
|
|
|
|
await asyncio.to_thread(thread.join, JOIN_TIMEOUT_SECONDS)
|
2026-06-14 09:48:10 +02:00
|
|
|
self._flush_remaining()
|
|
|
|
|
logger.info(
|
|
|
|
|
"background task queue stopped processed=%s failed=%s inline=%s",
|
|
|
|
|
self._processed,
|
|
|
|
|
self._failed,
|
|
|
|
|
self._inline,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _flush_remaining(self) -> None:
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
2026-06-14 16:46:36 +02:00
|
|
|
item = self._queue.get_nowait()
|
|
|
|
|
except queue.Empty:
|
2026-06-14 09:48:10 +02:00
|
|
|
break
|
2026-06-14 16:46:36 +02:00
|
|
|
if item is _STOP:
|
|
|
|
|
continue
|
|
|
|
|
fn, args, kwargs = item
|
2026-06-14 09:48:10 +02:00
|
|
|
self._execute(fn, args, kwargs)
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
def _worker(self) -> None:
|
2026-06-14 09:48:10 +02:00
|
|
|
while True:
|
2026-06-14 16:46:36 +02:00
|
|
|
try:
|
|
|
|
|
item = self._queue.get(timeout=GET_TIMEOUT_SECONDS)
|
|
|
|
|
except queue.Empty:
|
|
|
|
|
continue
|
|
|
|
|
if item is _STOP:
|
|
|
|
|
return
|
|
|
|
|
fn, args, kwargs = item
|
2026-06-14 09:48:10 +02:00
|
|
|
self._execute(fn, args, kwargs)
|
|
|
|
|
|
|
|
|
|
def stats(self) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"running": self.running,
|
|
|
|
|
"depth": self._queue.qsize(),
|
|
|
|
|
"submitted": self._submitted,
|
|
|
|
|
"processed": self._processed,
|
|
|
|
|
"failed": self._failed,
|
|
|
|
|
"inline": self._inline,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
background = BackgroundQueue()
|