fix periodic-check request queue throughput bottleneck
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 12s
The bot's request_queue.py 4s FIFO gate wasn't protecting against Lichess's rate limiter — that's already handled downstream in LichessWebServices/ rate_limiter.py (0.2s, shared across all callers of our stats service). The bot-side gate only paced calls to our own local service, and since it awaited each request to full completion before dequeuing the next, real dispatch gaps were max(4s, previous request's duration) — with 454 tracked gamer/user pairs, any burst (e.g. after a restart) piled into the queue and took 10-20+ minutes to drain. Replace it with a paced-dispatch + bounded-concurrency design: a hard 2s floor between dispatches (still never lets 2+ requests through in that window), decoupled from completion time, with up to 10 requests actually in flight at once via a semaphore. Doesn't touch the real Lichess-facing rate limit at all. Also add deterministic per-(user,gamer) checkpoint jitter: previously every pair sharing the same period_minutes re-locked onto the same wall-clock phase on every restart (backlog collapse snaps period_end_approx to `now` for everyone overdue at once), recreating the pileup each time. Jitter is stable across restarts (crc32-based, not Python's salted hash()) and capped well under the 2h stale-backlog threshold. Small startup stagger added too, purely cosmetic smoothing on top of the jitter fix.
This commit is contained in:
parent
ad6daf2918
commit
8080921141
3 changed files with 159 additions and 79 deletions
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import logging
|
||||
import sqlite3
|
||||
import os
|
||||
import zlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
|
|
@ -13,6 +14,7 @@ from telegram.ext import (
|
|||
PicklePersistence
|
||||
)
|
||||
|
||||
import config
|
||||
from config import (
|
||||
TELEGRAM_BOT_TOKEN, PERIOD_OPTIONS, POLL_INTERVAL,
|
||||
POLL_TIMEOUT, DROP_PENDING_UPDATES, ALLOWED_UPDATES,
|
||||
|
|
@ -224,16 +226,24 @@ class LichessBot:
|
|||
self.request_queue._start_processor()
|
||||
logger.info("✅ Request queue processor started")
|
||||
|
||||
for gamer in gamers_with_periods:
|
||||
if gamer['period_minutes'] > 0:
|
||||
user_id = gamer['user_id']
|
||||
username = gamer['username']
|
||||
period = gamer['period_minutes']
|
||||
# Start periodic task with user_id and gamer
|
||||
await self.start_periodic_task(gamer, user_id, period)
|
||||
logger.info(f"✅ Started periodic task for {username} (user {user_id}) with period {period} minutes")
|
||||
|
||||
logger.info(f"✅ All periodic tasks started. Total: {len([g for g in gamers_with_periods if g['period_minutes'] > 0])}")
|
||||
active_gamers = [g for g in gamers_with_periods if g['period_minutes'] > 0]
|
||||
# Stagger task creation across a short startup window instead of firing all
|
||||
# ~N tasks' first (blocking) sqlite checkpoint read in the same event loop
|
||||
# tick. Small and one-off — the real anti-lockstep fix is the per-pair
|
||||
# checkpoint jitter in periodic_check, this just smooths process boot.
|
||||
stagger_step = config.PERIODIC_STARTUP_STAGGER_MAX_SECONDS / max(1, len(active_gamers))
|
||||
|
||||
for gamer in active_gamers:
|
||||
user_id = gamer['user_id']
|
||||
username = gamer['username']
|
||||
period = gamer['period_minutes']
|
||||
# Start periodic task with user_id and gamer
|
||||
await self.start_periodic_task(gamer, user_id, period)
|
||||
logger.info(f"✅ Started periodic task for {username} (user {user_id}) with period {period} minutes")
|
||||
if stagger_step > 0:
|
||||
await asyncio.sleep(stagger_step)
|
||||
|
||||
logger.info(f"✅ All periodic tasks started. Total: {len(active_gamers)}")
|
||||
|
||||
# Start daily counter reset task
|
||||
asyncio.create_task(self.daily_counter_reset_task())
|
||||
|
|
@ -1534,6 +1544,24 @@ class LichessBot:
|
|||
# подряд идущих ошибок (~2 часа при капнутом бэкоффе в 300с на попытку)
|
||||
ADMIN_NOTIFY_ERROR_THRESHOLD = 25
|
||||
|
||||
@staticmethod
|
||||
def _checkpoint_jitter_seconds(user_id: int, gamer_id: int, period_minutes: int) -> float:
|
||||
"""
|
||||
Deterministic per-(user, gamer) jitter applied whenever a checkpoint gets
|
||||
pinned to "now" (backlog collapse / first check). Without this, every pair
|
||||
sharing the same period_minutes re-locks onto the exact same wall-clock
|
||||
phase on every bot restart, causing them all to become "due" in the same
|
||||
instant forever after. Stable across restarts (unlike Python's salted
|
||||
hash()), and capped well below STALE_BACKLOG_THRESHOLD so it never
|
||||
interacts with backlog-notification suppression.
|
||||
"""
|
||||
jitter_cap = min(period_minutes * 60 * config.PERIODIC_CHECKPOINT_JITTER_FRACTION,
|
||||
config.PERIODIC_CHECKPOINT_JITTER_MAX_SECONDS)
|
||||
if jitter_cap <= 0:
|
||||
return 0.0
|
||||
digest = zlib.crc32(f"{user_id}:{gamer_id}".encode())
|
||||
return digest % jitter_cap
|
||||
|
||||
async def periodic_check(self, gamer: Dict[str, Any], user_id: int, period_minutes: int):
|
||||
"""Periodic check for gamer activity"""
|
||||
task_key = f"{gamer['id']}_{user_id}"
|
||||
|
|
@ -1606,8 +1634,11 @@ class LichessBot:
|
|||
# игроков через один общий RequestQueue) чекпоинт никогда не догонит
|
||||
# текущее время — отставание только растёт. Вместо этого закрываем
|
||||
# весь пропущенный промежуток одним запросом и сразу прыгаем к "сейчас".
|
||||
# Джиттер (см. _checkpoint_jitter_seconds) не даёт всем парам с
|
||||
# одинаковым period_minutes зафиксироваться на один и тот же момент.
|
||||
since_time = last_check_time
|
||||
period_end_approx = now
|
||||
jitter = self._checkpoint_jitter_seconds(user_id, gamer['id'], period_minutes)
|
||||
period_end_approx = now - timedelta(seconds=jitter)
|
||||
if period_end_approx - next_period_start > timedelta(minutes=period_minutes):
|
||||
logger.warning(
|
||||
f"⏰ {username} is behind schedule by "
|
||||
|
|
@ -1620,8 +1651,9 @@ class LichessBot:
|
|||
logger.info(f"⏳ First check: waiting {period_minutes} minutes before first check for {username}")
|
||||
await asyncio.sleep(period_minutes * 60)
|
||||
|
||||
# Получаем текущее время
|
||||
period_end_approx = datetime.now()
|
||||
# Получаем текущее время (с джиттером, см. _checkpoint_jitter_seconds)
|
||||
jitter = self._checkpoint_jitter_seconds(user_id, gamer['id'], period_minutes)
|
||||
period_end_approx = datetime.now() - timedelta(seconds=jitter)
|
||||
# Начало периода - текущее время минус period_minutes
|
||||
since_time = period_end_approx - timedelta(minutes=period_minutes)
|
||||
logger.info(f"📌 First check: period from {since_time} to {period_end_approx}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue