chore: add locust files + testing setup
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django_object_actions import DjangoObjectActions, action
|
||||
|
||||
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)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||
"""Admin interface for CustomUser."""
|
||||
|
||||
# Add custom fields to the user admin
|
||||
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
|
||||
# Make CAS fields readonly in admin
|
||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||
|
||||
changelist_actions = ("create_load_test_users",)
|
||||
|
||||
def get_cas_groups_display(self, obj):
|
||||
"""Display CAS groups in admin list."""
|
||||
return obj.get_cas_groups_display()
|
||||
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
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}).",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
|
||||
|
||||
|
||||
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:
|
||||
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
|
||||
"""
|
||||
grid = bytearray(width * height)
|
||||
count = 0
|
||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
||||
"placed_at"
|
||||
).values_list("x", "y", "color_index")
|
||||
placements = (
|
||||
PixelPlacement.objects.filter(event=event)
|
||||
.order_by("placed_at")
|
||||
.values_list("x", "y", "color_index")
|
||||
)
|
||||
for x, y, color_index in placements.iterator():
|
||||
if x >= width or y >= height:
|
||||
continue
|
||||
|
||||
@@ -207,6 +207,16 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
||||
# Simple CAS Configuration
|
||||
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_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
||||
|
||||
@@ -329,14 +339,12 @@ STORAGES = {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": _s3_storage_options,
|
||||
},
|
||||
# Static files are collected to local disk and served by WhiteNoise in
|
||||
# DEBUG (no need to push every CSS/JS tweak to S3 while developing);
|
||||
# production collects straight to S3 like everything else here.
|
||||
# Static files are always collected to local disk and served by
|
||||
# WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
|
||||
# likewise unconditional) - S3 is only for user-uploaded content
|
||||
# ("default", above), never for the app's own CSS/JS/images.
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
if DEBUG
|
||||
else "storages.backends.s3.S3Storage",
|
||||
**({} if DEBUG else {"OPTIONS": _s3_storage_options}),
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||
from testing_auth_views import TestLoginView
|
||||
from events.models import Event
|
||||
from games.decorators import require_game_enabled
|
||||
from market.views import market_home
|
||||
@@ -112,6 +113,12 @@ urlpatterns = [
|
||||
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
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -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"})
|
||||
@@ -37,10 +37,12 @@ dev = [
|
||||
"django-stubs-ext",
|
||||
"django-types",
|
||||
"ipython",
|
||||
"locust>=2.46.0",
|
||||
"pre-commit",
|
||||
"pyjwt",
|
||||
"pyright",
|
||||
"ruff",
|
||||
"websocket-client",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
Reference in New Issue
Block a user