| """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 |
|
|
| os.environ["DATA_DIR"] = str(TEST_DIR) |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
| from app import config, db, maintenance |
| from app.auth import CurrentUser |
| from app.services import groups_for_user |
|
|
| |
| |
| 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, |
| "b" * 32, |
| now, |
| ) |
| for index, phone in enumerate(phones) |
| ], |
| ) |
|
|
| group_ids = [] |
| for index in range(groups): |
| |
| 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) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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()) |
|
|