Skip to content

Commit bbd8144

Browse files
committed
add weighting
1 parent 4898748 commit bbd8144

2 files changed

Lines changed: 154 additions & 62 deletions

File tree

.github/workflows/unittest.yml

Lines changed: 73 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,19 @@ permissions:
1414

1515
# Configurable global environment variables for batching
1616
env:
17-
BATCH_SIZE: 10
1817
TEST_ALL_PACKAGES: "true" # Set to "false" to only run tests for packages with a git diff
18+
ALL_PYTHON: '["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]'
1919

2020
jobs:
2121
# Dynamic package discovery job to calculate required matrix size automatically
22+
python_config:
23+
runs-on: ubuntu-latest
24+
outputs:
25+
all_python: ${{ steps.export.outputs.python_list }}
26+
steps:
27+
- id: export
28+
run: echo "python_list=${{ env.ALL_PYTHON }}" >> "$GITHUB_OUTPUT"
29+
2230
discover-packages:
2331
runs-on: ubuntu-latest
2432
outputs:
@@ -28,54 +36,23 @@ jobs:
2836
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
2937
with:
3038
persist-credentials: false
39+
- name: Setup Python
40+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
41+
with:
42+
python-version: "3.14"
3143
- name: Generate Batch Indices
3244
id: set-matrix
3345
run: |
34-
# Testing all monorepo packages sequentially on a single runner node is
35-
# too slow and creates a severe CI bottleneck as the repository expands.
36-
#
37-
# To scale efficiently, we chunk the workload into parallel batch slices.
38-
# Instead of using a fixed, hardcoded matrix array (which risks silently
39-
# skipping newly added packages if the repository outgrows the array size),
40-
# this step dynamically audits the 'packages/' directory at runtime.
41-
#
42-
# It calculates exactly how many concurrent runners are required based on
43-
# the current repository size and the configured BATCH_SIZE variable,
44-
# ensuring 100% test coverage with zero manual YAML maintenance.
45-
46-
# 1. Count the number of total directories matching the 'packages/*' pattern.
47-
# Redirect stderr to /dev/null so empty repos do not print unneeded errors.
48-
TOTAL_PACKAGES=$(ls -d packages/*/ 2>/dev/null | wc -l | tr -d ' ')
49-
50-
# 2. Safety fallback: If no packages are detected, assign a single slice index [0]
51-
# so subsequent matrix-dependent jobs do not break or fail validation on an empty matrix.
52-
if [ "$TOTAL_PACKAGES" -eq 0 ]; then
53-
echo "indices=[0]" >> "$GITHUB_OUTPUT"
54-
exit 0
55-
fi
56-
57-
# 3. Calculate the number of batches required using ceiling division: ceil(TOTAL_PACKAGES / BATCH_SIZE).
58-
# The formula ((A + B - 1) / B) ensures integer division rounds up if there's any remaining package leftover.
59-
# Example: 251 packages with a batch size of 10 gives ((251 + 10 - 1) / 10) = 260 / 10 = 26 batches.
60-
NUM_BATCHES=$(( (TOTAL_PACKAGES + ${{ env.BATCH_SIZE }} - 1) / ${{ env.BATCH_SIZE }} ))
61-
62-
# 4. Generate a zero-indexed sequence from 0 to (NUM_BATCHES - 1).
63-
# Use jq to securely parse the raw numbers and compile them into a compacted JSON array string.
64-
# Example output format: [0,1,2,3,...,25]
65-
INDICES=$(seq 0 $((NUM_BATCHES - 1)) | jq -R . | jq -s -c .)
66-
67-
# 5. Output the finished JSON string to the GitHub environment outputs pipeline.
68-
# This will safely feed directly into the execution matrix downstream.
46+
INDICES=$(python -c 'import sys; sys.path.append("ci"); import get_batches; print(get_batches.get_batch_indices())')
6947
echo "indices=${INDICES}" >> "$GITHUB_OUTPUT"
7048
7149
unit:
7250
name: "unit-run (${{ matrix.python }}, Batch ${{ matrix.batch-index }})"
7351
runs-on: ubuntu-22.04
74-
needs: discover-packages
52+
needs: [python_config, discover-packages]
7553
strategy:
7654
matrix:
77-
python: ['3.9', '3.10', "3.11", "3.12", "3.13", "3.14"]
78-
# Dynamically scales to fit every package perfectly without hardcoding array indices
55+
python: ${{ fromJSON(needs.python_config.outputs.all_python) }}
7956
batch-index: ${{ fromJson(needs.discover-packages.outputs.batch-indices) }}
8057
steps:
8158
- name: Checkout
@@ -95,43 +72,47 @@ jobs:
9572
python -m pip install --upgrade setuptools pip wheel
9673
python -m pip install nox
9774
- name: Run unit tests
75+
id: run-tests
9876
env:
99-
COVERAGE_FILE: ${{ github.workspace }}/.coverage-${{ matrix.python }}
77+
COVERAGE_FILE: ${{ github.workspace }}/.coverage-${{ matrix.python }}-${{ matrix.batch-index }}
10078
# Dynamically set BUILD_TYPE to an empty string to skip the diff calculation if TEST_ALL_PACKAGES is true
10179
BUILD_TYPE: ${{ env.TEST_ALL_PACKAGES == 'true' && '' || 'presubmit' }}
10280
TARGET_BRANCH: ${{ github.base_ref || github.event.merge_group.base_ref }}
10381
TEST_TYPE: unit
10482
PY_VERSION: ${{ matrix.python }}
10583
run: |
106-
# Gather all packages in alphabetical order
107-
ALL_PACKAGES=($(ls -d packages/*/ | sort))
108-
TOTAL_PACKAGES=${#ALL_PACKAGES[@]}
84+
UNIQUE_BATCH_PACKAGES=$(python -c 'import sys; sys.path.append("ci"); import get_batches; print(get_batches.get_batch_slice(${{ matrix.batch-index }}))')
10985
110-
# Determine this runner's slice window
111-
START_INDEX=$(( ${{ matrix.batch-index }} * ${{ env.BATCH_SIZE }} ))
112-
113-
if [ $START_INDEX -ge $TOTAL_PACKAGES ]; then
86+
if [ -z "$UNIQUE_BATCH_PACKAGES" ]; then
87+
echo "No structural units allocated to this matrix slice."
11488
exit 0
11589
fi
11690
117-
BATCH_PACKAGES=("${ALL_PACKAGES[@]:$START_INDEX:${{ env.BATCH_SIZE }}}")
91+
echo "Running tests for packages in this weighted batch: ${UNIQUE_BATCH_PACKAGES}"
92+
ci/run_conditional_tests.sh ${UNIQUE_BATCH_PACKAGES}
11893
119-
# Strip trailing slashes to pass down directly into ci/run_conditional_tests.sh
120-
subdirs=("${BATCH_PACKAGES[@]%/}")
94+
- name: Save Status Footprint
95+
run: |
96+
mkdir -p footprints
97+
echo "${{ steps.run-tests.outcome }}" > footprints/status.txt
98+
99+
- name: Upload Status Footprint
100+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
101+
with:
102+
name: footprint-${{ matrix.python }}-${{ matrix.batch-index }}
103+
path: footprints/
121104

122-
ci/run_conditional_tests.sh "${subdirs[@]}"
123105
- name: Upload coverage results
124106
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
125107
with:
126108
# Appended batch-index to separate parallel coverage uploads cleanly
127109
name: coverage-artifact-${{ matrix.python }}-${{ matrix.batch-index }}
128-
path: .coverage-${{ matrix.python }}
110+
path: .coverage-${{ matrix.python }}-${{ matrix.batch-index }}
129111
include-hidden-files: true
130112

131113
cover:
132114
runs-on: ubuntu-latest
133-
needs:
134-
- unit
115+
needs: [unit]
135116
steps:
136117
- name: Checkout
137118
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -246,19 +227,49 @@ jobs:
246227
247228
unittest-runtime-result:
248229
name: "unit (${{ matrix.python }})"
249-
needs: unit
230+
needs: [python_config, discover-packages, unit]
231+
if: always()
250232
strategy:
233+
fail-fast: false
251234
matrix:
252-
python: ['3.9', '3.10', "3.11", "3.12", "3.13", "3.14"]
235+
python: ${{ fromJSON(needs.python_config.outputs.all_python) }}
253236
runs-on: ubuntu-latest
254237
steps:
255-
- name: Check unit tests results
256-
run: |
257-
UNIT_STATUS="${{ needs.unit.result }}"
238+
- name: Download all status footprints for this runtime
239+
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
240+
with:
241+
pattern: footprint-${{ matrix.python }}-*
242+
path: footprint-results/
258243

259-
if [[ "$UNIT_STATUS" == "success" ]]; then
260-
echo "Python ${{ matrix.python }} tests passed."
244+
- name: Validate complete batch execution footprint status
245+
run: |
246+
EXPECTED_BATCHES=$(echo '${{ needs.discover-packages.outputs.batch-indices }}' | jq '. | length')
247+
248+
if [ -d "footprint-results" ]; then
249+
ACTUAL_BATCHES=$(find footprint-results -type f -name 'status.txt' | wc -l | tr -d ' ')
261250
else
262-
echo "Error: Python ${{ matrix.python }} status is '$UNIT_STATUS'."
251+
ACTUAL_BATCHES=0
252+
fi
253+
254+
echo "Validation metrics for Python ${{ matrix.python }}:"
255+
echo " -> Expected footprint files: $EXPECTED_BATCHES"
256+
echo " -> Downloaded footprint files: $ACTUAL_BATCHES"
257+
258+
if [ "$ACTUAL_BATCHES" -ne "$EXPECTED_BATCHES" ]; then
259+
echo "Error: Footprint count mismatch! Expected $EXPECTED_BATCHES files, but found $ACTUAL_BATCHES."
260+
exit 1
261+
fi
262+
263+
FAILED_RUNS=0
264+
while read -r STATUS_FILE; do
265+
RUN_STATUS=$(cat "$STATUS_FILE" | tr -d '[:space:]')
266+
if [[ "$RUN_STATUS" != "success" ]]; then
267+
echo "Failure detected in batch profile path: $STATUS_FILE (Status: $RUN_STATUS)"
268+
FAILED_RUNS=$((FAILED_RUNS + 1))
269+
fi
270+
done < <(find footprint-results -type f -name 'status.txt' 2>/dev/null)
271+
272+
if [ "$FAILED_RUNS" -gt 0 ]; then
273+
echo "Error: Validation failed. Found $FAILED_RUNS failing test batches."
263274
exit 1
264275
fi

ci/get_batches.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#!/usr/bin/env python3
15+
import os
16+
import glob
17+
import math
18+
import json
19+
import sys
20+
21+
BATCH_SIZE = 10
22+
23+
# Large SDKs take significantly longer to run tests. Assigning higher weights
24+
# ensures these packages occupy more runner slots and load-balance effectively.
25+
PACKAGE_WEIGHTS = {
26+
"google-cloud-compute": 5,
27+
"google-cloud-compute-v1beta": 5,
28+
"google-cloud-dialogflow": 5,
29+
"google-cloud-dialogflow-cx": 5,
30+
"google-cloud-retail": 5,
31+
}
32+
33+
def get_weighted_package_list():
34+
"""Audits the packages directory and expands the list based on weights."""
35+
raw_paths = sorted(glob.glob("packages/*"))
36+
package_paths = [p for p in raw_paths if os.path.isdir(p)]
37+
38+
weighted_list = []
39+
for path in package_paths:
40+
pkg_name = os.path.basename(path)
41+
weight = PACKAGE_WEIGHTS.get(pkg_name, 1)
42+
43+
for _ in range(weight):
44+
weighted_list.append(path)
45+
46+
return weighted_list
47+
48+
def get_batch_indices():
49+
"""Returns a JSON string of the array of batch indices for GitHub Actions matrix."""
50+
weighted_list = get_weighted_package_list()
51+
total_units = len(weighted_list)
52+
num_batches = math.ceil(total_units / BATCH_SIZE)
53+
54+
if num_batches == 0:
55+
num_batches = 1
56+
57+
return json.dumps(list(range(num_batches)))
58+
59+
def get_batch_slice(batch_index):
60+
"""Returns a space-separated string of unique package paths for a specific batch index."""
61+
weighted_list = get_weighted_package_list()
62+
total_units = len(weighted_list)
63+
start_idx = batch_index * BATCH_SIZE
64+
65+
if start_idx >= total_units:
66+
return ""
67+
68+
slice_window = weighted_list[start_idx : start_idx + BATCH_SIZE]
69+
70+
unique_packages = []
71+
for pkg in slice_window:
72+
if pkg not in unique_packages:
73+
unique_packages.append(pkg)
74+
75+
return " ".join(unique_packages)
76+
77+
if __name__ == "__main__":
78+
if len(sys.argv) > 1 and sys.argv[1] == "--count":
79+
print(get_batch_indices())
80+
elif len(sys.argv) > 2 and sys.argv[1] == "--slice":
81+
print(get_batch_slice(int(sys.argv[2])))

0 commit comments

Comments
 (0)