MSG_MOTOC / scripts /load_test.py
Samuel ADONE
Inscription libre, import de tableur, épinglage, signalement, jeu de test
2bc5c2a
Raw
History Blame Contribute Delete
7.8 kB
"""Mesure l'occupation disque et les temps de réponse à l'échelle du festival.
Cible : 1400 bénévoles, 110 groupes, 500 messages par groupe, 5 jours.
Le tier gratuit Hugging Face offre 1 Go persistant : ce script vérifie qu'on
reste très en dessous, et que les écrans les plus sollicités répondent vite
une fois la base pleine.
Usage : python scripts/load_test.py [--users 1400] [--groups 110] [--messages 500]
La base est créée dans un répertoire temporaire dédié et supprimée à la fin.
"""
from __future__ import annotations
import argparse
import random
import shutil
import statistics
import sys
import time
from pathlib import Path
TEST_DIR = Path("/tmp/motoc-load")
if TEST_DIR.exists():
shutil.rmtree(TEST_DIR)
TEST_DIR.mkdir(parents=True)
import os # noqa: E402
os.environ["DATA_DIR"] = str(TEST_DIR)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import config, db, maintenance # noqa: E402
from app.auth import CurrentUser # noqa: E402
from app.services import groups_for_user # noqa: E402
# Message représentatif : les messages de coordination sont courts, mais on
# prend une longueur généreuse pour ne pas sous-estimer l'occupation.
SAMPLE = (
"Point de situation : équipe en place au bar 2, il manque deux personnes "
"pour la relève de 18h, merci de confirmer votre présence."
)
def mb(value: int) -> str:
return f"{value / 1024 / 1024:.1f} Mo"
def build(users: int, groups: int, messages: int) -> None:
now = db.now_ms()
festival_ms = 5 * 86_400_000
phones = [f"06{i:08d}" for i in range(users)]
db.execute_many(
"INSERT INTO users(phone, first_name, last_name, nickname, pin_hash, pin_salt, "
"status, created_at) VALUES(?, ?, ?, ?, ?, ?, 'active', ?)",
[
(
phone,
f"Prénom{index}",
f"NOMDEFAMILLE{index}",
f"Pseudo{index}",
"a" * 64, # longueur d'un hash scrypt hexadécimal
"b" * 32,
now,
)
for index, phone in enumerate(phones)
],
)
group_ids = []
for index in range(groups):
# Un tiers de sous-groupes, comme dans l'organisation réelle.
parent = group_ids[index % max(1, len(group_ids))] if index > 2 and index % 3 == 0 else None
cur = db.execute(
"INSERT INTO groups(name, parent_id, description, created_at) VALUES(?, ?, ?, ?)",
(f"Groupe {index}", parent, "Description du groupe", now),
)
group_ids.append(cur.lastrowid)
# Chaque bénévole appartient à 3 groupes en moyenne.
memberships = []
for index, phone in enumerate(phones):
for offset in range(3):
memberships.append((group_ids[(index * 3 + offset) % groups], phone, "member", now))
db.execute_many(
"INSERT OR IGNORE INTO memberships(group_id, phone, role, joined_at) VALUES(?, ?, ?, ?)",
memberships,
)
rows = []
for gid in group_ids:
for index in range(messages):
author = phones[(gid + index) % users]
rows.append(
(
gid,
author,
f"Pseudo{(gid + index) % users}",
"text",
SAMPLE,
None,
now - festival_ms + int(index / messages * festival_ms),
)
)
db.execute_many(
"INSERT INTO messages(group_id, author_phone, author_label, kind, body, meta, created_at) "
"VALUES(?, ?, ?, ?, ?, ?, ?)",
rows,
)
# État de lecture : un enregistrement par couple (bénévole, groupe).
db.execute_many(
"INSERT OR IGNORE INTO read_state(phone, group_id, last_read_id) VALUES(?, ?, ?)",
[(phone, gid, 0) for gid, phone, _, _ in memberships],
)
def timed(label: str, function, repeats: int = 5) -> float:
samples = []
for _ in range(repeats):
start = time.perf_counter()
function()
samples.append((time.perf_counter() - start) * 1000)
median = statistics.median(samples)
flag = "\033[32m✓\033[0m" if median < 400 else "\033[33m!\033[0m"
print(f" {flag} {label:<46} {median:7.1f} ms")
return median
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--users", type=int, default=1400)
parser.add_argument("--groups", type=int, default=110)
parser.add_argument("--messages", type=int, default=500)
args = parser.parse_args()
total_messages = args.groups * args.messages
print(
f"\n\033[1mCharge simulée : {args.users} bénévoles, {args.groups} groupes, "
f"{total_messages} messages\033[0m\n"
)
db.connect()
start = time.perf_counter()
build(args.users, args.groups, args.messages)
print(f" base construite en {time.perf_counter() - start:.1f} s")
size = db.db_size_bytes()
quota = 1024 * 1024 * 1024
print(f"\n\033[1mOccupation disque\033[0m")
print(f" base complète {mb(size)}")
print(f" part du quota de 1 Go {size / quota * 100:.1f} %")
print(f" par message {size / total_messages:.0f} o")
print(f"\n\033[1mTemps de réponse, base pleine\033[0m")
phones = [r["phone"] for r in db.query("SELECT phone FROM users LIMIT 50")]
member = CurrentUser(
phone=phones[0], first_name="A", last_name="B", nickname="P",
is_superadmin=False, status="active",
)
admin = CurrentUser(
phone=phones[1], first_name="A", last_name="B", nickname="P",
is_superadmin=True, status="active",
)
gid = db.query_one("SELECT id FROM groups LIMIT 1")["id"]
worst = 0.0
worst = max(worst, timed("liste des groupes (bénévole, 3 groupes)", lambda: groups_for_user(member)))
worst = max(worst, timed(f"liste des groupes (superadmin, {args.groups} groupes)", lambda: groups_for_user(admin)))
worst = max(
worst,
timed(
"historique d'un groupe (50 messages)",
lambda: db.query(
"SELECT * FROM messages WHERE group_id = ? ORDER BY id DESC LIMIT 50", (gid,)
),
),
)
worst = max(
worst,
timed(
"annuaire, recherche par nom (200 résultats)",
lambda: db.query(
"SELECT * FROM users WHERE nickname LIKE ? ORDER BY flagged DESC, status, "
"nickname COLLATE NOCASE LIMIT 200",
("%Pseudo1%",),
),
),
)
worst = max(
worst,
timed(
"compteurs d'adhésions (annuaire complet)",
lambda: db.query("SELECT phone, COUNT(*) AS n FROM memberships GROUP BY phone"),
),
)
print(f"\n\033[1mPurge de rétention\033[0m")
print(f" quota configuré : {config.KEEP_MESSAGES_PER_GROUP} messages/groupe, "
f"{config.RETENTION_DAYS} jours")
start = time.perf_counter()
result = maintenance.purge(aggressive=False)
elapsed = time.perf_counter() - start
print(f" purge exécutée en {elapsed:.1f} s")
print(f" supprimés : {result['deleted_age']} par âge, {result['deleted_overflow']} par quota")
print(f" taille après purge et VACUUM : {mb(result['size_after'])}")
db.close()
shutil.rmtree(TEST_DIR, ignore_errors=True)
ok = size < quota * 0.25 and worst < 400
print(
f"\n\033[1m{'✓ Marge confortable' if ok else '! À surveiller'} — "
f"{mb(size)} sur 1 Go, requête la plus lente {worst:.0f} ms\033[0m\n"
)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())