Files
agents/tests/venue/load.py
T
2026-10-11 15:00:22 +07:00

99 lines
4.1 KiB
Python

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