basic functionality working

This commit is contained in:
Markus Pilman 2022-08-17 16:27:44 -06:00
parent 2704891c1a
commit 2bf6a838b8
5 changed files with 172 additions and 83 deletions

View File

@ -1,5 +1,4 @@
import argparse
import random
import sys
import traceback
@ -10,7 +9,6 @@ from test_harness.summarize import SummaryTree
if __name__ == '__main__':
try:
# seed the random number generator
parser = argparse.ArgumentParser('TestHarness', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
config.build_arguments(parser)
# initialize arguments
@ -21,7 +19,6 @@ if __name__ == '__main__':
required=False)
args = parser.parse_args()
config.extract_args(args)
random.seed(config.joshua_seed)
test_runner = TestRunner()
if not test_runner.run(args.stats):
exit(1)

View File

@ -70,6 +70,7 @@ class ConfigValue:
class Config:
def __init__(self):
self.random = random.Random()
self.kill_seconds: int = 30 * 60
self.kill_seconds_args = {'help': 'Timeout for individual test'}
self.buggify_on_ratio: float = 0.8
@ -102,6 +103,7 @@ class Config:
self.clean_up_args = {'long_name': 'no_clean_up', 'action': 'store_false'}
self.run_dir: Path = Path('tmp')
self.joshua_seed: int = int(os.getenv('JOSHUA_SEED', str(random.randint(0, 2 ** 32 - 1))))
self.random.seed(self.joshua_seed, version=2)
self.joshua_seed_args = {'short_name': 's', 'help': 'A random seed'}
self.print_coverage = False
self.print_coverage_args = {'action': 'store_true'}
@ -110,9 +112,16 @@ class Config:
self.output_format: str = 'xml'
self.output_format_args = {'short_name': 'O', 'choices': ['json', 'xml']}
self.include_test_files: str = r'.*'
self.include_test_files_args = {'help': 'Only consider test files whose path match against the given regex'}
self.exclude_test_files: str = r'.^'
self.exclude_test_files_args = {'help': 'Don\'t consider test files whose path match against the given regex'}
self.include_test_names: str = r'.*'
self.include_test_names_args = {'help': 'Only consider tests whose names match against the given regex'}
self.exclude_test_names: str = r'.^'
self.exclude_test_names_args = {'help': 'Don\'t consider tests whose names match against the given regex'}
self.max_stderr_bytes: int = 1000
self.write_stats: bool = True
self.read_stats: bool = True
self.config_map = self._build_map()
def _build_map(self):
@ -151,6 +160,8 @@ class Config:
for val in self.config_map.values():
k, v = val.get_value(args)
config.__setattr__(k, v)
if k == 'joshua_seed':
self.random.seed(self.joshua_seed, version=2)
config = Config()

View File

@ -8,21 +8,22 @@ import os
import resource
import shutil
import subprocess
import random
import re
import sys
import threading
import time
import uuid
from test_harness import version
from test_harness.config import config
from typing import List, Pattern, Callable, OrderedDict, Dict
from functools import total_ordering
from pathlib import Path
from test_harness.version import Version
from test_harness.config import config
from typing import List, Pattern, OrderedDict, Dict
from test_harness.summarize import Summary, SummaryTree
@total_ordering
class TestDescription:
def __init__(self, path: Path, name: str, priority: float):
self.paths: List[Path] = [path]
@ -31,6 +32,18 @@ class TestDescription:
# we only measure in seconds. Otherwise, keeping determinism will be difficult
self.total_runtime: int = 0
def __lt__(self, other):
if isinstance(other, TestDescription):
return self.name < other.name
else:
return self.name < str(other)
def __eq__(self, other):
if isinstance(other, TestDescription):
return self.name < other.name
else:
return self.name < str(other.name)
class StatFetcher:
def __init__(self, tests: OrderedDict[str, TestDescription]):
@ -68,11 +81,8 @@ class FileStatsFetcher(StatFetcher):
json.dump(self.last_state, f)
StatFetcherCreator = Callable[[OrderedDict[str, TestDescription]], StatFetcher]
class TestPicker:
def __init__(self, test_dir: Path, fetcher: StatFetcherCreator):
def __init__(self, test_dir: Path):
if not test_dir.exists():
raise RuntimeError('{} is neither a directory nor a file'.format(test_dir))
self.include_files_regex = re.compile(config.include_test_files)
@ -87,7 +97,8 @@ class TestPicker:
for subdir in self.test_dir.iterdir():
if subdir.is_dir() and subdir.name in config.test_dirs:
self.walk_test_dir(subdir)
self.fetcher = fetcher(self.tests)
self.fetcher: StatFetcher
# self.fetcher = fetcher(self.tests)
def add_time(self, test_file: Path, run_time: int) -> None:
# getting the test name is fairly inefficient. But since we only have 100s of tests, I won't bother
@ -99,8 +110,8 @@ class TestPicker:
break
if test_name is not None:
break
assert test_name is not None
self.fetcher.add_run_time(test_name, run_time)
# assert test_name is not None
# self.fetcher.add_run_time(test_name, run_time)
def dump_stats(self) -> str:
res = array.array('I')
@ -109,7 +120,8 @@ class TestPicker:
return base64.standard_b64encode(res.tobytes()).decode('utf-8')
def fetch_stats(self):
self.fetcher.read_stats()
# self.fetcher.read_stats()
pass
def load_stats(self, serialized: str):
times = array.array('I')
@ -121,7 +133,7 @@ class TestPicker:
idx += 1
def parse_txt(self, path: Path):
if self.include_files_regex.match(str(path)) is None or self.exclude_files_regex.match(str(path)) is not None:
if self.include_files_regex.search(str(path)) is None or self.exclude_files_regex.search(str(path)) is not None:
return
with path.open('r') as f:
test_name: str | None = None
@ -151,8 +163,8 @@ class TestPicker:
test_class = test_name
if priority is None:
priority = 1.0
if self.include_tests_regex.match(test_class) is None \
or self.exclude_tests_regex.match(test_class) is not None:
if self.include_tests_regex.search(test_class) is None \
or self.exclude_tests_regex.search(test_class) is not None:
return
if test_class not in self.tests:
self.tests[test_class] = TestDescription(path, test_class, priority)
@ -193,9 +205,10 @@ class TestPicker:
candidates = [v]
elif this_time == min_runtime:
candidates.append(v)
choice = random.randint(0, len(candidates) - 1)
candidates.sort()
choice = config.random.randint(0, len(candidates) - 1)
test = candidates[choice]
result = test.paths[random.randint(0, len(test.paths) - 1)]
result = test.paths[config.random.randint(0, len(test.paths) - 1)]
if self.restart_test.match(result.name):
return self.list_restart_files(result)
else:
@ -206,7 +219,7 @@ class OldBinaries:
def __init__(self):
self.first_file_expr = re.compile(r'.*-1\.(txt|toml)')
self.old_binaries_path: Path = config.old_binaries_path
self.binaries: OrderedDict[version.Version, Path] = collections.OrderedDict()
self.binaries: OrderedDict[Version, Path] = collections.OrderedDict()
if not self.old_binaries_path.exists() or not self.old_binaries_path.is_dir():
return
exec_pattern = re.compile(r'fdbserver-\d+\.\d+\.\d+(\.exe)?')
@ -220,13 +233,14 @@ class OldBinaries:
version_str = file.name.split('-')[1]
if version_str.endswith('.exe'):
version_str = version_str[0:-len('.exe')]
self.binaries[version.Version.parse(version_str)] = file
ver = Version.parse(version_str)
self.binaries[ver] = file
def choose_binary(self, test_file: Path) -> Path:
if len(self.binaries) == 0:
return config.binary
max_version = version.Version.max_version()
min_version = version.Version.parse('5.0.0')
max_version = Version.max_version()
min_version = Version.parse('5.0.0')
dirs = test_file.parent.parts
if 'restarting' not in dirs:
return config.binary
@ -239,16 +253,16 @@ class OldBinaries:
# upgrade test -- we only return an old version for the first test file
return config.binary
if version_expr[0] == 'from' or version_expr[0] == 'to':
min_version = version.Version.parse(version_expr[1])
min_version = Version.parse(version_expr[1])
if len(version_expr) == 4 and version_expr[2] == 'until':
max_version = version.Version.parse(version_expr[3])
max_version = Version.parse(version_expr[3])
candidates: List[Path] = []
for ver, binary in self.binaries.items():
if min_version <= ver <= max_version:
candidates.append(binary)
if len(candidates) == 0:
return config.binary
return random.choice(candidates)
return config.random.choice(candidates)
def is_restarting_test(test_file: Path):
@ -287,7 +301,7 @@ class ResourceMonitor(threading.Thread):
class TestRun:
def __init__(self, binary: Path, test_file: Path, random_seed: int, uid: uuid.UUID,
restarting: bool = False, test_determinism: bool = False,
stats: str | None = None, expected_unseed: int | None = None):
stats: str | None = None, expected_unseed: int | None = None, will_restart: bool = False):
self.binary = binary
self.test_file = test_file
self.random_seed = random_seed
@ -299,13 +313,16 @@ class TestRun:
self.err_out: str = 'error.xml'
self.use_valgrind: bool = config.use_valgrind
self.old_binary_path: Path = config.old_binaries_path
self.buggify_enabled: bool = random.random() < config.buggify_on_ratio
self.buggify_enabled: bool = config.random.random() < config.buggify_on_ratio
self.fault_injection_enabled: bool = True
self.trace_format = config.trace_format
if Version.of_binary(self.binary) < "6.1.0":
self.trace_format = None
self.temp_path = config.run_dir / str(self.uid)
# state for the run
self.retryable_error: bool = False
self.summary: Summary = Summary(binary)
self.summary: Summary = Summary(binary, uid=self.uid, stats=self.stats, expected_unseed=self.expected_unseed,
will_restart=will_restart)
self.run_time: int = 0
self.success = self.run()
@ -332,9 +349,11 @@ class TestRun:
command += [str(self.binary.absolute()),
'-r', 'test' if is_no_sim(self.test_file) else 'simulation',
'-f', str(self.test_file),
'-s', str(self.random_seed),
'-fi', 'on' if self.fault_injection_enabled else 'off',
'--trace_format', self.trace_format]
'-s', str(self.random_seed)]
if self.trace_format is not None:
command += ['--trace_format', self.trace_format]
if Version.of_binary(self.binary) >= '7.1.0':
command += ['-fi', 'on' if self.fault_injection_enabled else 'off']
if self.restarting:
command.append('--restarting')
if self.buggify_enabled:
@ -347,12 +366,16 @@ class TestRun:
# self.log_test_plan(out)
resources = ResourceMonitor()
resources.start()
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=self.temp_path)
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, cwd=self.temp_path,
text=True)
did_kill = False
timeout = 20 * config.kill_seconds if self.use_valgrind else config.kill_seconds
err_out: str
try:
process.wait(20 * config.kill_seconds if self.use_valgrind else config.kill_seconds)
_, err_out = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill()
_, err_out = process.communicate()
did_kill = True
resources.stop()
resources.join()
@ -360,10 +383,9 @@ class TestRun:
self.summary.runtime = resources.time()
self.summary.max_rss = resources.max_rss
self.summary.was_killed = did_kill
self.summary = Summary(self.binary, runtime=resources.time(), max_rss=resources.max_rss,
was_killed=did_kill, uid=self.uid, stats=self.stats, valgrind_out_file=valgrind_file,
expected_unseed=self.expected_unseed)
self.summary.summarize(self.temp_path)
self.summary.valgrind_out_file = valgrind_file
self.summary.error_out = err_out
self.summary.summarize(self.temp_path, ' '.join(command))
self.summary.out.dump(sys.stdout)
return self.summary.ok()
@ -371,19 +393,11 @@ class TestRun:
class TestRunner:
def __init__(self):
self.uid = uuid.uuid4()
self.test_path: str = 'tests'
self.test_path: Path = Path('tests')
self.cluster_file: str | None = None
self.fdb_app_dir: str | None = None
self.stat_fetcher: StatFetcherCreator = \
lambda x: FileStatsFetcher(os.getenv('JOSHUA_STAT_FILE', 'stats.json'), x)
self.binary_chooser = OldBinaries()
def fetch_stats_from_fdb(self, cluster_file: str, app_dir: str):
def fdb_fetcher(tests: OrderedDict[str, TestDescription]):
from . import fdb
return fdb.FDBStatFetcher(cluster_file, app_dir, tests)
self.stat_fetcher = fdb_fetcher
self.test_picker = TestPicker(self.test_path)
def backup_sim_dir(self, seed: int):
temp_dir = config.run_dir / str(self.uid)
@ -405,34 +419,37 @@ class TestRunner:
count = 0
result: bool = True
for file in test_files:
will_restart = count + 1 < len(test_files)
binary = self.binary_chooser.choose_binary(file)
unseed_check = random.random() < config.unseed_check_ratio
unseed_check = config.random.random() < config.unseed_check_ratio
if unseed_check and count != 0:
# for restarting tests we will need to restore the sim2 after the first run
self.backup_sim_dir(seed + count - 1)
run = TestRun(binary, file.absolute(), seed + count, self.uid, restarting=count != 0,
stats=test_picker.dump_stats())
stats=test_picker.dump_stats(), will_restart=will_restart)
result = result and run.success
test_picker.add_time(file, run.run_time)
if run.success and unseed_check and run.summary.unseed is not None:
self.restore_sim_dir(seed + count - 1)
run2 = TestRun(binary, file.absolute(), seed + count, self.uid, restarting=count != 0,
stats=test_picker.dump_stats(), expected_unseed=run.summary.unseed)
test_picker.add_time(file, run2.run_time)
test_picker.add_time(test_files[0], run.run_time)
if not result:
return False
if unseed_check and run.summary.unseed is not None:
if count != 0:
self.restore_sim_dir(seed + count - 1)
run2 = TestRun(binary, file.absolute(), seed + count, self.uid, restarting=count != 0,
stats=test_picker.dump_stats(), expected_unseed=run.summary.unseed,
will_restart=will_restart)
test_picker.add_time(file, run2.run_time)
result = result and run.success
count += 1
return result
def run(self, stats: str | None) -> bool:
seed = random.randint(0, 2 ** 32 - 1)
test_picker = TestPicker(Path(self.test_path), self.stat_fetcher)
seed = config.random.randint(0, 2 ** 32 - 1)
if stats is not None:
test_picker.load_stats(stats)
self.test_picker.load_stats(stats)
else:
test_picker.fetch_stats()
test_files = test_picker.choose_test()
success = self.run_tests(test_files, seed, test_picker)
self.test_picker.fetch_stats()
test_files = self.test_picker.choose_test()
success = self.run_tests(test_files, seed, self.test_picker)
if config.clean_up:
shutil.rmtree(config.run_dir / str(self.uid))
return success

View File

@ -13,7 +13,6 @@ import xml.sax.saxutils
from pathlib import Path
from typing import List, Dict, TextIO, Callable, Optional, OrderedDict, Any, Tuple
from xml.dom import minidom
from test_harness.config import config
@ -46,7 +45,7 @@ class SummaryTree:
# However, our xml is very simple and therefore serializing manually is easy enough
attrs = []
for k, v in self.attributes.items():
attrs.append('{}="{}"'.format(k, xml.sax.saxutils.escape(v)))
attrs.append('{}={}'.format(k, xml.sax.saxutils.quoteattr(v)))
elem = '{}<{}{}'.format(prefix, self.name, ('' if len(attrs) == 0 else ' '))
out.write(elem)
if config.pretty_print:
@ -92,7 +91,7 @@ class ParseHandler:
self.out = out
self.events: OrderedDict[Optional[Tuple[str, Optional[str]]], List[ParserCallback]] = collections.OrderedDict()
def add_handler(self, attr: Tuple[str, str], callback: ParserCallback) -> None:
def add_handler(self, attr: Tuple[str, Optional[str]], callback: ParserCallback) -> None:
if attr in self.events:
self.events[attr].append(callback)
else:
@ -319,7 +318,7 @@ class TraceFiles:
else:
self.timestamps.append(ts)
self.runs[ts] = [file]
self.timestamps.sort()
self.timestamps.sort(reverse=True)
def __getitem__(self, idx: int) -> List[Path]:
res = self.runs[self.timestamps[idx]]
@ -333,7 +332,8 @@ class TraceFiles:
class Summary:
def __init__(self, binary: Path, runtime: float = 0, max_rss: int | None = None,
was_killed: bool = False, uid: uuid.UUID | None = None, expected_unseed: int | None = None,
exit_code: int = 0, valgrind_out_file: Path | None = None, stats: str | None = None):
exit_code: int = 0, valgrind_out_file: Path | None = None, stats: str | None = None,
error_out: str = None, will_restart: bool = False):
self.binary = binary
self.runtime: float = runtime
self.max_rss: int | None = max_rss
@ -353,17 +353,21 @@ class Summary:
self.coverage: OrderedDict[Coverage, bool] = collections.OrderedDict()
self.test_count: int = 0
self.tests_passed: int = 0
self.error_out = error_out
self.stderr_severity: str = '40'
self.will_restart: bool = will_restart
if uid is not None:
self.out.attributes['TestUID'] = str(uid)
if stats is not None:
self.out.attributes['Statistics'] = stats
self.out.attributes['JoshuaSeed'] = str(config.joshua_seed)
self.out.attributes['WillRestart'] = '1' if self.will_restart else '0'
self.handler = ParseHandler(self.out)
self.register_handlers()
def summarize(self, trace_dir: Path):
def summarize(self, trace_dir: Path, command: str):
trace_files = TraceFiles(trace_dir)
if len(trace_files) == 0:
self.error = True
@ -371,14 +375,15 @@ class Summary:
child = SummaryTree('NoTracesFound')
child.attributes['Severity'] = '40'
child.attributes['Path'] = str(trace_dir.absolute())
child.attributes['Command'] = command
self.out.append(child)
return
for f in trace_files[0]:
self.parse_file(f)
self.done()
def ok(self):
return not self.error and self.tests_passed == self.test_count and self.tests_passed >= 0\
and self.test_end_found
return not self.error
def done(self):
if config.print_coverage:
@ -429,7 +434,38 @@ class Summary:
child = SummaryTree('TestUnexpectedlyNotFinished')
child.attributes['Severity'] = '40'
self.out.append(child)
if self.error_out is not None and len(self.error_out) > 0:
if self.stderr_severity == '40':
self.error = True
lines = self.error_out.split('\n')
stderr_bytes = 0
for line in lines:
remaining_bytes = config.max_stderr_bytes - stderr_bytes
if remaining_bytes > 0:
out_err = line[0:remaining_bytes] + ('...' if len(line) > remaining_bytes else '')
child = SummaryTree('StdErrOutput')
child.attributes['Severity'] = self.stderr_severity
child.attributes['Output'] = out_err
self.out.append(child)
stderr_bytes += len(line)
if stderr_bytes > config.max_stderr_bytes:
child = SummaryTree('StdErrOutputTruncated')
child.attributes['Severity'] = self.stderr_severity
child.attributes['BytesRemaining'] = stderr_bytes - config.max_stderr_bytes
self.out.append(child)
self.out.attributes['Ok'] = '1' if self.ok() else '0'
if not self.ok():
reason = 'Unknown'
if self.error:
reason = 'ProducedErrors'
elif not self.test_end_found:
reason = 'TestDidNotFinish'
elif self.tests_passed == 0:
reason = 'NoTestsPassed'
elif self.test_count != self.tests_passed:
reason = 'Expected {} tests to pass, but only {} did'.format(self.test_count, self.tests_passed)
self.out.attributes['FailReason'] = reason
def parse_file(self, file: Path):
parser: Parser
@ -581,3 +617,8 @@ class Summary:
self.out.append(child)
self.handler.add_handler(('Type', 'BuggifySection'), buggify_section)
self.handler.add_handler(('Type', 'FaultInjected'), buggify_section)
def stderr_severity(attrs: Dict[str, str]):
if 'NewSeverity' in attrs:
self.stderr_severity = attrs['NewSeverity']
self.handler.add_handler(('Type', 'StderrSeverity'), stderr_severity)

View File

@ -1,3 +1,9 @@
from functools import total_ordering
from pathlib import Path
from typing import Tuple
@total_ordering
class Version:
def __init__(self):
self.major: int = 0
@ -7,24 +13,39 @@ class Version:
def version_tuple(self):
return self.major, self.minor, self.patch
def __eq__(self, other):
return self.version_tuple() == other.version_tuple()
def _compare(self, other) -> int:
lhs: Tuple[int, int, int] = self.version_tuple()
rhs: Tuple[int, int, int]
if isinstance(other, Version):
rhs = other.version_tuple()
else:
rhs = Version.parse(str(other)).version_tuple()
if lhs < rhs:
return -1
elif lhs > rhs:
return 1
else:
return 0
def __lt__(self, other):
return self.version_tuple() < other.version_tuple()
def __eq__(self, other) -> bool:
return self._compare(other) == 0
def __le__(self, other):
return self.version_tuple() <= other.version_tuple()
def __gt__(self, other):
return self.version_tuple() > other.version_tuple()
def __ge__(self, other):
return self.version_tuple() >= other.version_tuple()
def __lt__(self, other) -> bool:
return self._compare(other) < 0
def __hash__(self):
return hash(self.version_tuple())
def __str__(self):
return format('{}.{}.{}'.format(self.major, self.minor, self.patch))
@staticmethod
def of_binary(binary: Path):
parts = binary.name.split('-')
if len(parts) != 2:
return Version.max_version()
return Version.parse(parts[1])
@staticmethod
def parse(version: str):
version_tuple = version.split('.')
@ -34,6 +55,7 @@ class Version:
self.minor = int(version_tuple[1])
if len(version_tuple) > 2:
self.patch = int(version_tuple[2])
return self
@staticmethod
def max_version():
@ -41,3 +63,4 @@ class Version:
self.major = 2**32 - 1
self.minor = 2**32 - 1
self.patch = 2**32 - 1
return self