Compare commits

...
2 Commits
Author SHA1 Message Date
Legrems b403311ce1 fix(games): card fixes; colors + width 2026-10-16 01:13:24 +02:00
Legrems 1ca4643774 fix(clock): dedup tasks lock bug removal 2026-10-16 01:08:50 +02:00
8 changed files with 127 additions and 69 deletions
+17 -7
View File
@@ -12,6 +12,7 @@ acts.
""" """
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@@ -30,13 +31,20 @@ HEARTBEAT_INTERVAL_SECONDS = 1
# One chain ticks every event with the game enabled - this lock keeps a # 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 # second chain from starting underneath an already-running one (e.g. two
# EventGame saves close together). Refreshed every tick, wide enough that a # EventGame saves close together). Refreshed every tick, wide enough that a
# single slow tick doesn't let a second chain slip in. # 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_KEY = "clock:heartbeat:lock"
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5 HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
@task @task
def clock_heartbeat_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( event_games = list(
EventGame.objects.filter(game__path="clock", enabled=True).select_related( EventGame.objects.filter(game__path="clock", enabled=True).select_related(
"event" "event"
@@ -46,7 +54,8 @@ def clock_heartbeat_task():
if not event_games: if not event_games:
# Nothing left to tick - let the chain die. start_clock_heartbeat() # Nothing left to tick - let the chain die. start_clock_heartbeat()
# starts a fresh one next time an event enables the game. # starts a fresh one next time an event enables the game.
cache.delete(HEARTBEAT_LOCK_KEY) if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
cache.delete(HEARTBEAT_LOCK_KEY)
return return
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
@@ -67,13 +76,14 @@ def clock_heartbeat_task():
}, },
) )
cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS) cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
clock_heartbeat_task.using( clock_heartbeat_task.using(
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS) run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
).enqueue() ).enqueue(chain_id)
def start_clock_heartbeat(): def start_clock_heartbeat():
"""Enqueues the heartbeat chain, unless one is already running.""" """Enqueues the heartbeat chain, unless one is already running."""
if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS): chain_id = uuid4().hex
clock_heartbeat_task.enqueue() if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
clock_heartbeat_task.enqueue(chain_id)
+57
View File
@@ -0,0 +1,57 @@
from unittest import mock
from django.core.cache import cache
from django.test import TestCase, override_settings
from events.models import Event
from games.models import EventGame, Game
from .tasks import HEARTBEAT_LOCK_KEY, clock_heartbeat_task, start_clock_heartbeat
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@override_settings(CACHES=LOCMEM_CACHE)
@mock.patch("clock.tasks.get_channel_layer")
class ClockHeartbeatTests(TestCase):
def setUp(self):
cache.clear()
event = Event.objects.create(name="Event", path="event")
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
self.event_game = EventGame.objects.create(event=event, game=clock)
cache.clear() # post_save signal can start a chain
@mock.patch("clock.tasks.clock_heartbeat_task")
def test_only_one_chain_is_started(self, task, _):
start_clock_heartbeat()
start_clock_heartbeat()
task.enqueue.assert_called_once_with(cache.get(HEARTBEAT_LOCK_KEY))
def test_owning_chain_reschedules_itself(self, channel_layer):
channel_layer.return_value.group_send = mock.AsyncMock()
cache.set(HEARTBEAT_LOCK_KEY, "chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("chain")
patched.using.return_value.enqueue.assert_called_once_with("chain")
channel_layer.return_value.group_send.assert_awaited_once()
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "chain")
def test_superseded_chain_stops(self, channel_layer):
cache.set(HEARTBEAT_LOCK_KEY, "new-chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("old-chain")
patched.using.assert_not_called()
channel_layer.assert_not_called()
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "new-chain")
def test_chain_dies_and_releases_lock_when_game_disabled(self, _):
EventGame.objects.filter(pk=self.event_game.pk).update(enabled=False)
cache.set(HEARTBEAT_LOCK_KEY, "chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("chain")
patched.using.assert_not_called()
self.assertIsNone(cache.get(HEARTBEAT_LOCK_KEY))
+1 -1
View File
@@ -40,7 +40,7 @@ class GameAdmin(admin.ModelAdmin):
game_ids = list( game_ids = list(
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True) queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
) )
refresh_steam_games_task.enqueue(game_ids=game_ids, reschedule=False) refresh_steam_games_task.enqueue(game_ids=game_ids)
self.message_user( self.message_user(
request, f"Queued Steam store refresh for {len(game_ids)} game(s)." request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
) )
+18 -9
View File
@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@@ -10,7 +11,9 @@ from .services.steam_store import refresh_steam_games
# Set while a periodic refresh is queued, so there's only ever one loop running, # Set while a periodic refresh is queued, so there's only ever one loop running,
# whatever the number of workers/containers trying to start it. It expires on # whatever the number of workers/containers trying to start it. It expires on
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...), # its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
# letting the next schedule_steam_refresh() start a new one. # letting the next schedule_steam_refresh() start a new one. The guard holds the
# loop id, so if it expires while the old loop is just late, the old one stops
# at its next run (instead of 2 loops running side by side).
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled" STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
@@ -25,28 +28,34 @@ def _guard_timeout() -> int:
def schedule_steam_refresh() -> bool: def schedule_steam_refresh() -> bool:
"""Start the periodic refresh loop right away, unless one is already queued. """Start the periodic refresh loop right away, unless one is already queued.
Safe to call from anywhere, any number of times.""" Safe to call from anywhere, any number of times."""
if not cache.add(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()): loop_id = uuid4().hex
if not cache.add(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout()):
return False return False
refresh_steam_games_task.enqueue() refresh_steam_games_task.enqueue(loop_id=loop_id)
return True return True
@task @task
def refresh_steam_games_task( def refresh_steam_games_task(
game_ids: list[int] | None = None, reschedule: bool = True game_ids: list[int] | None = None, loop_id: str | None = None
): ):
"""Refresh Steam store info, then re-enqueue itself every """Refresh Steam store info, then re-enqueue itself every
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
sync_event_game_scopes_task, no external scheduler needed). sync_event_game_scopes_task, no external scheduler needed).
reschedule=False is a one-off run (e.g. the admin action) that leaves the Without loop_id it's a one-off run (e.g. the admin action) that leaves the
periodic loop alone.""" periodic loop alone. With loop_id, it only continues while the guard still
holds this loop_id."""
if loop_id is not None and cache.get(STEAM_REFRESH_GUARD_KEY) != loop_id:
# Another loop owns the guard (or it expired), stop this one
return
try: try:
refresh_steam_games(game_ids) refresh_steam_games(game_ids)
finally: finally:
if reschedule: if loop_id is not None:
cache.set(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()) cache.set(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout())
refresh_steam_games_task.using( refresh_steam_games_task.using(
run_after=timezone.now() + steam_refresh_interval() run_after=timezone.now() + steam_refresh_interval()
).enqueue() ).enqueue(loop_id=loop_id)
+20 -5
View File
@@ -9,7 +9,11 @@ from events.models import Event
from .models import EventGame, Game from .models import EventGame, Game
from .services.steam_store import refresh_steam_games from .services.steam_store import refresh_steam_games
from .tasks import refresh_steam_games_task, schedule_steam_refresh from .tasks import (
STEAM_REFRESH_GUARD_KEY,
refresh_steam_games_task,
schedule_steam_refresh,
)
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@@ -83,21 +87,32 @@ class ScheduleSteamRefreshTests(TestCase):
def test_only_one_loop_is_scheduled(self, task): def test_only_one_loop_is_scheduled(self, task):
self.assertTrue(schedule_steam_refresh()) self.assertTrue(schedule_steam_refresh())
self.assertFalse(schedule_steam_refresh()) self.assertFalse(schedule_steam_refresh())
task.enqueue.assert_called_once_with() task.enqueue.assert_called_once_with(loop_id=cache.get(STEAM_REFRESH_GUARD_KEY))
@mock.patch("games.tasks.refresh_steam_games") @mock.patch("games.tasks.refresh_steam_games")
def test_task_reschedules_itself_even_on_failure(self, refresh): def test_task_reschedules_itself_even_on_failure(self, refresh):
cache.set(STEAM_REFRESH_GUARD_KEY, "loop")
refresh.side_effect = RuntimeError("boom") refresh.side_effect = RuntimeError("boom")
with mock.patch("games.tasks.refresh_steam_games_task") as patched: with mock.patch("games.tasks.refresh_steam_games_task") as patched:
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
refresh_steam_games_task.call() refresh_steam_games_task.call(loop_id="loop")
patched.using.return_value.enqueue.assert_called_once_with() patched.using.return_value.enqueue.assert_called_once_with(loop_id="loop")
@mock.patch("games.tasks.refresh_steam_games")
def test_superseded_loop_stops(self, refresh):
cache.set(STEAM_REFRESH_GUARD_KEY, "new-loop")
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(loop_id="old-loop")
refresh.assert_not_called()
patched.using.assert_not_called()
self.assertEqual(cache.get(STEAM_REFRESH_GUARD_KEY), "new-loop")
@mock.patch("games.tasks.refresh_steam_games") @mock.patch("games.tasks.refresh_steam_games")
def test_one_off_run_does_not_reschedule(self, refresh): def test_one_off_run_does_not_reschedule(self, refresh):
with mock.patch("games.tasks.refresh_steam_games_task") as patched: with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(game_ids=[1], reschedule=False) refresh_steam_games_task.call(game_ids=[1])
refresh.assert_called_once_with([1]) refresh.assert_called_once_with([1])
patched.using.assert_not_called() patched.using.assert_not_called()
+2 -2
View File
@@ -31,7 +31,7 @@ onMounted(async () => {
<template> <template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12"> <div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-6xl"> <div class="w-full max-w-7xl">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1> <h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
@@ -47,7 +47,7 @@ onMounted(async () => {
{{ t("games.empty") }} {{ t("games.empty") }}
</p> </p>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" /> <SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</div> </div>
</div> </div>
+1 -1
View File
@@ -53,7 +53,7 @@ onMounted(async () => {
<template> <template>
<div class="flex-1 bg-base-300 flex items-center justify-center px-4"> <div class="flex-1 bg-base-300 flex items-center justify-center px-4">
<div class="w-full max-w-6xl"> <div class="w-full max-w-7xl">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1> <h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
@@ -20,41 +20,41 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
<template> <template>
<div <div
class="steam-card flex flex-col overflow-hidden rounded-lg shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]"> class="card card-xl bg-base-200 hover:bg-base-100 overflow-hidden shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"> <a :href="game.store_url" target="_blank" rel="noopener noreferrer">
<img :src="game.header_image" :alt="game.name" loading="lazy" <img :src="game.header_image" :alt="game.name" loading="lazy"
class="w-full aspect-[460/215] object-cover" /> class="w-full aspect-[460/215] object-cover" />
</a> </a>
<div class="flex flex-col flex-1 min-w-0 p-6 gap-3"> <div class="card-body min-w-0">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" <a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="steam-title text-2xl font-semibold truncate hover:underline"> class="card-title text-2xl block truncate hover:underline">
{{ t("games.buy", { game: game.name }) }} {{ t("games.buy", { game: game.name }) }}
</a> </a>
<p v-if="game.short_description" class="steam-text line-clamp-3"> <p v-if="game.short_description" class="text-sm text-base-content/70 line-clamp-3">
{{ game.short_description }} {{ game.short_description }}
</p> </p>
<div class="mt-auto pt-2 flex flex-wrap justify-end gap-2"> <div class="mt-auto pt-2 flex flex-wrap sm:flex-nowrap justify-end gap-2 whitespace-nowrap">
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise --> <!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`" <a :href="`steam://run/${game.steam_app_id}`"
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1"> class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-play"></i> <i class="mdi mdi-play"></i>
{{ t("games.play") }} {{ t("games.play") }}
</a> </a>
<div class="steam-purchase flex items-stretch rounded-sm overflow-hidden"> <div class="bg-base-300 flex items-stretch rounded-sm overflow-hidden">
<template v-if="game.is_free"> <template v-if="game.is_free">
<span class="steam-price px-3 py-2 flex items-center">{{ t("games.free") }}</span> <span class="px-3 py-2 flex items-center">{{ t("games.free") }}</span>
</template> </template>
<template v-else-if="hasPrice"> <template v-else-if="hasPrice">
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium"> <span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
-{{ game.discount_percent }}% -{{ game.discount_percent }}%
</span> </span>
<span class="steam-price px-3 flex flex-col justify-center leading-tight"> <span class="px-3 flex flex-col justify-center leading-tight">
<span v-if="isDiscounted" class="steam-original text-xs line-through"> <span v-if="isDiscounted" class="text-base-content/50 text-xs line-through">
{{ formatPrice(game.price_initial!) }} {{ formatPrice(game.price_initial!) }}
</span> </span>
<span :class="{ 'steam-discounted': isDiscounted }">{{ formatPrice(game.price_final!) }}</span> <span :class="{ 'text-success font-medium': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
</span> </span>
</template> </template>
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" <a :href="game.store_url" target="_blank" rel="noopener noreferrer"
@@ -69,45 +69,12 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
</template> </template>
<style scoped> <style scoped>
/* Steam store palette, on purpose regardless of the site theme */ /* Steam palette for the purchase buttons, the card itself follows the site theme */
.steam-card {
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
}
/* Lightens on hover, like the homepage cards' hover:bg-base-100 */
.steam-card:hover {
background: linear-gradient(135deg, #22344a 0%, #335672 100%);
}
.steam-title {
color: #ffffff;
}
.steam-text {
color: #c6d4df;
}
.steam-purchase {
background: rgba(0, 0, 0, 0.2);
}
.steam-discount { .steam-discount {
background: #4c6b22; background: #4c6b22;
color: #beee11; color: #beee11;
} }
.steam-price {
color: #c6d4df;
}
.steam-original {
color: #738895;
}
.steam-discounted {
color: #beee11;
}
.steam-button { .steam-button {
color: #d2efa9; color: #d2efa9;
background: linear-gradient(to right, #75b022 5%, #588a1b 95%); background: linear-gradient(to right, #75b022 5%, #588a1b 95%);