"""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())