"""100 virtual clients in disposable prod/venue; does not test Wi-Fi radios.""" import json import math from concurrent.futures import ThreadPoolExecutor import statistics import sys import tempfile import threading import time import uuid sys.dont_write_bytecode = True from flow import API, Stand, export_package, fixture def check_load(stand): data = fixture(stand, 'online') venue = stand.apis['venue'] venue.request('/api/games/import', export_package(stand.apis['prod'], data['game'])) game = data['game'] teams = [venue.request('/api/teams', {'gameID': game, 'name': f'TEST load {i}', 'participantsCount': 4}) for i in range(25)] venue.request(f'/api/games/{game}/start', {}, 'PUT') samples = {} lock = threading.Lock() barrier = threading.Barrier(101) ids = [str(uuid.uuid4()) for _ in range(100)] connections = [str(uuid.uuid4()) for _ in range(100)] def measured(name, action): before = time.perf_counter() result = action() with lock: samples.setdefault(name, []).append(time.perf_counter() - before) return result def client(index): public = API(venue.base) team = teams[index // 4] heartbeat = {'deviceID': ids[index], 'connectionID': connections[index], 'teamID': team['ID'], 'password': team['password']} barrier.wait(timeout=30) started = time.monotonic() next_heartbeat = next_story = started moved = False while time.monotonic() - started < 60: now = time.monotonic() if now >= next_heartbeat: result = measured('heartbeat', lambda: public.request('/api/devices/heartbeat', heartbeat)) assert result['enabled'] is True next_heartbeat += 20 if not moved and index % 4 == 0: for code in ('p1', 'p2'): measured('move', lambda code=code: public.move(team, code)) measured('answers', lambda: public.request(f'/api/teams/{team["ID"]}/answers', {'password': team['password'], 'answers': [{'questionCode': 'q1', 'answer': 'Butler'}]})) moved = True if now >= next_story: measured('story', lambda: public.story(team)) next_story += 5 time.sleep(.05) with ThreadPoolExecutor(max_workers=100) as pool: futures = [pool.submit(client, index) for index in range(100)] barrier.wait(timeout=30) deadline = time.monotonic() + 60 while time.monotonic() < deadline: snapshot = measured('devices', lambda: venue.request(f'/api/devices?gameID={game}')) assert len(snapshot['devices']) <= 100 time.sleep(5) for future in futures: future.result() devices = venue.request(f'/api/devices?gameID={game}')['devices'] assert {device['ID'] for device in devices} == set(ids) assert all(device['connected'] and len(device['connections']) == 1 for device in devices) for team in teams: assert len(API(venue.base).story(team)['actions']) == 2 answers = venue.request(f'/api/games/{game}/answers')['teams'] assert {row['team']['ID'] for row in answers if any(answer.get('answer') == 'Butler' for answer in row.get('answers', []))} == {team['ID'] for team in teams} report = {} for name, values in sorted(samples.items()): values.sort() p95 = values[math.ceil(.95 * len(values)) - 1] report[name] = {'requests': len(values), 'p50_ms': round(statistics.median(values) * 1000), 'p95_ms': round(p95 * 1000), 'max_ms': round(values[-1] * 1000)} assert p95 <= 2, f'{name} exceeds the test budget p95 <= 2s' print('PASS: 100 profiles, 25 teams, 60s, 100 connected devices, 50 moves, 25 answers, zero HTTP errors', flush=True) print(json.dumps(report, sort_keys=True), flush=True) def main(): with tempfile.TemporaryDirectory(prefix='ed029-load-') as directory: stand = Stand(directory) try: stand.start() check_load(stand) finally: stand.close() if __name__ == '__main__': main()