90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer`
|
|
management command with the project's existing self-rescheduling django-tasks
|
|
pattern (see submissions.tasks.sync_event_game_scopes_task).
|
|
|
|
Resets any event's timer once it expires - even if nobody presses or
|
|
reconnects to notice - and gives already-connected clients a periodic tick.
|
|
Note this is a safety net, not the only way the timer gets reset: every press
|
|
and every new connection (see consumers.py) also calls reset_if_expired(), so
|
|
the game works correctly even if this chain is never started or dies -
|
|
connected-but-idle clients just won't see the countdown restart until someone
|
|
acts.
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
from uuid import uuid4
|
|
|
|
from asgiref.sync import async_to_sync
|
|
from channels.layers import get_channel_layer
|
|
from django.core.cache import cache
|
|
from django.db import transaction
|
|
from django.tasks import task
|
|
from django.utils import timezone
|
|
|
|
from games.models import EventGame
|
|
|
|
from .broadcast import clock_group
|
|
from .models import ClockTimer
|
|
from .wire import to_epoch_ms
|
|
|
|
HEARTBEAT_INTERVAL_SECONDS = 1
|
|
# One chain ticks every event with the game enabled - this lock keeps a
|
|
# second chain from starting underneath an already-running one (e.g. two
|
|
# EventGame saves close together). Refreshed every tick, wide enough that a
|
|
# single slow tick doesn't let a second chain slip in. The lock holds the
|
|
# chain id: if it expires anyway (worker down/restarted for a few seconds) and
|
|
# a new chain starts, the old one stops at its next tick (instead of both
|
|
# ticking forever).
|
|
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
|
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
|
|
|
|
|
@task
|
|
def clock_heartbeat_task(chain_id: str):
|
|
if cache.get(HEARTBEAT_LOCK_KEY) != chain_id:
|
|
# Another chain owns the lock (or it expired), stop this one
|
|
return
|
|
|
|
event_games = list(
|
|
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
|
"event"
|
|
)
|
|
)
|
|
|
|
if not event_games:
|
|
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
|
# starts a fresh one next time an event enables the game.
|
|
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
|
|
cache.delete(HEARTBEAT_LOCK_KEY)
|
|
return
|
|
|
|
channel_layer = get_channel_layer()
|
|
for event_game in event_games:
|
|
event = event_game.event
|
|
ClockTimer.current(event)
|
|
with transaction.atomic():
|
|
timer = ClockTimer.objects.select_for_update().get(event=event)
|
|
timer.reset_if_expired()
|
|
|
|
async_to_sync(channel_layer.group_send)(
|
|
clock_group(event.path),
|
|
{
|
|
"type": "tick",
|
|
"end": to_epoch_ms(timer.end_time),
|
|
"now": to_epoch_ms(timezone.now()),
|
|
"pressed": False,
|
|
},
|
|
)
|
|
|
|
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
|
clock_heartbeat_task.using(
|
|
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
|
).enqueue(chain_id)
|
|
|
|
|
|
def start_clock_heartbeat():
|
|
"""Enqueues the heartbeat chain, unless one is already running."""
|
|
chain_id = uuid4().hex
|
|
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
|
clock_heartbeat_task.enqueue(chain_id)
|