feat: add service lock file and multi-worker startup guard with session cache clear

This commit is contained in:
2026-06-02 21:17:51 +00:00
parent a136287d36
commit 13870d3219
30 changed files with 406 additions and 103 deletions
+188 -32
View File
@@ -23,6 +23,7 @@ NEWS_UIDS = []
NEWS_SLUGS = []
GIST_SLUGS = []
GIST_UIDS = []
BUG_UIDS = []
NOTIFICATION_UIDS = []
ADMIN_USER = {}
ADMIN_TARGETS = {}
@@ -30,10 +31,6 @@ TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
AVATAR_STYLES = [
"adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon",
"initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes",
]
UUID_RE = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
PASSWORD = "testpass123"
@@ -182,6 +179,20 @@ def seed_data(environment, **kwargs):
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
for seed in SEED_USERS[:2]:
try:
opener = logged_in_opener(seed["email"])
body = urllib.parse.urlencode({
"title": f"Bug {uuid.uuid4().hex[:6]}",
"description": "Seed bug report for load testing.",
}).encode()
opener.open(
urllib.request.Request(f"{host}/bugs/create", data=body),
timeout=10,
)
except Exception as e:
logger.warning(f"Create seed bug failed: {e}")
# ── Seed news articles (direct DB insert) ───────────────────
try:
from devplacepy.database import get_table, init_db
@@ -298,11 +309,20 @@ def seed_data(environment, **kwargs):
except Exception as e:
logger.warning(f"Harvest comment UIDs failed: {e}")
try:
opener = logged_in_opener(SEED_USERS[0]["email"])
resp = opener.open(urllib.request.Request(f"{host}/bugs"), timeout=10)
html = resp.read().decode("utf-8", errors="replace")
bug_matches = re.findall(rf'/votes/bug/{uuid_pat}', html)
BUG_UIDS.extend(b for b in bug_matches if b not in BUG_UIDS)
except Exception as e:
logger.warning(f"Harvest bug UIDs failed: {e}")
logger.info(
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
f"{len(USER_UIDS)} uids"
f"{len(BUG_UIDS)} bugs, {len(USER_UIDS)} uids"
)
@@ -334,7 +354,11 @@ class DevPlaceUser(HttpUser):
params = {"tab": tab}
if topic:
params["topic"] = topic
self.client.get("/feed", params=params, name="feed")
resp = self.client.get("/feed", params=params, name="feed")
cursor = re.search(r'/feed\?before=([^"&]+)', resp.text)
if cursor:
params["before"] = cursor.group(1)
self.client.get("/feed", params=params, name="feed?before")
@task(4)
def view_post(self):
@@ -350,14 +374,19 @@ class DevPlaceUser(HttpUser):
if not ALL_USERNAMES:
return
self.client.get(
f"/profile/{random.choice(ALL_USERNAMES)}", name="profile/[username]"
f"/profile/{random.choice(ALL_USERNAMES)}",
params={"tab": random.choice(["posts", "activity"])},
name="profile/[username]",
)
@task(3)
def view_projects(self):
self.client.get("/projects", params={
"tab": random.choice(["recent", "released", "popular", "new"]),
}, name="projects")
params = {"tab": random.choice(["recent", "released", "popular", "new"])}
if random.random() < 0.3:
params["project_type"] = random.choice(PROJECT_TYPES)
if random.random() < 0.2 and USER_UIDS:
params["user_uid"] = random.choice(USER_UIDS)
self.client.get("/projects", params=params, name="projects")
@task(1)
def view_landing(self):
@@ -396,7 +425,23 @@ class DevPlaceUser(HttpUser):
@task(2)
def view_gists(self):
self.client.get("/gists", name="gists")
params = {}
if random.random() < 0.3:
params["language"] = random.choice(GIST_LANGUAGES)
if random.random() < 0.2 and USER_UIDS:
params["user_uid"] = random.choice(USER_UIDS)
self.client.get("/gists", params=params, name="gists")
@task(1)
def view_leaderboard(self):
self.client.get("/leaderboard", name="leaderboard")
@task(1)
def view_pwa(self):
self.client.get(
random.choice(["/push.json", "/service-worker.js", "/manifest.json"]),
name="pwa",
)
@task(2)
def view_gist_detail(self):
@@ -551,45 +596,58 @@ class DevPlaceUser(HttpUser):
# ── social interaction ──────────────────────────────────────
def _vote(self, target_type, uid):
data = {"value": random.choice([1, -1])}
name = f"votes/{target_type}"
if random.random() < 0.5:
self.client.post(
f"/votes/{target_type}/{uid}", data=data, name=name
)
return
with self.client.post(
f"/votes/{target_type}/{uid}", data=data,
headers={"x-requested-with": "fetch"},
catch_response=True, name=f"{name}/ajax",
) as resp:
try:
payload = resp.json()
except ValueError:
resp.failure("vote ajax response not JSON")
return
if all(k in payload for k in ("net", "up", "down", "value")):
resp.success()
else:
resp.failure(f"vote ajax missing keys: {payload}")
@task(2)
def vote_on_post(self):
if not POST_UIDS:
return
self.client.post(
f"/votes/post/{random.choice(POST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/post",
)
self._vote("post", random.choice(POST_UIDS))
@task(1)
def vote_on_project(self):
if not PROJECT_UIDS:
return
self.client.post(
f"/votes/project/{random.choice(PROJECT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/project",
)
self._vote("project", random.choice(PROJECT_UIDS))
@task(1)
def vote_on_comment(self):
if not COMMENT_UIDS:
return
self.client.post(
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/comment",
)
self._vote("comment", random.choice(COMMENT_UIDS))
@task(1)
def vote_on_gist(self):
if not GIST_UIDS:
return
self.client.post(
f"/votes/gist/{random.choice(GIST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/gist",
)
self._vote("gist", random.choice(GIST_UIDS))
@task(1)
def vote_on_bug(self):
if not BUG_UIDS:
return
self._vote("bug", random.choice(BUG_UIDS))
@task(1)
def follow_user(self):
@@ -632,6 +690,15 @@ class DevPlaceUser(HttpUser):
def view_messages(self):
self.client.get("/messages", name="messages")
@task(1)
def view_conversation(self):
if not USER_UIDS:
return
self.client.get(
"/messages", params={"with_uid": random.choice(USER_UIDS)},
name="messages?with_uid",
)
@task(1)
def view_notifications(self):
resp = self.client.get("/notifications", name="notifications")
@@ -639,6 +706,21 @@ class DevPlaceUser(HttpUser):
NOTIFICATION_UIDS.extend(
m for m in matches if m not in NOTIFICATION_UIDS
)
cursor = re.search(r'/notifications\?before=([^"]+)', resp.text)
if cursor:
self.client.get(
"/notifications", params={"before": cursor.group(1)},
name="notifications?before",
)
@task(1)
def open_notification(self):
if not NOTIFICATION_UIDS:
return
self.client.get(
f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}",
name="notifications/open",
)
@task(1)
def mark_all_notifications_read(self):
@@ -741,14 +823,45 @@ class DevPlaceUser(HttpUser):
else:
resp.failure(f"upload failed: status={resp.status_code}")
@task(1)
def register_push(self):
token = uuid.uuid4().hex
body = {
"endpoint": f"https://push.locust/endpoint/{token}",
"keys": {
"auth": uuid.uuid4().hex[:22],
"p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43],
},
}
with self.client.post(
"/push.json", json=body,
catch_response=True, name="push.json/register",
) as resp:
if resp.status_code in (200, 400):
resp.success()
else:
resp.failure(f"push register: status={resp.status_code}")
# ── static / media ──────────────────────────────────────────
@task(2)
def view_multiavatar(self):
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
self.client.get(
resp = self.client.get(
f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
)
etag = resp.headers.get("ETag")
if not etag:
return
with self.client.get(
f"/avatar/multiavatar/{seed}?size=32",
headers={"If-None-Match": etag},
catch_response=True, name="avatar/multiavatar/304",
) as cached:
if cached.status_code == 304:
cached.success()
else:
cached.failure(f"expected 304, got {cached.status_code}")
@task(1)
def comment_on_news(self):
@@ -760,6 +873,49 @@ class DevPlaceUser(HttpUser):
"target_type": "news",
}, name="comments/news")
@task(1)
def comment_on_target(self):
pools = []
if PROJECT_UIDS:
pools.append(("project", PROJECT_UIDS))
if GIST_UIDS:
pools.append(("gist", GIST_UIDS))
if BUG_UIDS:
pools.append(("bug", BUG_UIDS))
if not pools:
return
target_type, uids = random.choice(pools)
self.client.post("/comments/create", data={
"content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2,
"target_uid": random.choice(uids),
"target_type": target_type,
}, name=f"comments/{target_type}")
@task(1)
def reply_to_comment(self):
if not POST_UIDS or not COMMENT_UIDS:
return
self.client.post("/comments/create", data={
"content": f"Reply {uuid.uuid4().hex[:6]} " * 2,
"post_uid": random.choice(POST_UIDS),
"parent_uid": random.choice(COMMENT_UIDS),
}, name="comments/reply")
@task(2)
def browse_and_engage(self):
slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
if not slugs:
return
resp = self.client.get(
f"/posts/{random.choice(slugs)}", name="posts/[uid]"
)
vote_uid = re.search(rf'/votes/post/({UUID_RE})', resp.text)
if vote_uid:
self._vote("post", vote_uid.group(1))
comment_uid = re.search(rf'/comments/delete/({UUID_RE})', resp.text)
if comment_uid and comment_uid.group(1) not in COMMENT_UIDS:
COMMENT_UIDS.append(comment_uid.group(1))
class AdminUser(HttpUser):
weight = 1