LichessStatTgWeb/LichessClientTG_bot/request_queue.py

178 lines
6.7 KiB
Python
Raw Permalink Normal View History

"""
Request Queue for managing API requests with rate limiting
Paces how fast we dispatch requests to our own local stats API
(http://localhost:8002), and caps how many of those requests may be in
flight at once. This does NOT protect against Lichess's real rate limiter —
that protection already lives downstream, in LichessWebServices/rate_limiter.py
(0.2s min delay, single shared instance, applied before every actual
lichess.org call, shared by ALL callers of stats_service not just this
bot). These numbers only govern bot -> local-service traffic.
"""
import asyncio
import logging
from typing import Callable, Any, Optional, Set
from datetime import datetime
2026-02-04 23:51:32 +03:00
import config
logger = logging.getLogger(__name__)
class RequestQueue:
"""
Queue for managing requests to our local stats API.
Dispatch is paced by a hard floor (min_dispatch_interval): two requests
can never be dispatched closer together than that, enforced by a lock in
the processor loop, independent of how long any individual request takes
to complete. Actual execution is capped separately by max_concurrent via
a semaphore, so a slow request can't stall the whole queue behind it.
"""
def __init__(self, min_dispatch_interval: float, max_concurrent: int):
"""
Args:
min_dispatch_interval: Minimum seconds between successive dispatches
max_concurrent: Maximum requests in flight at once (set to 1 to
fully serialize execution again, e.g. as a rollback lever)
"""
self.min_dispatch_interval = min_dispatch_interval
self.max_concurrent = max_concurrent
self.queue = asyncio.Queue()
self.is_processing = False
self.last_dispatch_time: Optional[float] = None
self.lock = asyncio.Lock()
self.semaphore = asyncio.Semaphore(max_concurrent)
self._processor_task: Optional[asyncio.Task] = None
self._in_flight: Set[asyncio.Task] = set()
async def add_request(self, request_func: Callable, *args, **kwargs) -> Any:
"""
Add a request to the queue and wait for its result.
Args:
request_func: Async function to call
*args: Positional arguments for the function
**kwargs: Keyword arguments for the function
Returns:
Result of the request function
"""
future = asyncio.Future()
await self.queue.put({
'func': request_func,
'args': args,
'kwargs': kwargs,
'future': future
})
if not self.is_processing:
self._start_processor()
return await future
def _start_processor(self):
"""Start the queue processor task"""
if self._processor_task is None or self._processor_task.done():
self.is_processing = True
self._processor_task = asyncio.create_task(self._process_queue())
logger.info(
f"🚀 Started request queue processor "
f"(dispatch interval: {self.min_dispatch_interval}s, "
f"max concurrent: {self.max_concurrent})"
)
async def _process_queue(self):
"""Dequeue requests and dispatch them at a paced rate, without waiting for completion"""
logger.info("📋 Request queue processor started")
while True:
try:
request_item = await self.queue.get()
# Hard floor: never dispatch two requests closer than
# min_dispatch_interval apart, regardless of how long the
# previous one takes to finish (that's what self.semaphore
# bounds separately, not this gate).
await self._wait_for_dispatch_slot()
task = asyncio.create_task(self._execute(request_item))
self._in_flight.add(task)
task.add_done_callback(self._in_flight.discard)
self.queue.task_done()
except asyncio.CancelledError:
logger.info("🛑 Request queue processor cancelled")
break
except Exception as e:
logger.error(f"❌ Error in request queue processor: {e}")
import traceback
logger.error(traceback.format_exc())
await asyncio.sleep(1)
async def _execute(self, request_item: dict):
"""Run one queued request, bounded by the concurrency semaphore"""
func = request_item['func']
args = request_item['args']
kwargs = request_item['kwargs']
future = request_item['future']
async with self.semaphore:
try:
logger.debug(f"🔄 Executing request: {func.__name__}")
result = await func(*args, **kwargs)
if not future.done():
future.set_result(result)
logger.debug(f"✅ Request completed: {func.__name__}")
except Exception as e:
logger.error(f"❌ Request failed: {func.__name__}: {e}")
if not future.done():
future.set_exception(e)
async def _wait_for_dispatch_slot(self):
"""Wait if necessary to maintain minimum delay between dispatches"""
async with self.lock:
now = datetime.now().timestamp()
if self.last_dispatch_time is not None:
elapsed = now - self.last_dispatch_time
if elapsed < self.min_dispatch_interval:
wait_time = self.min_dispatch_interval - elapsed
logger.debug(f"⏳ Dispatch pacing: waiting {wait_time:.2f} seconds")
await asyncio.sleep(wait_time)
now = datetime.now().timestamp()
self.last_dispatch_time = now
async def stop(self):
"""Stop the queue processor and any in-flight requests"""
if self._processor_task and not self._processor_task.done():
self._processor_task.cancel()
try:
await self._processor_task
except asyncio.CancelledError:
pass
for task in list(self._in_flight):
task.cancel()
if self._in_flight:
await asyncio.gather(*self._in_flight, return_exceptions=True)
self.is_processing = False
logger.info("🛑 Request queue processor stopped")
# Global request queue instance
_request_queue: Optional[RequestQueue] = None
def get_request_queue() -> RequestQueue:
"""Get the global request queue instance"""
global _request_queue
if _request_queue is None:
_request_queue = RequestQueue(
min_dispatch_interval=config.LICHESS_REQUEST_QUEUE_MIN_DISPATCH_INTERVAL,
max_concurrent=config.LICHESS_REQUEST_QUEUE_MAX_CONCURRENT,
)
return _request_queue