blob: 1038d6920e14e42070b7324e7a8362eb6aa0538b [file] [log] [blame]
# Copyright 2015 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script that shows build timing for a build, and it's stages.
This script shows how long a build took, how long each stage took, and when each
stage started relative to the start of the build.
"""
from __future__ import print_function
import collections
import datetime
import itertools
from chromite.cbuildbot import constants
from chromite.lib import cidb
from chromite.lib import commandline
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
# MUST be kept in sync with GetParser's build-type option.
BUILD_TYPE_MAP = {
'cq': constants.CQ_MASTER,
'canary': constants.CANARY_MASTER,
'chrome-pfq': constants.PFQ_MASTER,
}
BuildTiming = collections.namedtuple(
'BuildTiming', ['id', 'build_config', 'duration', 'stages'])
# Sometimes used with TimeDeltas, and sometimes with TimeDeltaStats.
StageTiming = collections.namedtuple(
'StageTiming', ['name', 'start', 'finish', 'duration'])
class TimeDeltaStats(collections.namedtuple(
'TimeDeltaStats', ['median', 'mean', 'min', 'max'])):
"""Collection a stats about a set of time.timedelta values."""
__slotes__ = ()
def __str__(self):
return 'median %s mean %s min %s max %s' % self
def FillInBuildStatusesWithStages(db, build_statuses):
"""Fill in a 'stages' value for a list of build_statuses.
Modifies the build_status objects in-place.
Args:
db: cidb.CIDBConnection object.
build_statuses: List of build_status dictionaries as returned by various
CIDB methods.
"""
ids = [status['id'] for status in build_statuses]
all_stages = db.GetBuildsStages(ids)
stages_by_build_id = cros_build_lib.GroupByKey(all_stages, 'build_id')
for status in build_statuses:
status['stages'] = stages_by_build_id.get(status['id'], [])
def BuildIdToBuildStatus(db, build_id):
"""Fetch a BuildStatus (with stages) from cidb from a build_id.
Args:
db: cidb.CIDBConnection object.
build_id: build id as an integer.
Returns:
build status dictionary from CIDB with 'stages' field populated.
"""
build_status = db.GetBuildStatus(build_id)
build_status['stages'] = db.GetBuildStages(build_id)
return build_status
def FilterBuildStatuses(build_statuses):
"""We only want to process passing 'normal' builds for stats.
Args:
build_statuses: List of Cidb result dictionary. 'stages' are not needed.
Returns:
List of all build statuses that weren't removed.
"""
# Ignore tryserver, release branches, branch builders, chrome waterfall, etc.
WATERFALLS = ('chromeos', 'chromiumos')
return [status for status in build_statuses
if status['status'] == 'pass' and status['waterfall'] in WATERFALLS]
def BuildConfigToStatuses(db, build_config, start_date, end_date):
"""Find a list of BuildStatus dictionaries with stages populated.
Args:
db: cidb.CIDBConnection object.
build_config: Name of build config to find builds for.
start_date: datetime.datetime object for start of search range.
end_date: datetime.datetime object for end of search range.
Returns:
A list of cidb style BuildStatus dictionaries with 'stages' populated.
"""
# Find builds.
build_statuses = db.GetBuildHistory(
build_config, db.NUM_RESULTS_NO_LIMIT,
start_date=start_date, end_date=end_date)
build_statuses = FilterBuildStatuses(build_statuses)
# Fill in stage information.
FillInBuildStatusesWithStages(db, build_statuses)
return build_statuses
def MasterConfigToStatuses(db, build_config, start_date, end_date):
"""Find a list of BuildStatuses for all master/slave builds.
Args:
db: cidb.CIDBConnection object.
build_config: Name of build config of master builder.
start_date: datetime.datetime object for start of search range.
end_date: datetime.datetime object for end of search range.
Returns:
A list of cidb style BuildStatus dictionaries with 'stages' populated.
"""
# Find masters.
master_statuses = db.GetBuildHistory(
build_config, db.NUM_RESULTS_NO_LIMIT,
start_date=start_date, end_date=end_date)
# Find slaves.
build_statuses = []
for status in master_statuses:
build_statuses += db.GetSlaveStatuses(status['id'])
build_statuses = FilterBuildStatuses(build_statuses)
# Fill in stage information.
FillInBuildStatusesWithStages(db, build_statuses)
return build_statuses
def GetBuildTimings(build_status):
"""Convert a build_status with stages into BuildTimings.
After filling in a build_status dictionary with stage information
(FillInBuildStatusesWithStages), convert to a BuildTimings tuple with only the
data we care about.
Args:
build_status: Cidb result dictionary with 'stages' added.
Returns:
BuildTimings tuple with all time values populated as timedeltas or None.
"""
start = build_status['start_time']
def safeDuration(start, finish):
# Do time math, but don't raise on a missing value.
if start is None or finish is None:
return None
return finish - start
stage_times = []
for stage in build_status['stages']:
stage_times.append(
StageTiming(stage['name'],
safeDuration(start, stage['start_time']),
safeDuration(start, stage['finish_time']),
safeDuration(stage['start_time'], stage['finish_time'])))
return BuildTiming(build_status['id'],
build_status['build_config'],
safeDuration(start, build_status['finish_time']),
stage_times)
def CalculateTimeStats(durations):
"""Use a set of durations to populate a TimeDelaStats.
Args:
durations: A list of timedate.timedelta objects. May contain None values.
Returns:
A TimeDeltaStats object or None (if no valid deltas).
"""
durations = [d for d in durations if d is not None]
if not durations:
return None
durations.sort()
median = durations[len(durations) / 2]
# Convert to seconds so we can round to nearest second.
summation = sum(d.total_seconds() for d in durations)
average = datetime.timedelta(seconds=int(summation / len(durations)))
minimum = durations[0]
maximum = durations[-1]
return TimeDeltaStats(median, average, minimum, maximum)
def CalculateBuildStats(builds_timings):
"""Find total build time stats for a set of BuildTiming objects.
Args:
builds_timings: List of BuildTiming objects.
Returns:
TimeDeltaStats object,or None if no valid builds.
"""
return CalculateTimeStats([b.duration for b in builds_timings])
def CalculateStageStats(builds_timings):
"""Find time stats for all stages in a set of BuildTiming objects.
Given a set of builds, find all unique stage names, and calculate average
stats across all instances of that stage name.
Given a list of 20 builds, if a stage is only in 2 of them, it's stats are
only computed across the two instances.
Args:
builds_timings: List of BuildTiming objects.
Returns:
List of StageTiming objects with all time values populated with
TimeDeltaStats values.
"""
all_stages = list(itertools.chain(*[b.stages for b in builds_timings]))
stage_names = set()
stage_names.update([s.name for s in all_stages])
stage_stats = []
for name in stage_names:
named_stages = [s for s in all_stages if s.name == name]
stage_stats.append(
StageTiming(
name=name,
start=CalculateTimeStats([s.start for s in named_stages]),
finish=CalculateTimeStats([s.finish for s in named_stages]),
duration=CalculateTimeStats([s.duration for s in named_stages])))
return stage_stats
def Report(focus_build, builds_timings):
"""Generate a report describing our stats.
Args:
focus_build: A BuildTiming object for a build to compare against stats.
builds_timings: List of BuildTiming objects to display stats for.
"""
build_stats = CalculateBuildStats(builds_timings)
stage_stats = CalculateStageStats(builds_timings)
if focus_build:
print('Focus build: ', focus_build.id)
if builds_timings:
builds_timings.sort(key=lambda b: b.id)
print('Averages for %s Builds: %s - %s' %
(len(builds_timings), builds_timings[0].id, builds_timings[-1].id))
print(' Build Time:')
if focus_build:
print(' ', focus_build.duration)
if build_stats:
print(' ', build_stats)
print()
# Map name to StageTiming.
focus_stages = {}
if focus_build:
focus_stages = {s.name: s for s in focus_build.stages}
stats_stages = {s.name: s for s in stage_stats}
# Order the stage names to display, sorted by median start time.
stage_names = list(set(focus_stages.keys() + stats_stages.keys()))
def name_key(name):
f, s = focus_stages.get(name), stats_stages.get(name)
return s.start.median if s else f.start or datetime.timedelta()
stage_names.sort(key=name_key)
# Display info about each stage.
for name in stage_names:
print(' %s:' % name)
f, s = focus_stages.get(name), stats_stages.get(name)
print(' start: ', f.start if f else '', s.start if s else '')
print(' duration:', f.duration if f else '', s.duration if s else '')
print(' finish: ', f.finish if f else '', s.finish if s else '')
def GetParser():
"""Creates the argparse parser."""
parser = commandline.ArgumentParser(description=__doc__)
parser.add_argument('--cred-dir', action='store', required=True,
metavar='CIDB_CREDENTIALS_DIR',
help='Database credentials directory with certificates '
'and other connection information. Obtain your '
'credentials at go/cros-cidb-admin .')
ex_group = parser.add_mutually_exclusive_group(required=True)
# Ask for pull out a single build id to compare against other values.
ex_group.add_argument('--build-id', action='store', type=int, default=None,
help="Single build with comparison to 'normal'.")
# Look at all builds for a build_config.
ex_group.add_argument('--build-config', action='store', default=None,
help='Build config to gather stats for.')
# Look at all builds for a master/slave group.
ex_group.add_argument('--build-type', default=None,
choices=['cq', 'chrome-pfq', 'canary'],
help='Build master/salves to gather stats for: cq.')
# How many builds are included, for builder/build-types.
start_group = parser.add_mutually_exclusive_group()
start_group.add_argument('--start-date', action='store', type='date',
default=None,
help='Limit scope to a start date in the past.')
start_group.add_argument('--past-month', action='store_true', default=False,
help='Limit scope to the past 30 days up to now.')
start_group.add_argument('--past-week', action='store_true', default=False,
help='Limit scope to the past week up to now.')
start_group.add_argument('--past-day', action='store_true', default=False,
help='Limit scope to the past day up to now.')
parser.add_argument('--end-date', action='store', type='date', default=None,
help='Limit scope to an end date in the past.')
return parser
def OptionsToStartEndDates(options):
end_date = options.end_date or datetime.datetime.now().date()
start_date = end_date - datetime.timedelta(days=7)
if options.past_month:
start_date = end_date - datetime.timedelta(days=30)
elif options.past_day:
start_date = end_date - datetime.timedelta(days=1)
else:
# Default of past_week.
start_date = end_date - datetime.timedelta(days=7)
return start_date, end_date
def main(argv):
parser = GetParser()
options = parser.parse_args(argv)
# Timeframe for discovering builds, if options.build_id not used.
start_date, end_date = OptionsToStartEndDates(options)
db = cidb.CIDBConnection(options.cred_dir)
# Data about a single build (optional).
focus_build = None
if options.build_id:
logging.info('Gathering data for %s', options.build_id)
focus_status = BuildIdToBuildStatus(db, options.build_id)
focus_build = GetBuildTimings(focus_status)
builds_statuses = BuildConfigToStatuses(
db, focus_status['build_config'], start_date, end_date)
elif options.build_config:
builds_statuses = BuildConfigToStatuses(
db, options.build_config, start_date, end_date)
elif options.build_type:
builds_statuses = MasterConfigToStatuses(
db, BUILD_TYPE_MAP[options.build_type], start_date, end_date)
# Compute per-build timing.
builds_timings = [GetBuildTimings(status) for status in builds_statuses]
# Report average data.
Report(focus_build, builds_timings)