399 lines
13 KiB
Python
399 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
#
|
|
# Copyright 2021 Google Inc. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# BASED ON https://github.com/philwo/bazel-utils/blob/main/sharding/sharding.py
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
|
|
|
|
|
@dataclass
|
|
class BazelRule:
|
|
"""
|
|
Dataclass representing a bazel py_test rule (BUILD entry).
|
|
|
|
Only the subset of fields we care about is included.
|
|
"""
|
|
|
|
name: str
|
|
size: str
|
|
timeout: Optional[str] = None
|
|
|
|
def __post_init__(self):
|
|
assert self.size in ("small", "medium", "large", "enormous")
|
|
assert self.timeout in (None, "short", "moderate", "long", "eternal")
|
|
|
|
@property
|
|
def actual_timeout_s(self) -> int:
|
|
# See https://bazel.build/reference/be/common-definitions
|
|
# Timeout takes priority over size
|
|
if self.timeout == "short":
|
|
return 60
|
|
if self.timeout == "moderate":
|
|
return 60 * 5
|
|
if self.timeout == "long":
|
|
return 60 * 15
|
|
if self.timeout == "eternal":
|
|
return 60 * 60
|
|
if self.size == "small":
|
|
return 60
|
|
if self.size == "medium":
|
|
return 60 * 5
|
|
if self.size == "large":
|
|
return 60 * 15
|
|
if self.size == "enormous":
|
|
return 60 * 60
|
|
|
|
def __lt__(self, other: "BazelRule") -> bool:
|
|
return (self.name, self.actual_timeout_s) < (other.name, other.actual_timeout_s)
|
|
|
|
def __hash__(self) -> int:
|
|
return self.name.__hash__()
|
|
|
|
@classmethod
|
|
def from_xml_element(cls, element: ET.Element) -> "BazelRule":
|
|
"""Create a BazelRule from an XML element.
|
|
|
|
The XML element is expected to be produced by the
|
|
``bazel query --output=xml`` command.
|
|
"""
|
|
name = element.get("name")
|
|
all_string_tags = element.findall("string")
|
|
size = next(
|
|
(tag.get("value") for tag in all_string_tags if tag.get("name") == "size"),
|
|
"medium",
|
|
)
|
|
timeout = next(
|
|
(
|
|
tag.get("value")
|
|
for tag in all_string_tags
|
|
if tag.get("name") == "timeout"
|
|
),
|
|
None,
|
|
)
|
|
return cls(name=name, size=size, timeout=timeout)
|
|
|
|
|
|
def quote_targets(targets: Iterable[str]) -> str:
|
|
"""Quote each target in a list so that it can be passed used in subprocess."""
|
|
return (" ".join(shlex.quote(t) for t in targets)) if targets else ""
|
|
|
|
|
|
def partition_targets(targets: Iterable[str]) -> Tuple[List[str], List[str]]:
|
|
"""
|
|
Given a list of string targets, partition them into included and excluded
|
|
lists depending on whether they start with a - (exclude) or not (include).
|
|
"""
|
|
included_targets, excluded_targets = set(), set()
|
|
for target in targets:
|
|
if target[0] == "-":
|
|
assert not target[1] == "-", f"Double negation is not allowed: {target}"
|
|
excluded_targets.add(target[1:])
|
|
else:
|
|
included_targets.add(target)
|
|
return included_targets, excluded_targets
|
|
|
|
|
|
def split_tag_filters(tag_str: str) -> Tuple[Set[str], Set[str]]:
|
|
"""Split tag_filters string into include & exclude tags."""
|
|
split_tags = tag_str.split(",") if tag_str else []
|
|
return partition_targets(split_tags)
|
|
|
|
|
|
def generate_regex_from_tags(tags: Iterable[str]) -> str:
|
|
"""Turn tag filters into a regex used in bazel query."""
|
|
return "|".join([f"(\\b{re.escape(tag)}\\b)" for tag in tags])
|
|
|
|
|
|
def get_target_expansion_query(
|
|
targets: Iterable[str],
|
|
tests_only: bool,
|
|
exclude_manual: bool,
|
|
include_tags: Optional[Iterable[str]] = None,
|
|
exclude_tags: Optional[Iterable[str]] = None,
|
|
) -> str:
|
|
"""Generate the bazel query to obtain individual rules."""
|
|
included_targets, excluded_targets = partition_targets(targets)
|
|
|
|
included_targets = quote_targets(included_targets)
|
|
excluded_targets = quote_targets(excluded_targets)
|
|
|
|
query = f"set({included_targets})"
|
|
|
|
if include_tags:
|
|
tags_regex = generate_regex_from_tags(include_tags)
|
|
# Each rule has to have at least one tag from
|
|
# include_tags
|
|
query = f'attr("tags", "{tags_regex}", {query})'
|
|
|
|
if tests_only:
|
|
# Discard any non-test rules
|
|
query = f"tests({query})"
|
|
|
|
if excluded_targets:
|
|
# Exclude the targets we do not want
|
|
excluded_set = f"set({excluded_targets})"
|
|
query = f"{query} except {excluded_set}"
|
|
|
|
if exclude_manual:
|
|
# Exclude targets with 'manual' tag
|
|
exclude_tags = exclude_tags or set()
|
|
exclude_tags.add("manual")
|
|
|
|
if exclude_tags:
|
|
# Exclude targets which have at least one exclude_tag
|
|
tags_regex = generate_regex_from_tags(exclude_tags)
|
|
query = f'{query} except attr("tags", "{tags_regex}", set({included_targets}))'
|
|
|
|
return query
|
|
|
|
|
|
def run_bazel_query(query: str, debug: bool) -> ET.Element:
|
|
"""Runs bazel query with XML output format.
|
|
|
|
We need the XML to obtain rule metadata such as
|
|
size, timeout, etc.
|
|
"""
|
|
args = ["bazel", "query", "--output=xml", query]
|
|
if debug:
|
|
print(f"$ {args}", file=sys.stderr)
|
|
sys.stderr.flush()
|
|
p = subprocess.run(
|
|
args,
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
errors="replace",
|
|
universal_newlines=True,
|
|
)
|
|
output = p.stdout.strip()
|
|
return ET.fromstring(output) if output else None
|
|
|
|
|
|
def extract_rules_from_xml(element: ET.Element) -> List[BazelRule]:
|
|
"""Extract BazelRules from the XML obtained from ``bazel query --output=xml``."""
|
|
xml_rules = element.findall("rule")
|
|
return [BazelRule.from_xml_element(element) for element in xml_rules]
|
|
|
|
|
|
def group_rules_by_time_needed(
|
|
rules: List[BazelRule],
|
|
) -> List[Tuple[int, List[BazelRule]]]:
|
|
"""
|
|
Return a list of tuples of (timeout in seconds, list of rules)
|
|
sorted descending.
|
|
"""
|
|
grouped_rules = defaultdict(list)
|
|
for rule in rules:
|
|
grouped_rules[rule.actual_timeout_s].append(rule)
|
|
for timeout in grouped_rules:
|
|
grouped_rules[timeout] = sorted(grouped_rules[timeout])
|
|
return sorted(grouped_rules.items(), key=lambda x: x[0], reverse=True)
|
|
|
|
|
|
def allocate_slots_to_shards(
|
|
rules_grouped_by_time: List[Tuple[int, List[BazelRule]]],
|
|
count: int,
|
|
) -> List[Dict[int, int]]:
|
|
"""
|
|
Allocate test slots to shards using least-loaded strategy.
|
|
|
|
This only determines how many tests of each size go to each shard,
|
|
without assigning specific tests. This preserves load balancing while
|
|
allowing tests to be assigned in name order later.
|
|
"""
|
|
shard_times = [0] * count
|
|
shard_slots = [defaultdict(int) for _ in range(count)]
|
|
|
|
for timeout, rules in rules_grouped_by_time:
|
|
for _ in range(len(rules)):
|
|
# Always pick the least-loaded shard
|
|
best_idx = min(range(count), key=lambda i: shard_times[i])
|
|
shard_slots[best_idx][timeout] += 1
|
|
shard_times[best_idx] += timeout
|
|
|
|
return shard_slots
|
|
|
|
|
|
def get_rules_for_shard_naive(
|
|
rules_grouped_by_time: List[Tuple[int, List[BazelRule]]], index: int, count: int
|
|
) -> List[str]:
|
|
"""Create shards by assigning the same number of rules to each shard."""
|
|
all_rules = []
|
|
for _, rules in rules_grouped_by_time:
|
|
all_rules.extend(rules)
|
|
shard = sorted(all_rules)[index::count]
|
|
return [rule.name for rule in shard]
|
|
|
|
|
|
def get_rules_for_shard_optimal(
|
|
rules_grouped_by_time: List[Tuple[int, List[BazelRule]]], index: int, count: int
|
|
) -> List[str]:
|
|
"""Creates shards by trying to make sure each shard takes around the same time.
|
|
|
|
Uses a two-phase approach:
|
|
1. Allocate slots to shards using least-loaded strategy (determines capacity)
|
|
2. Fill slots with tests in name order (preserves test clustering)
|
|
|
|
This ensures no empty shards while keeping tests of the same size contiguous,
|
|
making it easier to locate specific tests.
|
|
|
|
``rules_grouped_by_time`` is expected to be a list of tuples of
|
|
(timeout in seconds, list of rules) sorted by timeout descending.
|
|
"""
|
|
# Phase 1: Determine slot allocation for all shards
|
|
shard_slots = allocate_slots_to_shards(rules_grouped_by_time, count)
|
|
|
|
# Phase 2: Assign tests to all shards by name order within each timeout group
|
|
all_shard_rules: List[List[BazelRule]] = [[] for _ in range(count)]
|
|
for timeout, rules in rules_grouped_by_time:
|
|
# Sort rules by name for deterministic, contiguous assignment
|
|
sorted_rules = sorted(rules, key=lambda r: r.name)
|
|
for i in range(count):
|
|
# Calculate which tests belong to each shard
|
|
# Tests are assigned contiguously: shard 0 gets first N, shard 1 gets next M
|
|
start_idx = sum(shard_slots[j][timeout] for j in range(i))
|
|
end_idx = start_idx + shard_slots[i][timeout]
|
|
all_shard_rules[i].extend(sorted_rules[start_idx:end_idx])
|
|
|
|
# Collect all rules for sanity checks
|
|
all_rules = []
|
|
for _, rules in rules_grouped_by_time:
|
|
all_rules.extend(rules)
|
|
|
|
# Sanity checks
|
|
num_all_rules = sum(len(shard) for shard in all_shard_rules)
|
|
|
|
# Make sure that there are no duplicate rules.
|
|
all_rules_set = set()
|
|
for shard in all_shard_rules:
|
|
all_rules_set = all_rules_set.union(set(shard))
|
|
assert len(all_rules_set) == num_all_rules, (
|
|
f"num of unique rules {len(all_rules_set)} "
|
|
f"doesn't match num of rules {num_all_rules}"
|
|
)
|
|
|
|
# Make sure that all rules have been included in the shards.
|
|
assert all_rules_set == set(all_rules), (
|
|
f"unique rules after sharding {len(all_rules_set)} "
|
|
f"doesn't match unique rules after sharding {len(all_rules)}"
|
|
)
|
|
|
|
print(
|
|
"get_rules_for_shard statistics:\n"
|
|
+ "\n".join(
|
|
f"\tShard {i}: {len(shard)} rules, "
|
|
f"{sum(rule.actual_timeout_s for rule in shard)} seconds"
|
|
for i, shard in enumerate(all_shard_rules)
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return sorted([rule.name for rule in all_shard_rules[index]])
|
|
|
|
|
|
def main(
|
|
targets: List[str],
|
|
*,
|
|
index: int,
|
|
count: int,
|
|
tests_only: bool = False,
|
|
exclude_manual: bool = False,
|
|
tag_filters: Optional[str] = None,
|
|
sharding_strategy: str = "optimal",
|
|
debug: bool = False,
|
|
) -> List[str]:
|
|
include_tags, exclude_tags = split_tag_filters(tag_filters)
|
|
|
|
query = get_target_expansion_query(
|
|
targets, tests_only, exclude_manual, include_tags, exclude_tags
|
|
)
|
|
xml_output = run_bazel_query(query, debug)
|
|
rules = extract_rules_from_xml(xml_output)
|
|
rules_grouped_by_time = group_rules_by_time_needed(rules)
|
|
if sharding_strategy == "optimal":
|
|
rules_for_this_shard = get_rules_for_shard_optimal(
|
|
rules_grouped_by_time, index, count
|
|
)
|
|
else:
|
|
rules_for_this_shard = get_rules_for_shard_naive(
|
|
rules_grouped_by_time, index, count
|
|
)
|
|
return rules_for_this_shard
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Expand and shard Bazel targets.")
|
|
parser.add_argument("--debug", action="store_true")
|
|
parser.add_argument("--tests_only", action="store_true")
|
|
parser.add_argument("--exclude_manual", action="store_true")
|
|
parser.add_argument(
|
|
"--index", type=int, default=os.getenv("BUILDKITE_PARALLEL_JOB", 1)
|
|
)
|
|
parser.add_argument(
|
|
"--count", type=int, default=os.getenv("BUILDKITE_PARALLEL_JOB_COUNT", 1)
|
|
)
|
|
parser.add_argument(
|
|
"--tag_filters",
|
|
type=str,
|
|
help=(
|
|
"Accepts the same string as in bazel test --test_tag_filters "
|
|
"to apply the filters during gathering targets here."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--sharding_strategy",
|
|
type=str,
|
|
default="optimal",
|
|
help=(
|
|
"What sharding strategy to use. Can be 'optimal' (try to make sure each "
|
|
"shard takes up around the same time) or 'naive' (assign the same number "
|
|
"of targets to each shard)."
|
|
),
|
|
)
|
|
parser.add_argument("targets", nargs="+")
|
|
args, extra_args = parser.parse_known_args()
|
|
args.targets = list(args.targets) + list(extra_args)
|
|
if args.index >= args.count:
|
|
parser.error(f"--index must be between 0 and {args.count - 1}")
|
|
|
|
if args.sharding_strategy not in ("optimal", "naive"):
|
|
parser.error(
|
|
"--sharding_strategy must be either 'optimal' or 'naive', "
|
|
f"got {args.sharding_strategy}"
|
|
)
|
|
|
|
my_targets = main(
|
|
targets=args.targets,
|
|
index=args.index,
|
|
count=args.count,
|
|
tests_only=args.tests_only,
|
|
exclude_manual=args.exclude_manual,
|
|
tag_filters=args.tag_filters,
|
|
sharding_strategy=args.sharding_strategy,
|
|
debug=args.debug,
|
|
)
|
|
|
|
# Print so we can capture the stdout and pipe it somewhere.
|
|
print(" ".join(my_targets))
|
|
sys.exit(0)
|