File size: 7,801 Bytes
2bc5c2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | """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())
|