forked from retoor/devplacepy
feat: add service lock file and multi-worker startup guard with session cache clear
This commit is contained in:
+188
-32
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user