[svn.haxx.se] · SVN Dev · SVN Users · SVN Org · TSVN Dev · TSVN Users · Subclipse Dev · Subclipse Users · this month's index

Parallelizing the python testsuite

From: Lieven Govaerts <lgo_at_mobsol.be>
Date: 2006-11-06 01:36:42 CET

Attached patch is my work in progress towards a parallelized python test
suite. It's not completely finished, basically it needs some more
testing, some fixes (not all the tests are passing), code cleanup and a
log message. But, I send it to the list anyway, so people interested in
this patch can already have a look at it .

There are two new cmd-line parameters for a test:
- add a -p/--parallel cmd-line option to the tests; triggers parallel
execution
- add a -c option (child process) to the tests; disables setup and
cleanup of temporary folders

When run with -p the main python process will create some threads each
spawning another child process, this for each test in the list. The
output of the child process (stdout, stderr, exit code) is cached, and
will only be printed when all the tests were run.

Currently the max. nr. of child processes is 10, but I propose to make
that a value of the -p option.

The two problems I had parallelizing the testsuite are/were with:
- tests that change the current directory or the environment ($EDITOR):
these were handled by changing the original implementation with threads
to child processes. We had a long discussion on IRC about this, current
implementation is based on the consensus of that discussion.

- tests that change the authz file: for dav tests, the authz file has to
be put in a specific unchangeable location, which makes it impossible to
run multiple authz tests in parallel. I've currently solved this with
adding an option to completely disable parallel execution, which is now
activited for authz_tests.py and svnsync_tests.py.

I have some other changes in run_tests.py and win_tests.py to support
the -p option, but those aren't included in the patch.

Things I didn't test: whether or not the exit_code is correctly
returned, ra_dav, ra_svn & non-Windows platforms.

All input is welcome.

Oh before I forget, the good part: both when run on hard disk or ram
disk, running a file like basic_tests.py improved performance from 50
seconds to 20 seconds, so that's about 2,5x faster.

Lieven

Index: subversion/tests/cmdline/authz_tests.py
===================================================================
--- subversion/tests/cmdline/authz_tests.py (revision 22227)
+++ subversion/tests/cmdline/authz_tests.py (working copy)
@@ -718,7 +718,7 @@
              ]
 
 if __name__ == '__main__':
- svntest.main.run_tests(test_list)
+ svntest.main.run_tests(test_list, serial_only = True)
   # NOTREACHED
 
 
Index: subversion/tests/cmdline/svnsync_tests.py
===================================================================
--- subversion/tests/cmdline/svnsync_tests.py (revision 22227)
+++ subversion/tests/cmdline/svnsync_tests.py (working copy)
@@ -460,7 +460,7 @@
              ]
 
 if __name__ == '__main__':
- svntest.main.run_tests(test_list)
+ svntest.main.run_tests(test_list, serial_only = True)
   # NOTREACHED
 
 
Index: subversion/tests/cmdline/svntest/actions.py
===================================================================
--- subversion/tests/cmdline/svntest/actions.py (revision 22227)
+++ subversion/tests/cmdline/svntest/actions.py (working copy)
@@ -55,10 +55,24 @@
   """Create the pristine repository, 'svn import' the greek tree and
   checkout the pristine working copy"""
 
+ # these directories don't exist out of the box, so we may have to create them
+ if not os.path.exists(main.general_wc_dir):
+ os.makedirs(main.general_wc_dir)
+
+ if not os.path.exists(main.general_repo_dir):
+ os.makedirs(main.general_repo_dir) # this also creates all the intermediate dirs
+
   # If there's no pristine repos, create one.
   if not os.path.exists(main.pristine_dir):
     main.create_repos(main.pristine_dir)
 
+ # if this is dav, gives us access rights to import the greek tree.
+ if main.is_ra_type_dav():
+ authz_file = os.path.join(main.work_dir, "authz")
+ fp = open(authz_file, "w")
+ fp.write("[/]\n* = rw\n")
+ fp.close()
+
     # dump the greek tree to disk.
     main.greek_state.write_to_disk(main.greek_dump_dir)
 
@@ -144,7 +158,6 @@
   # make the repos world-writeable, for mod_dav_svn's sake.
   main.chmod_tree(path, 0666, 0666)
 
-
 def run_and_verify_svnversion(message, wc_dir, repo_url,
                               expected_stdout, expected_stderr):
   "Run svnversion command and check its output"
@@ -932,10 +945,6 @@
   guarantee_greek_repository(sbox.repo_dir)
 
   if create_wc:
- # this dir doesn't exist out of the box, so we may have to make it
- if not os.path.exists(main.general_wc_dir):
- os.makedirs(main.general_wc_dir)
-
     # copy the pristine wc and relocate it to our new repository.
     duplicate_dir(main.pristine_wc_dir, sbox.wc_dir)
 
Index: subversion/tests/cmdline/svntest/main.py
===================================================================
--- subversion/tests/cmdline/svntest/main.py (revision 22227)
+++ subversion/tests/cmdline/svntest/main.py (working copy)
@@ -5,7 +5,7 @@
 # See http://subversion.tigris.org for more information.
 #
 # ====================================================================
-# Copyright (c) 2000-2004 CollabNet. All rights reserved.
+# Copyright (c) 2000-2006 CollabNet. All rights reserved.
 #
 # This software is licensed as described in the file COPYING, which
 # you should have received as part of this distribution. The terms
@@ -24,6 +24,7 @@
 import copy # for deepcopy()
 import time # for time()
 import traceback # for print_exc()
+import threading
 
 import getopt
 try:
@@ -134,6 +135,10 @@
 # Global variable indicating if svnserve should use Cyrus SASL
 enable_sasl = 0
 
+# Global variable indicating if this is a child process and no cleanup
+# of global directories is needed.
+is_child_process = 0
+
 # Global URL to testing area. Default to ra_local, current working dir.
 test_area_url = file_scheme_prefix + os.path.abspath(os.getcwd())
 if windows == 1:
@@ -246,16 +251,7 @@
                            None, *varargs)
 
 # Run any binary, supplying input text, logging the command line
-def run_command_stdin(command, error_expected, binary_mode=0,
- stdin_lines=None, *varargs):
- """Run COMMAND with VARARGS; input STDIN_LINES (a list of strings
- which should include newline characters) to program via stdin - this
- should not be very large, as if the program outputs more than the OS
- is willing to buffer, this will deadlock, with both Python and
- COMMAND waiting to write to each other for ever.
- Return stdout, stderr as lists of lines.
- If ERROR_EXPECTED is None, any stderr also will be printed."""
-
+def spawn_process(command, binary_mode=0,stdin_lines=None, *varargs):
   args = ''
   for arg in varargs: # build the command string
     arg = str(arg)
@@ -272,7 +268,6 @@
   else:
     mode = 't'
 
- start = time.time()
   infile, outfile, errfile = os.popen3(command + args, mode)
 
   if stdin_lines:
@@ -286,6 +281,8 @@
   outfile.close()
   errfile.close()
 
+ exit_code = 0
+
   if platform_with_os_wait:
     pid, wait_code = os.wait()
 
@@ -295,7 +292,27 @@
     if exit_signal != 0:
       raise SVNProcessTerminatedBySignal
 
+ return exit_code, stdout_lines, stderr_lines
+
+def run_command_stdin(command, error_expected, binary_mode=0,
+ stdin_lines=None, *varargs):
+ """Run COMMAND with VARARGS; input STDIN_LINES (a list of strings
+ which should include newline characters) to program via stdin - this
+ should not be very large, as if the program outputs more than the OS
+ is willing to buffer, this will deadlock, with both Python and
+ COMMAND waiting to write to each other for ever.
+ Return stdout, stderr as lists of lines.
+ If ERROR_EXPECTED is None, any stderr also will be printed."""
+
   if verbose_mode:
+ start = time.time()
+
+ exit_code, stdout_lines, stderr_lines = spawn_process(command,
+ binary_mode,
+ stdin_lines,
+ *varargs)
+
+ if verbose_mode:
     stop = time.time()
     print '<TIME = %.6f>' % (stop - start)
 
@@ -456,9 +473,6 @@
 
   # If the copy may have the same uuid, then hotcopy the repos files on disk.
   if not ignore_uuid:
- if not os.path.exists(general_repo_dir):
- os.makedirs(general_repo_dir) # this also creates all the intermediate dirs
-
     output, errput = run_svnadmin('hotcopy', src_path, dst_path)
   else:
     # Do an svnadmin dump|svnadmin load cycle. Print a fake pipe command so that
@@ -685,7 +699,38 @@
       print "WARNING: cleanup failed, will try again later"
     _deferred_test_paths.append(path)
 
+class SpawnTest(threading.Thread):
+ def __init__(self, index, tests = None):
+ threading.Thread.__init__(self)
+ self.index = index
+ self.tests = tests
+ self.result = None
+ self.stdout_lines = None
+ self.stderr_lines = None
 
+ def run(self):
+ command = sys.argv[0]
+
+ args = []
+ args.append(str(self.index))
+ args.append('-c')
+ # add some startup arguments from this process
+ if fs_type:
+ args.append('--fs-type=' + fs_type)
+ if test_area_url:
+ args.append('--url=' + test_area_url)
+ if verbose_mode:
+ args.append('-v')
+ if cleanup_mode:
+ args.append('--cleanup')
+ if enable_sasl:
+ args.append('--enable-sasl')
+
+ self.result, self.stdout_lines, self.stderr_lines =\
+ spawn_process(command, 1, None, *args)
+ sys.stdout.write('.')
+ self.tests.append(self)
+
 class TestRunner:
   """Encapsulate a single test case (predicate), including logic for
   runing the test and test list output."""
@@ -767,7 +812,6 @@
       sandbox.cleanup_test_paths()
     return result
 
-
 ######################################################################
 # Main testing functions
 
@@ -778,27 +822,63 @@
 # it can be displayed by the 'list' command.)
 
 # Func to run one test in the list.
-def run_one_test(n, test_list):
+def run_one_test(n, test_list, parallel = False, finished_tests = None):
   "Run the Nth client test in TEST_LIST, return the result."
+#TODO: add comment
 
   if (n < 1) or (n > len(test_list) - 1):
     print "There is no test", `n` + ".\n"
     return 1
 
   # Run the test.
- exit_code = TestRunner(test_list[n], n).run()
- return exit_code
+ if parallel:
+ st = SpawnTest(n, finished_tests)
+ st.start()
+ return 0
+ else:
+ exit_code = TestRunner(test_list[n], n).run()
+ return exit_code
 
-def _internal_run_tests(test_list, testnums):
+def _internal_run_tests(test_list, testnums, parallel):
   """Run the tests from TEST_LIST whose indices are listed in TESTNUMS."""
+#TODO: add comment about parallel
 
   exit_code = 0
+ finished_tests = []
+ max_nr_of_tests = 10
+ tests_started = 0
 
- for testnum in testnums:
- # 1 is the only return code that indicates actual test failure.
- if run_one_test(testnum, test_list) == 1:
- exit_code = 1
+ if not parallel:
+ for testnum in testnums:
+ if run_one_test(testnum, test_list) == 1:
+ exit_code = 1
+ else:
+ for testnum in testnums:
+ # wait till there's a free spot.
+ while tests_started - len(finished_tests) > max_nr_of_tests:
+ time.sleep(0.2)
+ run_one_test(testnum, test_list, parallel, finished_tests)
+ tests_started += 1
 
+ # wait for all tests to finish
+ while len(finished_tests) < len(testnums):
+ time.sleep(0.2)
+
+ # Sort test results list by test nr.
+ finished_tests.sort(key=lambda test: test.index)
+ print
+
+ # all tests are finished, find out the result and print the logs.
+ for test in finished_tests:
+ if test.stdout_lines:
+ for line in test.stdout_lines:
+ sys.stdout.write(line)
+ if test.stderr_lines:
+ for line in test.stderr_lines:
+ sys.stdout.write(line)
+ if test.result == 1:
+ exit_code = 1
+
   _cleanup_deferred_test_paths()
   return exit_code
 
@@ -815,7 +895,7 @@
 #
 # [<testnum>]... : the numbers of the tests that should be run. If no
 # testnums are specified, then all tests in TEST_LIST are run.
-def run_tests(test_list):
+def run_tests(test_list, serial_only = False):
   """Main routine to run all tests in TEST_LIST.
 
   NOTE: this function does not return. It does a sys.exit() with the
@@ -828,13 +908,17 @@
   global verbose_mode
   global cleanup_mode
   global enable_sasl
+ global is_child_process
+
   testnums = []
   # Should the tests be listed (as opposed to executed)?
   list_tests = 0
 
- opts, args = my_getopt(sys.argv[1:], 'v',
+ parallel = 0
+
+ opts, args = my_getopt(sys.argv[1:], 'vpc',
                          ['url=', 'fs-type=', 'verbose', 'cleanup', 'list',
- 'enable-sasl'])
+ 'enable-sasl', 'parallel'])
 
   for arg in args:
     if arg == "list":
@@ -864,21 +948,41 @@
     elif opt == "--enable-sasl":
       enable_sasl = 1
 
+ elif opt == '-p' or opt == "--parallel":
+ parallel = 1
+
+ elif opt == '-c':
+ is_child_process = 1
+
   if test_area_url[-1:] == '/': # Normalize url to have no trailing slash
     test_area_url = test_area_url[:-1]
 
+ ######################################################################
+ # Initialization
+
+ # Cleanup: if a previous run crashed or interrupted the python
+ # interpreter, then `temp_dir' was never removed. This can cause wonkiness.
+ if not is_child_process:
+ safe_rmtree(temp_dir)
+
   # Calculate pristine_url from test_area_url.
   pristine_url = test_area_url + '/' + pristine_dir
   if windows == 1:
     pristine_url = string.replace(pristine_url, '\\', '/')
   
   # Setup the pristine repository (and working copy)
+ #if not is_child_process:
   actions.setup_pristine_repository()
 
   if not testnums:
     # If no test numbers were listed explicitly, include all of them:
     testnums = range(1, len(test_list))
 
+ # don't run tests in parallel when the tests don't support it or there
+ # are only a few tests to run.
+ if serial_only or len(testnums) < 2:
+ parallel = False
+
   if list_tests:
     print "Test # Mode Test Description"
     print "------ ----- ----------------"
@@ -889,11 +993,12 @@
     sys.exit(0)
 
   else:
- exit_code = _internal_run_tests(test_list, testnums)
+ exit_code = _internal_run_tests(test_list, testnums, parallel)
 
     # remove all scratchwork: the 'pristine' repository, greek tree, etc.
     # This ensures that an 'import' will happen the next time we run.
- safe_rmtree(temp_dir)
+ if not is_child_process:
+ safe_rmtree(temp_dir)
 
     _cleanup_deferred_test_paths()
 
@@ -901,14 +1006,6 @@
     sys.exit(exit_code)
 
 
-######################################################################
-# Initialization
-
-# Cleanup: if a previous run crashed or interrupted the python
-# interpreter, then `temp_dir' was never removed. This can cause wonkiness.
-
-safe_rmtree(temp_dir)
-
 # the modules import each other, so we do this import very late, to ensure
 # that the definitions in "main" have been completed.
 import actions

---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@subversion.tigris.org
For additional commands, e-mail: dev-help@subversion.tigris.org
Received on Mon Nov 6 01:37:04 2006

This is an archived mail posted to the Subversion Dev mailing list.