feat(submissions): sync scopes admin from token
This commit is contained in:
@@ -1,19 +1,61 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.tasks import task
|
||||
from django.utils import timezone
|
||||
from games.models import EventGame
|
||||
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
|
||||
from submissions.services.aandt_sync import (
|
||||
AANDT_STATUS_IN_PROGRESS,
|
||||
ScopeSyncError,
|
||||
sync_event_game_scopes,
|
||||
)
|
||||
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
|
||||
# How often to repoll an EventGame's AAndT while it's still "in progress".
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How often to repoll an EventGame's AAndT while it's "in progress", and while
|
||||
# it isn't yet (registrations, preparation, ...) or the API call failed.
|
||||
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
|
||||
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
|
||||
# AAndT statuses after which there's nothing left to sync, the loop stops.
|
||||
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
|
||||
|
||||
|
||||
def _scope_sync_guard_key(event_game_id: int) -> str:
|
||||
return f"submissions:scope_sync:{event_game_id}"
|
||||
|
||||
|
||||
def _scope_sync_guard_timeout() -> int:
|
||||
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
|
||||
|
||||
|
||||
def schedule_scope_sync(event_game_id: int, /) -> bool:
|
||||
"""Start the sync loop of an EventGame, unless one is already running.
|
||||
Safe to call any number of times (admin button spam, multiple workers...).
|
||||
|
||||
The loop holds a per-EventGame guard with its id (same idea as
|
||||
games.tasks.schedule_steam_refresh), so if the guard expires while an old
|
||||
loop is just late, the old one stops at its next run."""
|
||||
loop_id = uuid4().hex
|
||||
if not cache.add(
|
||||
_scope_sync_guard_key(event_game_id),
|
||||
loop_id,
|
||||
timeout=_scope_sync_guard_timeout(),
|
||||
):
|
||||
return False
|
||||
|
||||
sync_event_game_scopes_task.enqueue(event_game_id, loop_id=loop_id)
|
||||
return True
|
||||
|
||||
|
||||
def stop_scope_sync(event_game_id: int, /):
|
||||
"""Release the guard, the running loop (if any) stops at its next run."""
|
||||
cache.delete(_scope_sync_guard_key(event_game_id))
|
||||
|
||||
|
||||
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
||||
@@ -90,26 +132,55 @@ def sync_all_event_games_task():
|
||||
)
|
||||
|
||||
for event_game in event_games:
|
||||
sync_event_game_scopes_task.enqueue(event_game.id)
|
||||
schedule_scope_sync(event_game.id)
|
||||
|
||||
|
||||
@task
|
||||
def sync_event_game_scopes_task(event_game_id: int):
|
||||
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress.
|
||||
def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
|
||||
"""Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done.
|
||||
|
||||
Each run re-checks the AAndT's status (from the `/` endpoint). While it's
|
||||
still "in progress" (`7inprog`), the task re-enqueues itself with a delay
|
||||
instead of relying on an external scheduler; once the status moves on, it
|
||||
simply doesn't reschedule and the loop stops.
|
||||
Started once (see schedule_scope_sync), it keeps going on its own without
|
||||
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
|
||||
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
|
||||
when the API call failed. Stops once the AAndT is done/reviewed, when the
|
||||
EventGame (or its token) is gone, or when another loop took the guard.
|
||||
|
||||
Without loop_id it's a one-off run that leaves the loop alone.
|
||||
"""
|
||||
event_game = EventGame.objects.filter(id=event_game_id).first()
|
||||
|
||||
if not event_game:
|
||||
guard_key = _scope_sync_guard_key(event_game_id)
|
||||
if loop_id is not None and cache.get(guard_key) != loop_id:
|
||||
# Another loop owns the guard (or it expired), stop this one
|
||||
return
|
||||
|
||||
result = sync_event_game_scopes(event_game)
|
||||
event_game = EventGame.objects.filter(id=event_game_id).first()
|
||||
|
||||
if result.status == AANDT_STATUS_IN_PROGRESS:
|
||||
sync_event_game_scopes_task.using(
|
||||
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL
|
||||
).enqueue(event_game_id)
|
||||
if not event_game or not event_game.apibot_token:
|
||||
if loop_id is not None:
|
||||
cache.delete(guard_key)
|
||||
return
|
||||
|
||||
status = None
|
||||
try:
|
||||
status = sync_event_game_scopes(event_game).status
|
||||
except ScopeSyncError:
|
||||
if loop_id is None:
|
||||
raise
|
||||
# Probably transient (AAndT site down, ...), retry later
|
||||
logger.exception("Scope sync failed for EventGame %s", event_game_id)
|
||||
|
||||
if loop_id is None:
|
||||
return
|
||||
|
||||
if status in AANDT_FINISHED_STATUSES:
|
||||
cache.delete(guard_key)
|
||||
return
|
||||
|
||||
interval = (
|
||||
SCOPE_SYNC_REPOLL_INTERVAL
|
||||
if status == AANDT_STATUS_IN_PROGRESS
|
||||
else SCOPE_SYNC_IDLE_INTERVAL
|
||||
)
|
||||
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
|
||||
sync_event_game_scopes_task.using(run_after=timezone.now() + interval).enqueue(
|
||||
event_game_id, loop_id=loop_id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user