1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
|
#!/usr/bin/env python
"""Test Failures
Update etc/test_lifecycle.yml to tag unreliable tests based on historic failure rates.
"""
from __future__ import division
from __future__ import print_function
import collections
import copy
import fnmatch
import optparse
import os
import re
import subprocess
import sys
import textwrap
import yaml
# Get relative imports to work when the package is not installed on the PYTHONPATH.
if __name__ == "__main__" and __package__ is None:
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from buildscripts import burn_in_tests
from buildscripts import resmokelib
from buildscripts import test_failures as tf
def read_yaml_file(yaml_file):
"""Opens YAML file and returns YAML structure."""
with open(yaml_file, "r") as fstream:
return yaml.load(fstream)
def write_yaml_file(yaml_file, object):
"""Writes object to yaml_file."""
# Setup to preserve order in yaml.dump, see https://stackoverflow.com/a/8661021
represent_dict_order = lambda self, data: self.represent_mapping(
'tag:yaml.org,2002:map', data.items())
yaml.add_representer(collections.OrderedDict, represent_dict_order)
with open(yaml_file, "w") as fstream:
comment = ("This file was generated by {} and shouldn't be edited by hand. It was"
" generated against commit {} with the following invocation: {}.").format(
sys.argv[0], callo(["git", "rev-parse", "HEAD"]), " ".join(sys.argv))
print(textwrap.fill(comment, width=100, initial_indent="# ", subsequent_indent="# "),
file=fstream)
yaml.dump(object, fstream, default_flow_style=False)
def get_test_suites_membership():
"""Return a dictionary with keys of all js_tests and list of associated suites."""
# SERVER-29486 - Non-JS tests can be returned, so we'll filter them here.
test_membership_map = resmokelib.parser.create_test_membership_map()
for test in test_membership_map.keys():
if not resmokelib.utils.is_js_file(test):
del test_membership_map[test]
return test_membership_map
def get_suite_tasks_membership(evg_yaml):
"""Return a dictionary with keys of all suites and list of associated tasks."""
evg = read_yaml_file(evg_yaml)
suite_membership = collections.defaultdict(list)
for task in evg["tasks"]:
resmoke_args = burn_in_tests.get_resmoke_args(task)
if resmoke_args:
m = re.search("--suites=(?P<suite>\w+)", resmoke_args)
if m is not None:
suite_membership[m.group("suite")].append(task["name"])
return suite_membership
def get_test_tasks_membership(evg_yaml):
"""Return a dictionary with keys of all tests and list of associated tasks."""
test_suites_membership = get_test_suites_membership()
suite_tasks_membership = get_suite_tasks_membership(evg_yaml)
test_tasks_membership = collections.defaultdict(list)
for test in test_suites_membership.keys():
for suite in test_suites_membership[test]:
test_tasks_membership[test].extend(suite_tasks_membership[suite])
return test_tasks_membership
def get_tests_from_tasks(tasks, test_tasks_membership):
"""Return a list of tests from list of specified tasks."""
tests = []
tasks_set = set(tasks)
for test in test_tasks_membership.keys():
if not tasks_set.isdisjoint(test_tasks_membership[test]):
tests.append(test)
return tests
def create_test_groups(tests):
"""Return groups of tests by their directory, i.e., jstests/core."""
test_groups = collections.defaultdict(list)
for test in tests:
test_split = test.split("/")
# If the test does not have a directory, then ignore it.
if len(test_split) <= 1:
continue
test_dir = test_split[1]
test_groups[test_dir].append(test)
return test_groups
def create_batch_groups(test_groups, batch_size):
"""Return batch groups list of test_groups."""
batch_groups = []
for test_group_name in test_groups:
test_group = test_groups[test_group_name]
while test_group:
batch_groups.append(test_group[:batch_size])
test_group = test_group[batch_size:]
return batch_groups
def get_all_tasks(evg_yaml):
"""Returns list of tasks from evg_yaml.
Note that tasks can be excluded in 'test_lifecycle_excluded_tasks'.
"""
evg = read_yaml_file(evg_yaml)
all_tasks = [t["name"] for t in evg["tasks"]]
# The list of excluded tasks may include "Unix shell-style wildcards",
# i.e., 'compile*', which matches 'compile', 'compile_all'
excluded_glob_tasks = evg.get("test_lifecycle_excluded_tasks", [])
excluded_tasks = []
for excluded_glob_task in excluded_glob_tasks:
excluded_tasks.extend(fnmatch.filter(all_tasks, excluded_glob_task))
return list(set(all_tasks) - set(excluded_tasks))
def callo(args):
"""Call a program, and capture its output."""
return subprocess.check_output(args)
def git_commit_range_since(since):
"""Returns first and last commit in 'since' period specified.
Specify 'since' as any acceptable period for git log --since.
The period can be specified as '4.weeks' or '3.days'.
"""
git_command = "git log --since={} --pretty=format:%H".format(since)
commits = callo(git_command.split()).split("\n")
return commits[-1], commits[0]
def git_commit_prior(revision):
"""Returns commit revision prior to one specified."""
git_format = "git log -2 {revision} --pretty=format:%H"
git_command = git_format.format(revision=revision)
commits = callo(git_command.split()).split("\n")
return commits[-1]
def unreliable_test(test_fr, unacceptable_fr, test_runs, min_run):
"""Check for an unreliable test.
A test should be added to the set of tests believed not to run reliably when it has more
than min_run executions with a failure percentage greater than unacceptable_fr.
"""
return test_runs >= min_run and test_fr > unacceptable_fr
def reliable_test(test_fr, acceptable_fr, test_runs, min_run):
"""Check for a reliable test.
A test should then removed from the set of tests believed not to run reliably when it has
at least min_run executions with a failure percentage less than acceptable_fr.
"""
return test_runs >= min_run and test_fr < acceptable_fr
def check_fail_rates(fr_name, acceptable_fr, unacceptable_fr):
"""Raise an error if the acceptable_fr > unacceptable_fr."""
if acceptable_fr > unacceptable_fr:
raise ValueError("'{}' acceptable failure rate {} must be <= the unacceptable failure rate"
" {}".format(fr_name, acceptable_fr, unacceptable_fr))
def check_days(name, days):
"""Raise an error if days < 1."""
if days < 1:
raise ValueError("'{}' days must be greater than 0.".format(name))
def js_test_exists(lifecycle, test):
"""Returns True if test exists in js_test and is not a 'None' value."""
js_test = lifecycle["selector"].get("js_test")
if js_test:
return lifecycle["selector"]["js_test"].get(test) is not None
return False
def get_lifecycle_test_tags(lifecycle, test):
"""Returns list of tags for a js_test from lifecycle object."""
tags = []
if js_test_exists(lifecycle, test):
tags = lifecycle["selector"]["js_test"].get(test)
return tags
def update_lifecycle_test_tags(lifecycle, test, tags):
"""Returns lifecycle object after creating or updating the tags for 'test'."""
if not lifecycle["selector"]["js_test"]:
lifecycle["selector"]["js_test"] = {test: tags}
else:
lifecycle["selector"]["js_test"][test] = tags
return lifecycle
def unreliable_tag(test, task, variant, distro):
"""Returns the unreliable tag."""
if distro and variant and task and test:
return "unreliable|{}|{}|{}".format(task, variant, distro)
elif variant and task and test:
return "unreliable|{}|{}".format(task, variant)
elif task and test:
return "unreliable|{}".format(task)
elif test:
return "unreliable"
def _update_lifecycle(lifecycle, add_tags, test, existing_tags, update_tag):
"""Updates the lifecycle object.
If add_tags is True, creates or updates a test in lifecycle, with update_tag.
If add_tags is False, removes an empty test (no tags) in lifecycle, or removes the update_tag.
Returns updated lifecycle YAML.
"""
existing_tags_set = set(existing_tags)
if add_tags:
existing_tags_set.add(update_tag)
else:
existing_tags_set.discard(update_tag)
if existing_tags_set:
sorted_tags = sorted(list(existing_tags_set), key=lambda x: x.split("|"))
lifecycle = update_lifecycle_test_tags(lifecycle, test, sorted_tags)
elif js_test_exists(lifecycle, test):
# No tags left, so remove test from lifecycle.
del lifecycle["selector"]["js_test"][test]
def update_lifecycle(lifecycle, report, method_test, add_tags, fail_rate, min_run):
"""Updates the lifecycle object based on the test_method.
The test_method checks unreliable or reliable fail_rates.
"""
for summary in report:
if method_test(summary.fail_rate,
fail_rate,
summary.num_pass + summary.num_fail,
min_run):
existing_tags = get_lifecycle_test_tags(lifecycle, summary.test)
update_tag = unreliable_tag(
summary.test, summary.task, summary.variant, summary.distro)
_update_lifecycle(lifecycle, add_tags, summary.test, existing_tags, update_tag)
def main():
required_options = ["project",
"reliable_test_min_run",
"unreliable_test_min_run",
"test_fail_rates",
]
parser = optparse.OptionParser(description=__doc__,
usage="Usage: %prog [options] test1 test2 ...")
parser.add_option("--project", dest="project",
default=None,
help="Evergreen project to analyze [REQUIRED].")
parser.add_option("--reliableTestMinimumRun", dest="reliable_test_min_run",
default=None,
type="int",
help="Minimum number of tests runs for test to be considered as reliable"
" [REQUIRED].")
parser.add_option("--unreliableTestMinimumRun", dest="unreliable_test_min_run",
default=None,
type="int",
help="Minimum number of tests runs for test to be considered as unreliable"
" [REQUIRED].")
parser.add_option("--testFailRates", dest="test_fail_rates",
metavar="ACCEPTABLE-FAILRATE UNACCEPTABLE-FAILRATE",
default=None,
type="float",
nargs=2,
help="Test fail rates: acceptable fail rate and unacceptable fail rate"
" Specify floating numbers between 0.0 and 1.0 [REQUIRED].")
parser.add_option("--taskFailRates", dest="task_fail_rates",
metavar="ACCEPTABLE-FAILRATE UNACCEPTABLE-FAILRATE",
default=None,
type="float",
nargs=2,
help="Task fail rates: acceptable fail rate and unacceptable fail rate."
" Specify floating numbers between 0.0 and 1.0."
" Uses --test-fail-rates if unspecified.")
parser.add_option("--variantFailRates", dest="variant_fail_rates",
metavar="ACCEPTABLE-FAILRATE UNACCEPTABLE-FAILRATE",
default=None,
type="float",
nargs=2,
help="Variant fail rates: acceptable fail rate and unacceptable fail rate."
" Specify floating numbers between 0.0 and 1.0."
" Uses --task-fail-rates if unspecified.")
parser.add_option("--distroFailRates", dest="distro_fail_rates",
metavar="ACCEPTABLE-FAILRATE UNACCEPTABLE-FAILRATE",
default=None,
type="float",
nargs=2,
help="Distro fail rates: acceptable fail rate and unacceptable fail rate."
" Specify floating numbers between 0.0 and 1.0."
" Uses --variant-fail-rates if unspecified.")
parser.add_option("--tasks", dest="tasks",
default=None,
help="Names of tasks to analyze for tagging unreliable tests."
" If specified and no tests are specified, then only tests"
" associated with the tasks will be analyzed."
" If unspecified and no tests are specified, the list of tasks will be"
" the non-excluded list of tasks from the file specified by"
" '--evergreenYML'.")
parser.add_option("--variants", dest="variants",
default="",
help="Names of variants to analyze for tagging unreliable tests.")
parser.add_option("--distros", dest="distros",
default="",
help="Names of distros to analyze for tagging unreliable tests [UNUSED].")
parser.add_option("--evergreenYML", dest="evergreen_yml",
default="etc/evergreen.yml",
help="Evergreen YML file used to get the list of tasks,"
" defaults to '%default'.")
parser.add_option("--lifecycleFile", dest="lifecycle_file",
default="etc/test_lifecycle.yml",
help="Evergreen lifecycle file to update, defaults to '%default'.")
parser.add_option("--reliableDays", dest="reliable_days",
default=14,
type="int",
help="Number of days to check for reliable tests, defaults to '%default'.")
parser.add_option("--unreliableDays", dest="unreliable_days",
default=28,
type="int",
help="Number of days to check for unreliable tests, defaults to '%default'.")
parser.add_option("--batchGroupSize", dest="batch_size",
default=100,
type="int",
help="Size of test batch group, defaults to '%default'.")
(options, tests) = parser.parse_args()
for option in required_options:
if not getattr(options, option):
parser.print_help()
parser.error("Missing required option")
use_test_tasks_membership = False
tasks = options.tasks.split(",") if options.tasks else []
if not tasks:
# If no tasks are specified, then the list of tasks is all.
tasks = get_all_tasks(options.evergreen_yml)
use_test_tasks_membership = True
variants = options.variants.split(",") if options.variants else []
distros = options.distros.split(",") if options.distros else []
check_fail_rates("Test", options.test_fail_rates[0], options.test_fail_rates[1])
# The less specific failures rates are optional and default to a lower level value.
if not options.task_fail_rates:
options.task_fail_rates = options.test_fail_rates
else:
check_fail_rates("Task", options.task_fail_rates[0], options.task_fail_rates[1])
if not options.variant_fail_rates:
options.variant_fail_rates = options.task_fail_rates
else:
check_fail_rates("Variant", options.variant_fail_rates[0], options.variant_fail_rates[1])
if not options.distro_fail_rates:
options.distro_fail_rates = options.variant_fail_rates
else:
check_fail_rates("Distro", options.distro_fail_rates[0], options.distro_fail_rates[1])
check_days("Reliable days", options.reliable_days)
check_days("Unreliable days", options.unreliable_days)
orig_lifecycle = read_yaml_file(options.lifecycle_file)
lifecycle = copy.deepcopy(orig_lifecycle)
test_tasks_membership = get_test_tasks_membership(options.evergreen_yml)
# If no tests are specified then the list of tests is generated from the list of tasks.
if not tests:
tests = get_tests_from_tasks(tasks, test_tasks_membership)
if not options.tasks:
use_test_tasks_membership = True
commit_first, commit_last = git_commit_range_since("{}.days".format(options.unreliable_days))
commit_prior = git_commit_prior(commit_first)
# For efficiency purposes, group the tests and process in batches of batch_size.
test_groups = create_batch_groups(create_test_groups(tests), options.batch_size)
for tests in test_groups:
# Find all associated tasks for the test_group if tasks or tests were not specified.
if use_test_tasks_membership:
tasks_set = set()
for test in tests:
tasks_set = tasks_set.union(test_tasks_membership[test])
tasks = list(tasks_set)
if not tasks:
print("Warning - No tasks found for tests {}, skipping this group.".format(tests))
continue
report = tf.HistoryReport(period_type="revision",
start=commit_prior,
end=commit_last,
group_period=options.reliable_days,
project=options.project,
tests=tests,
tasks=tasks,
variants=variants,
distros=distros)
view_report = report.generate_report()
# We build up report_combo to check for more specific test failures rates.
report_combo = []
# TODO EVG-1665: Uncomment this line once this has been supported.
# for combo in ["test", "task", "variant", "distro"]:
for combo in ["test", "task", "variant"]:
report_combo.append(combo)
if combo == "distro":
acceptable_fail_rate = options.distro_fail_rates[0]
unacceptable_fail_rate = options.distro_fail_rates[1]
elif combo == "variant":
acceptable_fail_rate = options.variant_fail_rates[0]
unacceptable_fail_rate = options.variant_fail_rates[1]
elif combo == "task":
acceptable_fail_rate = options.task_fail_rates[0]
unacceptable_fail_rate = options.task_fail_rates[1]
else:
acceptable_fail_rate = options.test_fail_rates[0]
unacceptable_fail_rate = options.test_fail_rates[1]
# Unreliable tests are analyzed from the entire period.
update_lifecycle(lifecycle,
view_report.view_summary(group_on=report_combo),
unreliable_test,
True,
unacceptable_fail_rate,
options.unreliable_test_min_run)
# Reliable tests are analyzed from the last period, i.e., last 14 days.
(reliable_start_date, reliable_end_date) = view_report.last_period()
update_lifecycle(lifecycle,
view_report.view_summary(group_on=report_combo,
start_date=reliable_start_date,
end_date=reliable_end_date),
reliable_test,
False,
acceptable_fail_rate,
options.reliable_test_min_run)
# Update the lifecycle_file only if there have been changes.
if orig_lifecycle != lifecycle:
write_yaml_file(options.lifecycle_file, lifecycle)
if __name__ == "__main__":
main()
|