chore: add locust files + testing setup

This commit is contained in:
2026-09-19 16:24:19 +02:00
parent b74d20cbe0
commit e8b018ec49
8 changed files with 1256 additions and 14 deletions
+44 -1
View File
@@ -1,10 +1,20 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django_object_actions import DjangoObjectActions, action
from .models import CustomUser from .models import CustomUser
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
# accounts are for and how they're used.
LOAD_TEST_USER_COUNT = 500
LOAD_TEST_USER_PREFIX = "loadtest"
LOAD_TEST_PASSWORD = "loadtest-only-password"
@admin.register(CustomUser) @admin.register(CustomUser)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(DjangoObjectActions, UserAdmin):
"""Admin interface for CustomUser.""" """Admin interface for CustomUser."""
# Add custom fields to the user admin # Add custom fields to the user admin
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
# Make CAS fields readonly in admin # Make CAS fields readonly in admin
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes") readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
changelist_actions = ("create_load_test_users",)
def get_cas_groups_display(self, obj): def get_cas_groups_display(self, obj):
"""Display CAS groups in admin list.""" """Display CAS groups in admin list."""
return obj.get_cas_groups_display() return obj.get_cas_groups_display()
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
def get_changelist_actions(self, request):
# Second layer of the same guard rail as the login endpoint itself
# (see testing_auth_views.py): the button doesn't even show up
# unless this deployment opted into load testing.
if not settings.LOAD_TEST_LOGIN_ENABLED:
return []
return super().get_changelist_actions(request)
@action(
label="Create load test users",
description=(
"Create/reset the local loadtestN accounts used by "
"loadtest/locustfile.py - authenticates via ModelBackend, "
"never the real CAS server"
),
)
def create_load_test_users(self, request, _queryset):
created = 0
for i in range(LOAD_TEST_USER_COUNT):
username = f"{LOAD_TEST_USER_PREFIX}{i}"
user, was_created = CustomUser.objects.get_or_create(username=username)
user.set_password(LOAD_TEST_PASSWORD)
user.save()
created += was_created
self.message_user(
request,
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
)
+233
View File
@@ -0,0 +1,233 @@
"""Locust load test for the place game.
Simulates real players: log in, fetch the canvas, open the same websocket
the frontend uses, and place a pixel as soon as the per-user cooldown
allows - while a background greenlet keeps draining the broadcast stream so
each idle connection costs what a real one costs.
A placement that comes back rejected or errors out (timeout, dropped
connection, ...) has a configurable chance of triggering a "reload" -
tearing the socket down and redoing the page load (HTML + canvas fetch +
fresh websocket), the way a real, mildly frustrated player mashes refresh
instead of quietly waiting. That reconnect-storm behavior - many clients
reloading around the same time - is itself part of what's worth measuring
under load, not just steady-state placement throughput.
Authentication uses local Django accounts (ModelBackend), never the real
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
production.
Setup, on the target server:
export LOAD_TEST_LOGIN_ENABLED=true
Then, in the Django admin, open Accounts > Users and click "Create load
test users" (see accounts/admin.py) - it's only visible with the env
var above set. Creates/resets loadtest0..loadtest499.
Run:
uv run locust -f loadtest/locustfile.py --host http://localhost:7777
Env vars:
PLACE_EVENT_PATH event slug under test (default: "loadtest")
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
RELOAD_ON_FAILURE_PROBABILITY chance a failed placement reloads instead
of just waiting for the next cooldown
(default: 0.7)
"""
import os
import random
import struct
import time
import gevent
import websocket
from gevent.queue import Empty, Queue
from locust import HttpUser, between, events, task
EVENT_PATH = os.environ.get("PLACE_EVENT_PATH", "loadtest")
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
RELOAD_ON_FAILURE_PROBABILITY = float(os.environ.get("RELOAD_ON_FAILURE_PROBABILITY", "0.7"))
# Mirrors place/models.py::PALETTE - kept as a plain constant here since
# loadtest runs standalone, without Django settings loaded.
PALETTE_SIZE = 16
CANVAS_HEADER = struct.Struct("<HH")
_next_account_index = 0
def _next_username() -> str:
"""Round-robins through the pool of pre-created accounts so N Locust
users spread evenly across USER_COUNT real accounts even when N differs
from USER_COUNT."""
global _next_account_index
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
_next_account_index += 1
return username
class PlaceUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
self.username = _next_username()
self._login()
self.width, self.height = self._fetch_canvas_size()
self._connect()
def on_stop(self):
self._teardown_ws()
def _login(self):
response = self.client.post(
"/testing/login/",
data={"username": self.username, "password": PASSWORD},
name="/testing/login/",
)
if response.status_code != 200:
raise RuntimeError(
f"login failed for {self.username}: {response.status_code} "
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
"target, and did you run the 'Create load test users' admin "
"action (accounts/admin.py)?"
)
def _fetch_canvas_size(self) -> tuple[int, int]:
response = self.client.get(
f"/api/events/{EVENT_PATH}/place/canvas",
name="/api/events/[event]/place/canvas",
)
width, height = CANVAS_HEADER.unpack_from(response.content, 0)
return width, height
def _connect(self):
"""(Re)opens the websocket and a fresh reader greenlet, then syncs
next_placement_at to the server's own cooldown clock - every
connection starts with an 's|<next>|...' message (see
place/consumers.py::_initial_state) - rather than trusting local
bookkeeping. That sync is exactly what makes a page reload
meaningful to simulate: it re-anchors client state to the server's
truth instead of just reopening a blank socket."""
self.ws = self._open_ws()
self.next_placement_at = self._read_initial_next_placement_at()
self.reply_queue = Queue()
self._reader = gevent.spawn(self._reader_loop)
def _teardown_ws(self):
reader = getattr(self, "_reader", None)
if reader is not None:
reader.kill(block=False)
ws = getattr(self, "ws", None)
if ws is not None:
try:
ws.close()
except Exception:
pass
def _reload(self):
"""What a real player does after a placement fails: hit refresh.
Tears the socket down and redoes the page load over HTTP plus a
brand-new websocket - the same reconnect churn a real outage or
rejection wave would cause."""
self._teardown_ws()
self.client.get(f"/{EVENT_PATH}/place", name="/[event]/place (reload)")
self.width, self.height = self._fetch_canvas_size()
self._connect()
def _open_ws(self):
scheme = "wss" if self.client.base_url.startswith("https") else "ws"
host = self.client.base_url.split("://", 1)[1].rstrip("/")
url = f"{scheme}://{host}/ws/place/{EVENT_PATH}/"
cookie_header = "; ".join(
f"{name}={value}" for name, value in self.client.cookies.get_dict().items()
)
start = time.perf_counter()
exc = None
ws = None
try:
ws = websocket.create_connection(
url, header=[f"Cookie: {cookie_header}"], timeout=10
)
except Exception as e:
exc = e
events.request.fire(
request_type="WS",
name="ws_connect",
response_time=(time.perf_counter() - start) * 1000,
response_length=0,
exception=exc,
)
if exc is not None:
raise exc
return ws
def _read_initial_next_placement_at(self) -> float:
message = self.ws.recv()
next_ms = message.split("|")[1]
return int(next_ms) / 1000 if next_ms else 0.0
def _reader_loop(self):
"""The only greenlet that ever calls ws.recv() - place_pixel below
only ever writes, and reads back via reply_queue, so there's no race
between two greenlets reading the same socket. Broadcast pixels
from other players (and 'e|...' resize messages) are read and
dropped here, same as a real idle viewer."""
while True:
try:
message = self.ws.recv()
except Exception:
return
if not message:
return
if message[0] in ("k", "r"):
self.reply_queue.put(message)
@task
def place_pixel(self):
now = time.time()
if now < self.next_placement_at:
return # still on cooldown - a real client wouldn't send either
x = random.randrange(self.width)
y = random.randrange(self.height)
color = random.randrange(PALETTE_SIZE)
message = f"p|{x}|{y}|{color}"
start = time.perf_counter()
exc = None
reply = None
try:
self.ws.send(message)
reply = self.reply_queue.get(timeout=10)
except (Empty, Exception) as e:
exc = e
events.request.fire(
request_type="WS",
name="place_pixel",
response_time=(time.perf_counter() - start) * 1000,
response_length=len(reply) if reply else 0,
exception=exc,
)
accepted = reply is not None and reply.startswith("k|")
# Both an accepted placement (k|<next>) and a cooldown rejection
# (r|c|<next>) carry the server's authoritative cooldown end - sync
# to it either way, for the same reason _connect syncs off 's|...':
# it keeps us honest against clock drift and against several
# simulated users sharing one round-robined account, instead of
# hammering the server with attempts it will just keep rejecting.
if reply is not None and reply.startswith(("k|", "r|c|")):
next_ms = reply.rsplit("|", 1)[1]
self.next_placement_at = int(next_ms) / 1000
if not accepted and random.random() < RELOAD_ON_FAILURE_PROBABILITY:
self._reload()
+6 -4
View File
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
def _canvas_key(event_path: str) -> str: def _canvas_key(event_path: str) -> str:
return f"place:canvas:{event_path}" return f"place:{event_path}:canvas"
def read_canvas(event_path: str, width: int, height: int) -> bytes: def read_canvas(event_path: str, width: int, height: int) -> bytes:
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
""" """
grid = bytearray(width * height) grid = bytearray(width * height)
count = 0 count = 0
placements = PixelPlacement.objects.filter(event=event).order_by( placements = (
"placed_at" PixelPlacement.objects.filter(event=event)
).values_list("x", "y", "color_index") .order_by("placed_at")
.values_list("x", "y", "color_index")
)
for x, y, color_index in placements.iterator(): for x, y, color_index in placements.iterator():
if x >= width or y >= height: if x >= width or y >= height:
continue continue
@@ -207,6 +207,16 @@ AUTH_USER_MODEL = "accounts.CustomUser"
# Simple CAS Configuration # Simple CAS Configuration
CAS_SERVER_URL = "https://polylan.ch/cas/" CAS_SERVER_URL = "https://polylan.ch/cas/"
# Enables POST /testing/login/ (see testing_auth_views.py), a
# username/password login for local-only accounts that authenticate through
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
# loadtest/locustfile.py) never sends traffic to the real CAS server at
# polylan.ch. Leave this off everywhere except a dedicated load-test
# environment - the "Create load test users" admin action on CustomUser
# (see accounts/admin.py) also hides itself without it, as a second layer
# of the same guard rail.
LOAD_TEST_LOGIN_ENABLED = (os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true")
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
@@ -329,14 +339,12 @@ STORAGES = {
"BACKEND": "storages.backends.s3.S3Storage", "BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": _s3_storage_options, "OPTIONS": _s3_storage_options,
}, },
# Static files are collected to local disk and served by WhiteNoise in # Static files are always collected to local disk and served by
# DEBUG (no need to push every CSS/JS tweak to S3 while developing); # WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
# production collects straight to S3 like everything else here. # likewise unconditional) - S3 is only for user-uploaded content
# ("default", above), never for the app's own CSS/JS/images.
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
if DEBUG
else "storages.backends.s3.S3Storage",
**({} if DEBUG else {"OPTIONS": _s3_storage_options}),
}, },
} }
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from testing_auth_views import TestLoginView
from events.models import Event from events.models import Event
from games.decorators import require_game_enabled from games.decorators import require_game_enabled
from market.views import market_home from market.views import market_home
@@ -112,6 +113,12 @@ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
] ]
# Load-test-only username/password login (see testing_auth_views.py) - the
# URL itself doesn't exist unless explicitly enabled, so it 404s rather than
# 403s in every environment that hasn't opted in.
if settings.LOAD_TEST_LOGIN_ENABLED:
urlpatterns.append(path("testing/login/", TestLoginView.as_view(), name="testing.login"))
# Serve media files in development # Serve media files in development
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+34
View File
@@ -0,0 +1,34 @@
"""Test-only username/password login.
Lets load-testing tools (see loadtest/locustfile.py) authenticate local
Django accounts through AUTHENTICATION_BACKENDS' ModelBackend, entirely
bypassing SimpleCASBackend - so hammering this app under load never sends a
single request to the real CAS server at polylan.ch.
Only wired up in urls.py when settings.LOAD_TEST_LOGIN_ENABLED is true, and
that must stay off in production: it lets anyone who knows a local test
account's password log in without going through CAS at all. The "Create
load test users" admin action on CustomUser (see accounts/admin.py) is
hidden unless that same flag is set, as a second layer of the same guard
rail.
"""
from django.contrib.auth import authenticate, login
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@method_decorator(csrf_exempt, name="dispatch")
class TestLoginView(View):
def post(self, request: HttpRequest):
username = request.POST.get("username")
password = request.POST.get("password")
user = authenticate(request, username=username, password=password)
if user is None:
return JsonResponse({"detail": "invalid credentials"}, status=401)
login(request, user)
return JsonResponse({"detail": "ok"})
+2
View File
@@ -37,10 +37,12 @@ dev = [
"django-stubs-ext", "django-stubs-ext",
"django-types", "django-types",
"ipython", "ipython",
"locust>=2.46.0",
"pre-commit", "pre-commit",
"pyjwt", "pyjwt",
"pyright", "pyright",
"ruff", "ruff",
"websocket-client",
] ]
[tool.uv.workspace] [tool.uv.workspace]
Generated
+915 -2
View File
File diff suppressed because it is too large Load Diff