StressTest.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. #!/usr/bin/env python3
  2. # Contest Management System - http://cms-dev.github.io/
  3. # Copyright © 2010-2012 Giovanni Mascellani <mascellani@poisson.phc.unipi.it>
  4. # Copyright © 2010-2017 Stefano Maggiolo <s.maggiolo@gmail.com>
  5. # Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
  6. # Copyright © 2014 Artem Iglikov <artem.iglikov@gmail.com>
  7. # Copyright © 2016 Luca Wehrstedt <luca.wehrstedt@gmail.com>
  8. # Copyright © 2017 Luca Chiodini <luca@chiodini.org>
  9. #
  10. # This program is free software: you can redistribute it and/or modify
  11. # it under the terms of the GNU Affero General Public License as
  12. # published by the Free Software Foundation, either version 3 of the
  13. # License, or (at your option) any later version.
  14. #
  15. # This program is distributed in the hope that it will be useful,
  16. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. # GNU Affero General Public License for more details.
  19. #
  20. # You should have received a copy of the GNU Affero General Public License
  21. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  22. import argparse
  23. import ast
  24. import os
  25. import random
  26. import sys
  27. import threading
  28. import time
  29. import cmstestsuite.web
  30. from cms import config, ServiceCoord, get_service_address, utf8_decoder
  31. from cms.db import Contest, SessionGen
  32. from cmscommon.crypto import parse_authentication
  33. from cmstestsuite.web import Browser
  34. from cmstestsuite.web.CWSRequests import HomepageRequest, CWSLoginRequest, \
  35. TaskRequest, TaskStatementRequest, SubmitRandomRequest
  36. cmstestsuite.web.debug = True
  37. class RequestLog:
  38. def __init__(self, log_dir=None):
  39. self.total = 0
  40. self.success = 0
  41. self.failure = 0
  42. self.error = 0
  43. self.undecided = 0
  44. self.total_time = 0.0
  45. self.max_time = 0.0
  46. self.log_dir = log_dir
  47. if self.log_dir is not None:
  48. try:
  49. os.makedirs(self.log_dir)
  50. except OSError:
  51. pass
  52. def print_stats(self):
  53. print("TOTAL: %5d" % (self.total), file=sys.stderr)
  54. print("SUCCESS: %5d" % (self.success), file=sys.stderr)
  55. print("FAIL: %5d" % (self.failure), file=sys.stderr)
  56. print("ERROR: %5d" % (self.error), file=sys.stderr)
  57. print("UNDECIDED: %5d" % (self.undecided), file=sys.stderr)
  58. print("Total time: %7.3f" % (self.total_time), file=sys.stderr)
  59. print("Average time: %7.3f" % (self.total_time / self.total),
  60. file=sys.stderr)
  61. print("Max time: %7.3f" % (self.max_time), file=sys.stderr)
  62. def merge(self, log2):
  63. self.total += log2.total
  64. self.success += log2.success
  65. self.failure += log2.failure
  66. self.error += log2.error
  67. self.undecided += log2.undecided
  68. self.total_time += log2.total_time
  69. self.max_time = max(self.max_time, log2.max_time)
  70. def store_to_file(self, request):
  71. if self.log_dir is None:
  72. return
  73. filename = "%s_%s.log" % (request.start_time,
  74. request.__class__.__name__)
  75. filepath = os.path.join(self.log_dir, filename)
  76. linkpath = os.path.join(self.log_dir, request.__class__.__name__)
  77. with open(filepath, 'wt', encoding='utf-8') as fd:
  78. request.store_to_file(fd)
  79. try:
  80. os.remove(linkpath)
  81. except OSError:
  82. pass
  83. os.symlink(filename, linkpath)
  84. class ActorDying(Exception):
  85. """Exception to be raised when an Actor is going to die soon. See
  86. Actor class.
  87. """
  88. pass
  89. class Actor(threading.Thread):
  90. """Class that simulates the behaviour of a user of the system. It
  91. performs some requests at randomized times (checking CMS pages,
  92. doing submissions, ...), checking for their success or failure.
  93. The probability that the users doing actions depends on the value
  94. specified in an object called "metrics".
  95. """
  96. def __init__(self, username, password, metrics, tasks,
  97. log=None, base_url=None, submissions_path=None):
  98. threading.Thread.__init__(self)
  99. self.username = username
  100. self.password = password
  101. self.metrics = metrics
  102. self.tasks = tasks
  103. self.log = log
  104. self.base_url = base_url
  105. self.submissions_path = submissions_path
  106. self.name = "Actor thread for user %s" % (self.username)
  107. self.browser = Browser()
  108. self.die = False
  109. def run(self):
  110. try:
  111. print("Starting actor for user %s" % (self.username),
  112. file=sys.stderr)
  113. self.act()
  114. except ActorDying:
  115. print("Actor dying for user %s" % (self.username), file=sys.stderr)
  116. def act(self):
  117. """Define the behaviour of the actor. Subclasses are expected
  118. to overwrite this stub method properly.
  119. """
  120. raise Exception("Not implemented. Please subclass Action"
  121. "and overwrite act().")
  122. def do_step(self, request):
  123. self.wait_next()
  124. self.log.total += 1
  125. try:
  126. request.execute()
  127. except Exception as exc:
  128. print("Unhandled exception while executing the request: %s" % exc,
  129. file=sys.stderr)
  130. return
  131. self.log.__dict__[request.outcome] += 1
  132. self.log.total_time += request.duration
  133. self.log.max_time = max(self.log.max_time, request.duration)
  134. self.log.store_to_file(request)
  135. def wait_next(self):
  136. """Wait some time. At the moment it waits c*X seconds, where c
  137. is the time_coeff parameter in metrics and X is an
  138. exponentially distributed random variable, with parameter
  139. time_lambda in metrics.
  140. The total waiting time is divided in lots of little sleep()
  141. call each one of 0.1 seconds, so that the waiting gets
  142. interrupted if a die signal arrives.
  143. If a die signal is received, an ActorDying exception is
  144. raised.
  145. """
  146. SLEEP_PERIOD = 0.1
  147. time_to_wait = self.metrics['time_coeff'] * \
  148. random.expovariate(self.metrics['time_lambda'])
  149. sleep_num = time_to_wait // SLEEP_PERIOD
  150. remaining_sleep = time_to_wait - (sleep_num * SLEEP_PERIOD)
  151. for _ in range(sleep_num):
  152. time.sleep(SLEEP_PERIOD)
  153. if self.die:
  154. raise ActorDying()
  155. time.sleep(remaining_sleep)
  156. if self.die:
  157. raise ActorDying()
  158. def login(self):
  159. """Log in and check to be logged in."""
  160. self.do_step(HomepageRequest(self.browser,
  161. self.username,
  162. loggedin=False,
  163. base_url=self.base_url))
  164. lr = CWSLoginRequest(self.browser,
  165. self.username,
  166. self.password,
  167. base_url=self.base_url)
  168. self.browser.read_xsrf_token(lr.base_url)
  169. self.do_step(lr)
  170. self.do_step(HomepageRequest(self.browser,
  171. self.username,
  172. loggedin=True,
  173. base_url=self.base_url))
  174. class RandomActor(Actor):
  175. def act(self):
  176. self.login()
  177. while True:
  178. choice = random.random()
  179. task = random.choice(self.tasks)
  180. if choice < 0.1 and self.submissions_path is not None:
  181. self.do_step(SubmitRandomRequest(
  182. self.browser,
  183. task,
  184. base_url=self.base_url,
  185. submissions_path=self.submissions_path))
  186. elif choice < 0.6 and task[2] != []:
  187. self.do_step(TaskStatementRequest(self.browser,
  188. task[1],
  189. random.choice(task[2]),
  190. base_url=self.base_url))
  191. else:
  192. self.do_step(TaskRequest(self.browser,
  193. task[1],
  194. base_url=self.base_url))
  195. class SubmitActor(Actor):
  196. def act(self):
  197. self.login()
  198. # Then keep forever stumbling across user pages
  199. while True:
  200. task = random.choice(self.tasks)
  201. self.do_step(SubmitRandomRequest(
  202. self.browser,
  203. task,
  204. base_url=self.base_url,
  205. submissions_path=self.submissions_path))
  206. def harvest_contest_data(contest_id):
  207. """Retrieve the couples username, password and the task list for a
  208. given contest.
  209. contest_id (int): the id of the contest we want.
  210. return (tuple): the first element is a dictionary mapping
  211. usernames to passwords; the second one is the list
  212. of the task names.
  213. """
  214. users = {}
  215. tasks = []
  216. with SessionGen() as session:
  217. contest = Contest.get_from_id(contest_id, session)
  218. for participation in contest.participations:
  219. user = participation.user
  220. # Pick participation's password if present, or the user's.
  221. password_source = participation.password
  222. if password_source is None:
  223. password_source = user.password
  224. # We can log in only if we know the plaintext password.
  225. method, password = parse_authentication(password_source)
  226. if method != "plaintext":
  227. print("Not using user %s with non-plaintext password."
  228. % user.username)
  229. continue
  230. users[user.username] = {'password': password}
  231. for task in contest.tasks:
  232. tasks.append((task.id, task.name, list(task.statements.keys())))
  233. return users, tasks
  234. DEFAULT_METRICS = {'time_coeff': 10.0,
  235. 'time_lambda': 2.0}
  236. def main():
  237. parser = argparse.ArgumentParser(description="Stress tester for CMS")
  238. parser.add_argument(
  239. "-c", "--contest-id", action="store", type=int, required=True,
  240. help="ID of the contest to test against")
  241. parser.add_argument(
  242. "-n", "--actor-num", action="store", type=int,
  243. help="the number of actors to spawn")
  244. parser.add_argument(
  245. "-s", "--sort-actors", action="store_true",
  246. help="sort usernames alphabetically before slicing them")
  247. parser.add_argument(
  248. "-u", "--base-url", action="store", type=utf8_decoder,
  249. help="base contest URL for placing HTTP requests "
  250. "(without trailing slash)")
  251. parser.add_argument(
  252. "-S", "--submissions-path", action="store", type=utf8_decoder,
  253. help="base path for submission to send")
  254. parser.add_argument(
  255. "-p", "--prepare-path", action="store", type=utf8_decoder,
  256. help="file to put contest info to")
  257. parser.add_argument(
  258. "-r", "--read-from", action="store", type=utf8_decoder,
  259. help="file to read contest info from")
  260. parser.add_argument(
  261. "-t", "--time-coeff", action="store", type=float, default=10.0,
  262. help="average wait between actions")
  263. parser.add_argument(
  264. "-o", "--only-submit", action="store_true",
  265. help="whether the actor only submits solutions")
  266. args = parser.parse_args()
  267. # If prepare_path is specified we only need to save some useful
  268. # contest data and exit.
  269. if args.prepare_path is not None:
  270. users, tasks = harvest_contest_data(args.contest_id)
  271. contest_data = dict()
  272. contest_data['users'] = users
  273. contest_data['tasks'] = tasks
  274. with open(args.prepare_path, "wt", encoding="utf-8") as file_:
  275. file_.write("%s" % contest_data)
  276. return
  277. assert args.time_coeff > 0.0
  278. assert not (args.only_submit and len(args.submissions_path) == 0)
  279. users = []
  280. tasks = []
  281. # If read_from is not specified, read contest data from database
  282. # if it is specified - read contest data from the file
  283. if args.read_from is None:
  284. users, tasks = harvest_contest_data(args.contest_id)
  285. else:
  286. with open(args.read_from, "rt", encoding="utf-8") as file_:
  287. contest_data = ast.literal_eval(file_.read())
  288. users = contest_data['users']
  289. tasks = contest_data['tasks']
  290. if len(users) == 0:
  291. print("No viable users, terminating.")
  292. return
  293. if args.actor_num is not None:
  294. user_items = list(users.items())
  295. if args.sort_actors:
  296. user_items.sort()
  297. else:
  298. random.shuffle(user_items)
  299. users = dict(user_items[:args.actor_num])
  300. # If the base URL is not specified, we try to guess it; anyway,
  301. # the guess code isn't very smart...
  302. if args.base_url is not None:
  303. base_url = args.base_url
  304. else:
  305. base_url = "http://%s:%d/" % \
  306. (get_service_address(ServiceCoord('ContestWebServer', 0))[0],
  307. config.contest_listen_port[0])
  308. metrics = DEFAULT_METRICS
  309. metrics["time_coeff"] = args.time_coeff
  310. actor_class = RandomActor
  311. if args.only_submit:
  312. actor_class = SubmitActor
  313. actors = [actor_class(username, data['password'], metrics, tasks,
  314. log=RequestLog(log_dir=os.path.join('./test_logs',
  315. username)),
  316. base_url=base_url,
  317. submissions_path=args.submissions_path)
  318. for username, data in users.items()]
  319. for actor in actors:
  320. actor.start()
  321. try:
  322. while True:
  323. time.sleep(1)
  324. except KeyboardInterrupt:
  325. print("Taking down actors", file=sys.stderr)
  326. for actor in actors:
  327. actor.die = True
  328. # Uncomment to turn on some memory profiling.
  329. # from meliae import scanner
  330. # print("Dumping")
  331. # scanner.dump_all_objects('objects.json')
  332. # print("Dump finished")
  333. for actor in actors:
  334. actor.join()
  335. print("Test finished", file=sys.stderr)
  336. great_log = RequestLog()
  337. for actor in actors:
  338. great_log.merge(actor.log)
  339. great_log.print_stats()
  340. if __name__ == '__main__':
  341. main()