blob: 732664d4a0efe3f90369cc0ff62b785b34300343 [file] [log] [blame]
# Copyright 2016 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module for tracking and querying build status."""
import collections
import datetime
import logging
from chromite.lib import buildbucket_v2
from chromite.lib import builder_status_lib
from chromite.lib import constants
from chromite.lib import metrics
BUILD_START_TIMEOUT_MIN = 60
# TODO(nxia): Rename this module to slave_status, since this module is for
# a master build which has slave builds and there is builder_status_lib for
# managing the status of an indivudual build.
class SlaveStatus(object):
"""Keep track of statuses of all slaves from CIDB and Buildbucket(optional).
For the master build scheduling slave builds through Buildbucket, it will
interpret slave statuses by querying CIDB and Buildbucket; otherwise,
it will only interpret slave statuses by querying CIDB.
"""
ACCEPTED_STATUSES = (
constants.BUILDER_STATUS_PASSED,
constants.BUILDER_STATUS_SKIPPED,
)
def __init__(
self,
start_time,
builders_array,
master_build_identifier,
buildstore,
config=None,
metadata=None,
buildbucket_client=None,
version=None,
dry_run=True,
):
"""Initializes a SlaveStatus instance.
Args:
start_time: datetime.datetime object of when the build started.
builders_array: List of the expected slave builds.
master_build_identifier: The BuildIdentifier instance of the master build.
buildstore: BuildStore instance to make DB calls.
config: Instance of config_lib.BuildConfig. Config dict of this build.
metadata: Instance of metadata_lib.CBuildbotMetadata. Metadata of this
build.
buildbucket_client: Instance of buildbucket_v2.BuildbucketV2 client.
version: Current manifest version string. See the return type of
VersionInfo.VersionString().
dry_run: Boolean indicating whether it's a dry run. Default to True.
"""
self.start_time = start_time
self.all_builders = builders_array
self.master_build_identifier = master_build_identifier
self.master_build_id = master_build_identifier.cidb_id
self.buildstore = buildstore
self.config = config
self.metadata = metadata
self.buildbucket_client = buildbucket_client
self.version = version
self.dry_run = dry_run
# A set of completed builds which will not be retried any more.
self.completed_builds = set()
# Dict mapping config names of slaves not in self.completed_builds to
# their new CIDBStatusInfo. Everytime UpdateSlaveStatus is called,
# new (current) status will be pulled from CIDB.
self.new_cidb_status_dict = None
# Dict mapping all slave config names to CIDBStatusInfo.
self.all_cidb_status_dict = None
self.missing_builds = None
self.scheduled_builds = None
# Dict mapping config names of slaves not in self.completed_builds to
# their new BuildbucketInfo. Everytime UpdateSlaveStatus is called,
# new (current) status will be pulled from Buildbucket.
# TODO(jkop): The code uses 'is not None' checks to determine if it's using
# Buildbucket. Initialize this to a dict for simplicity when that's been
# refactored.
self.new_buildbucket_info_dict = None
# Dict mapping all slave config names to BuildbucketInfo
self.all_buildbucket_info_dict = {}
self.status_buildset_dict = {}
# Records history (per-tick) of self.completed_builds. Keep only the most
# recent 2 entries of history. Used only for metrics purposes, not used for
# any decision logic.
self._completed_build_history = collections.deque([], 2)
self.UpdateSlaveStatus()
def _GetNewSlaveCIDBStatusInfo(
self, all_cidb_status_dict, completed_builds
):
"""Get new build status information for slaves not in completed_builds.
Args:
all_cidb_status_dict: A dict mapping all build config names to their
information fetched from CIDB (in the format of CIDBStatusInfo).
completed_builds: A set of slave build configs (strings) completed before.
Returns:
A dict mapping the build config names of slave builds which are not in
the completed_builds to their CIDBStatusInfos.
"""
return {
build_config: status_info
for build_config, status_info in all_cidb_status_dict.items()
if build_config not in completed_builds
}
def _GetNewSlaveBuildbucketInfo(
self, all_buildbucket_info_dict, completed_builds
):
"""Get new buildbucket info for slave builds not in completed_builds.
Args:
all_buildbucket_info_dict: A dict mapping all slave build config names
to their BuildbucketInfos.
completed_builds: A set of slave build configs (strings) completed before.
Returns:
A dict mapping config names of slave builds which are not in the
completed_builds set to their BuildbucketInfos.
"""
completed_builds = completed_builds or {}
return {
k: v
for k, v in all_buildbucket_info_dict.items()
if k not in completed_builds
}
def _SetStatusBuildsDict(self):
"""Set status_buildset_dict by sorting the builds into their status set."""
self.status_buildset_dict = {}
for build, info in self.new_buildbucket_info_dict.items():
if info.status is not None:
self.status_buildset_dict.setdefault(info.status, set())
self.status_buildset_dict[info.status].add(build)
def UpdateSlaveStatus(self):
"""Update slave statuses by querying CIDB and Buildbucket(if supported)."""
logging.info("Updating slave status...")
if self.config and self.metadata:
scheduled_buildbucket_info_dict = buildbucket_v2.GetBuildInfoDict(
self.metadata
)
# It's possible that CQ-master has a list of important slaves configured
# but doesn't schedule any slaves as no CLs were picked up in SyncStage.
# These are set to include only important builds.
self.all_builders = list(scheduled_buildbucket_info_dict)
self.all_buildbucket_info_dict = builder_status_lib.SlaveBuilderStatus.GetAllSlaveBuildbucketInfo(
self.buildbucket_client, scheduled_buildbucket_info_dict
)
self.new_buildbucket_info_dict = self._GetNewSlaveBuildbucketInfo(
self.all_buildbucket_info_dict, self.completed_builds
)
self._SetStatusBuildsDict()
self.all_cidb_status_dict = (
builder_status_lib.SlaveBuilderStatus.GetAllSlaveCIDBStatusInfo(
self.buildstore,
self.master_build_identifier,
self.all_buildbucket_info_dict,
)
)
self.new_cidb_status_dict = self._GetNewSlaveCIDBStatusInfo(
self.all_cidb_status_dict, self.completed_builds
)
self.missing_builds = self._GetMissingBuilds()
self.scheduled_builds = self._GetScheduledBuilds()
self.completed_builds = self._GetCompletedBuilds()
def GetBuildbucketBuilds(self, build_status):
"""Get the buildbucket builds which are in the build_status status.
Args:
build_status: The status of the builds to get. The status must
be a member of constants.BUILDBUCKET_BUILDER_STATUSES.
Returns:
A set of builds in build_status status.
"""
if build_status not in constants.BUILDBUCKET_BUILDER_STATUSES:
raise ValueError(
"%s is not a member of %s "
% (build_status, constants.BUILDBUCKET_BUILDER_STATUSES)
)
return self.status_buildset_dict.get(build_status, set())
def _GetExpectedBuilders(self):
"""Returns the list of expected slave build configs.
Returns:
A list of build slave config names.
"""
experimental_builders = []
if self.metadata:
experimental_builders = self.metadata.GetValueWithDefault(
constants.METADATA_EXPERIMENTAL_BUILDERS, []
)
return [
builder
for builder in self.all_builders
if builder not in experimental_builders
]
def _GetMissingBuilds(self):
"""Returns the missing builds.
For builds scheduled by Buildbucket, missing refers to builds without
'status' from Buildbucket.
For builds not scheduled by Buildbucket, missing refers builds without
reporting status to CIDB.
Returns:
A set of the config names of missing builds.
"""
if self.new_buildbucket_info_dict is not None:
return set(
build
for build, info in self.new_buildbucket_info_dict.items()
if info.status is None
)
else:
return (
set(self._GetExpectedBuilders())
- set(self.new_cidb_status_dict)
- self.completed_builds
)
def _GetScheduledBuilds(self):
"""Returns the scheduled builds.
Returns:
For builds scheduled by Buildbucket, a set of config names of builds
with 'SCHEDULED' status in Buildbucket;
For other builds, None.
"""
if self.new_buildbucket_info_dict is not None:
return self.GetBuildbucketBuilds(
constants.BUILDBUCKET_BUILDER_STATUS_SCHEDULED
)
else:
return None
def _GetCompletedBuilds(self):
"""Returns the builds that have completed and will not be retried.
Returns:
A set of config names of completed and not retriable builds.
"""
# current completed builds (not in self.completed_builds) from CIDB
current_completed = set(
b
for b, s in self.new_cidb_status_dict.items()
if s.status in constants.BUILDER_COMPLETED_STATUSES
and b in self._GetExpectedBuilders()
)
if self.new_buildbucket_info_dict is not None:
# current completed builds (not in self.completed_builds) from Buildbucket
current_completed_buildbucket = self.GetBuildbucketBuilds(
constants.BUILDBUCKET_BUILDER_STATUS_SUCCESS
)
current_completed = (
current_completed | current_completed_buildbucket
)
for build in current_completed:
cidb_status = (
self.new_cidb_status_dict[build].status
if build in self.new_cidb_status_dict
else None
)
status_output = "Build config %s completed: CIDB status: %s." % (
build,
cidb_status,
)
if self.new_buildbucket_info_dict is not None:
status_output += " Buildbucket status %s." % (
self.new_buildbucket_info_dict[build].status
)
logging.info(status_output)
completed_builds = self.completed_builds | current_completed
return completed_builds
def _Completed(self):
"""Returns a bool if all builds have completed successfully.
Returns:
A bool of True if all builds successfully completed, False otherwise.
"""
return len(self.completed_builds) == len(self._GetExpectedBuilders())
def _GetUncompletedBuilds(self, completed_builds):
"""Get uncompleted important builds.
Args:
completed_builds: a set of config names (strings) of completed builds.
Returns:
A set of config names (strings) of uncompleted important builds.
"""
return set(self._GetExpectedBuilders()) - completed_builds
def _GetUncompletedExperimentalBuildbucketIDs(self):
"""Get buildbucket_ids for uncompleted experimental builds.
Returns:
A set of Buildbucket IDs (strings) of uncompleted experimental builds.
"""
flagged_experimental_builders = self.metadata.GetValueWithDefault(
constants.METADATA_EXPERIMENTAL_BUILDERS, []
)
experimental_slaves = self.metadata.GetValueWithDefault(
constants.METADATA_SCHEDULED_EXPERIMENTAL_SLAVES, []
)
important_slaves = self.metadata.GetValueWithDefault(
constants.METADATA_SCHEDULED_IMPORTANT_SLAVES, []
)
experimental_slaves += [
(name, bb_id, time)
for (name, bb_id, time) in important_slaves
if name in flagged_experimental_builders
]
all_experimental_bb_info_dict = (
builder_status_lib.SlaveBuilderStatus.GetAllSlaveBuildbucketInfo(
self.buildbucket_client,
buildbucket_v2.GetScheduledBuildDict(experimental_slaves),
)
)
all_experimental_cidb_status_dict = (
builder_status_lib.SlaveBuilderStatus.GetAllSlaveCIDBStatusInfo(
self.buildstore,
self.master_build_identifier,
all_experimental_bb_info_dict,
)
)
completed_experimental_builds = set(
name
for name, info in all_experimental_bb_info_dict.items()
if info.status == constants.BUILDBUCKET_BUILDER_STATUS_SUCCESS
)
completed_experimental_builds |= set(
name
for name, info in all_experimental_cidb_status_dict.items()
if info.status in constants.BUILDER_COMPLETED_STATUSES
)
return set(
[
bb_id
for (name, bb_id, time) in experimental_slaves
if name not in completed_experimental_builds
]
)
def _ShouldFailForBuilderStartTimeout(self, current_time):
"""Decides if we should fail if a build hasn't started within 5 mins.
If a build hasn't started within BUILD_START_TIMEOUT_MIN and the rest of
the builds have finished, let the caller know that we should fail.
Args:
current_time: A datetime.datetime object letting us know the current time.
Returns:
A bool saying True that we should fail, False otherwise.
"""
# Check that we're at least past the start timeout.
builder_start_deadline = datetime.timedelta(
minutes=BUILD_START_TIMEOUT_MIN
)
past_deadline = current_time - self.start_time > builder_start_deadline
# Check that we have missing builders and logging who they are.
for builder in self.missing_builds:
logging.error("No status found for build config %s.", builder)
if self.new_buildbucket_info_dict is not None:
# All scheduled builds added in new_buildbucket_info_dict are
# either in completed status or still in scheduled status.
other_builders_completed = len(self.scheduled_builds) + len(
self.completed_builds
) == len(self._GetExpectedBuilders())
for builder in self.scheduled_builds:
logging.error("Builder not started %s.", builder)
return (
past_deadline
and other_builders_completed
and self.scheduled_builds
)
else:
# Check that aside from the missing builders the rest have completed.
other_builders_completed = len(self.missing_builds) + len(
self.completed_builds
) == len(self._GetExpectedBuilders())
return (
past_deadline
and other_builders_completed
and self.missing_builds
)
@staticmethod
def _LastSlavesToComplete(completed_builds_history):
"""Given a |completed_builds_history|, find the last to complete.
Returns:
A set of build_configs that were the last to complete.
"""
if not completed_builds_history:
return set()
elif len(completed_builds_history) == 1:
return set(completed_builds_history[0])
else:
return set(completed_builds_history[-1]) - set(
completed_builds_history[-2]
)
def ShouldWait(self):
"""Decides if we should continue to wait for the builds to finish.
This will be the retry function for timeout_util.WaitForSuccess, basically
this function will return False if all builds finished or we see a problem
with the builds. Otherwise it returns True to continue polling
for the builds statuses.
Returns:
A bool of True if we should continue to wait and False if we should not.
"""
retval, slaves_remain, long_pole = self._ShouldWait()
# If we're no longer waiting, record last-slave-to-complete metrics.
if not retval and long_pole:
m = metrics.CumulativeMetric(constants.MON_LAST_SLAVE)
slaves = self._LastSlavesToComplete(self._completed_build_history)
if slaves and self.config:
increment = 1.0 / len(slaves)
for s in slaves:
m.increment_by(
increment,
fields={
"master_config": self.config.name,
"last_slave_config": s,
"slaves_remain": slaves_remain,
},
)
return retval
def _ShouldWait(self):
"""Private helper with all the main logic of ShouldWait.
Returns:
A tuple of (bool indicating if we should wait,
bool indicating if slaves remain,
bool indicating if the final slave(s) to complete should
be considered the long-pole reason for terminating)
"""
self._completed_build_history.append(list(self.completed_builds))
uncompleted_experimental_build_buildbucket_ids = (
self._GetUncompletedExperimentalBuildbucketIDs()
)
# Check if all builders completed.
if self._Completed():
buildbucket_client = buildbucket_v2.BuildbucketV2()
summary_markdown = (
"Experimental build, cancelled as all others " + "completed."
)
logging.info(summary_markdown)
buildbucket_client.BatchCancelBuilds(
list(uncompleted_experimental_build_buildbucket_ids),
summary_markdown,
)
return False, False, True
current_time = datetime.datetime.now()
uncompleted_important_builds = self._GetUncompletedBuilds(
self.completed_builds
)
uncompleted_important_build_buildbucket_ids = set(
v.buildbucket_id
for k, v in self.all_buildbucket_info_dict.items()
if k in uncompleted_important_builds
)
uncompleted_build_buildbucket_ids = list(
uncompleted_important_build_buildbucket_ids
| uncompleted_experimental_build_buildbucket_ids
)
if self._ShouldFailForBuilderStartTimeout(current_time):
summary_markdown = (
"Ending build since at least one builder has "
+ "not started within %d minutes." % BUILD_START_TIMEOUT_MIN
)
logging.error(summary_markdown)
buildbucket_client = buildbucket_v2.BuildbucketV2()
buildbucket_client.BatchCancelBuilds(
uncompleted_build_buildbucket_ids,
summary_markdown,
)
return False, False, False
# We got here which means no problems, we should still wait.
logging.info(
"Still waiting for the following builds to complete: %r",
sorted(set(self._GetExpectedBuilders()) - self.completed_builds),
)
return True, True, False