Files
Jarek Potiuk 083b82d7c4 Size CI test jobs on RunsOn runners to the parallelism they can use (#75833)
* Size CI test jobs on RunsOn runners to the parallelism they can use

Jobs that run tests with --run-in-parallel start one serial pytest per test type, so they never use more CPUs than they have test types. On RunsOn, splitting core test types into two jobs only doubles the jobs: each half still lasts as long as its slowest test type, and the out-of-memory risk the split guards against on public runners does not apply to the larger RunsOn runners. The lowest-dependencies provider jobs left their 32 CPUs partly idle, and the integration-core and system tests ran a single pytest on 32-CPU runners.

Generated-by: Claude Opus 5

* Run provider tests one provider per test type on RunsOn runners

Grouping all providers but the long-running ones into one test type left the provider jobs waiting on that group's single serial pytest while the other CPUs idled. The Non-DB jobs run every provider in one xdist pytest, where splitting test types only adds entries, so they get the providers unsplit.

Generated-by: Claude Opus 5

* Give docker on RunsOn runners enough address pools for parallel test runs

Each test type of a parallel test run is a docker compose project with its own network. Docker's default address pools fit about 30 networks, so provider jobs running a test type per CPU on 32-CPU and larger runners failed with "all predefined address pools have been fully subnetted".

Generated-by: Claude Opus 5

* Split the longest-running provider tests into several test types on RunsOn

Once every provider runs as its own test type, a job lasts as long as its slowest provider, and amazon, google and standard each take far longer than the rest as a single serial pytest. Splitting them lets the large RunsOn runners work on their parts at once. Lowest-dependencies jobs are sized by test-type count, so a change touching a few providers keeps one job.

Generated-by: Claude Opus 5

* Keep split provider test types out of compose project and artifact names

Docker Compose project names and GitHub artifact names reject the colon that names a part of a split provider.

Generated-by: Claude Opus 5

* Skip split provider test types whose provider is ignored

A split provider part selects a folder inside the provider's tests, and pytest runs an explicitly selected path even when its provider folder is ignored, so a skipped or excluded provider's parts would still run.

Generated-by: Claude Opus 5

* Fit RunsOn runner sizes to the work of each job and record their CPU and memory use

Several jobs ran a single process, or a minute or two of work, on 32-CPU or 8-CPU runners, and fourteen Helm unit test jobs each spent more time preparing the image than running their tests. Charting each job's CPU and memory use lets the size classes be set from measurements rather than from how the jobs are expected to behave.

Generated-by: Claude Opus 5

* Skip ignored test selections by the paths the test type selects

The check for an empty run treated the first remaining argument, or any path-like argument, as a test selection, so option values such as the "8" of "-n 8" or a path passed to --rootdir decided whether tests ran. It now compares the ignored folders with the paths the test type itself selects, and passes paths the user names explicitly to pytest as they are.

Generated-by: Claude Opus 5

* Reject split provider test types where all test types share one pytest

Under xdist every test type runs in one pytest process, where the --ignore arguments of a provider's other part would silently be dropped or would remove its sibling parts, so the wrong tests would run.

Generated-by: Claude Opus 5

* Count only test folders as the selections of a test type

Test suites such as All-Postgres add pytest options with values, which were taken for selected test paths, so a run whose every test folder was ignored still started pytest with nothing selected.

Generated-by: Claude Opus 5

* Run only its own test type in each worker of a parallel test run

Each worker kept the whole list of test types, so with --skip-db-tests every worker selected the folders of all of them.

Generated-by: Claude Opus 5

* Raise the AIO limit on RunsOn runners for parallel MySQL tests

A few dozen MySQL servers running at once exhaust the default fs.aio-max-nr, and InnoDB then fails with "io_setup() failed with EAGAIN".

Generated-by: Claude Opus 5

* Keep the provider compatibility tests on the grouped provider test types

Each test type of a compatibility test installs an Airflow version and the providers before running, so listing every provider as its own test type multiplied those installs and the jobs ran out of time.

Generated-by: Claude Opus 5
2026-10-29 22:12:23 +01:00

473 lines
19 KiB
YAML

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
#
---
name: Basic tests
on: # yamllint disable-line rule:truthy
workflow_call:
inputs:
newsfragment-pr-number:
description: "Pull request the newsfragments are checked against; empty to accept any number"
required: false
type: string
default: ""
runners:
description: "The array of labels (in json form) determining runners."
required: true
type: string
run-ui-tests:
description: "Whether to run UI tests (true/false)"
required: true
type: string
run-www-tests:
description: "Whether to run WWW tests (true/false)"
required: true
type: string
run-api-codegen:
description: "Whether to run API codegen (true/false)"
required: true
type: string
run-breeze-integration-tests:
description: "Whether to run breeze integration tests (true/false)"
required: true
type: string
run-scripts-tests:
description: "Whether to run scripts tests (true/false)"
required: true
type: string
run-generic-basic-tests:
description: >-
Whether to run the standalone Airflow and shared distributions tests (true/false): not on the
release branch of a single component
required: true
type: string
basic-checks-only:
description: "Whether to run only basic checks (true/false)"
required: true
type: string
skip-prek-hooks:
description: "Whether to skip prek hooks (true/false)"
required: true
type: string
default-python-version:
description: "Which version of python should be used by default"
required: true
type: string
shared-distributions-as-json:
description: "Json array of shared distributions to run tests for"
required: true
type: string
canary-run:
description: "Whether to run canary tests (true/false)"
required: true
type: string
latest-versions-only:
description: "Whether to run only latest version checks (true/false)"
required: true
type: string
use-uv:
description: "Whether to use uv in the image"
required: true
type: string
platform:
description: 'Platform for the build - linux/amd64 or linux/arm64'
required: true
type: string
push-base-commit:
description: "For a push: the last successfully tested commit of the branch (empty otherwise)"
required: false
type: string
default: ""
permissions:
contents: read
jobs:
run-breeze-tests:
timeout-minutes: 10
name: Breeze unit tests
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
steps:
- name: "Cleanup repo"
shell: bash
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need to fetch all history for selective checks tests
fetch-depth: 0
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
- name: "Run unit tests"
run: uv run --locked pytest -n auto --color=yes
working-directory: ./dev/breeze/
run-breeze-integration-tests:
timeout-minutes: 120
name: Breeze integration tests
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
if: inputs.run-breeze-integration-tests == 'true'
steps:
- name: "Cleanup repo"
shell: bash
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
- name: "Install SVN"
run: sudo apt-get update && sudo apt-get install -y subversion
- name: "Install Java (for Apache RAT)"
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: 'temurin'
java-version: '17'
- name: "Install hatch"
run: uv tool install hatch
- name: "Run integration tests"
run: uv run --locked pytest -v --color=yes -m integration_tests
working-directory: ./dev/breeze/
tests-shared-distributions:
timeout-minutes: 10
name: Shared distributions tests
if: inputs.run-generic-basic-tests == 'true'
env:
# Without a pin uv picks the runner's system Python, which can be older than Airflow supports.
UV_PYTHON: "${{ inputs.default-python-version }}"
SHARED_DISTRIBUTIONS: ${{ join(fromJSON(inputs.shared-distributions-as-json), ' ') }}
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: "RunsOn cost summary"
uses: ./.github/actions/runs-on-cost-summary
- name: "Install uv"
# Extract uv version from uv.lock. The format is stable: the line
# immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
run: |
UV_VERSION=$(sed -n '/^name = "uv"$/{n;s/^version = "\(.*\)"$/\1/p;}' uv.lock)
if [[ -z "${UV_VERSION}" ]]; then
echo "Failed to extract uv version from uv.lock" >&2
exit 1
fi
echo "Installing uv==${UV_VERSION}"
pip install "uv==${UV_VERSION}"
- name: "Run shared distributions tests"
run: |
failed=()
for dist in ${SHARED_DISTRIBUTIONS}; do
echo "::group::Shared ${dist} tests"
if ! (cd "shared/${dist}" && uv run --exact --group dev pytest --color=yes -n auto); then
failed+=("${dist}")
fi
echo "::endgroup::"
done
if (( ${#failed[@]} )); then
echo "::error::Shared distribution tests failed: ${failed[*]}"
exit 1
fi
tests-scripts:
timeout-minutes: 10
name: Scripts tests
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
if: inputs.run-scripts-tests == 'true'
env:
# Without a pin uv picks the runner's system Python, which can be older than Airflow supports.
UV_PYTHON: "${{ inputs.default-python-version }}"
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: "RunsOn cost summary"
uses: ./.github/actions/runs-on-cost-summary
- name: "Install uv"
# Extract uv version from uv.lock. The format is stable: the line
# immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
run: |
UV_VERSION=$(sed -n '/^name = "uv"$/{n;s/^version = "\(.*\)"$/\1/p;}' uv.lock)
if [[ -z "${UV_VERSION}" ]]; then
echo "Failed to extract uv version from uv.lock" >&2
exit 1
fi
echo "Installing uv==${UV_VERSION}"
pip install "uv==${UV_VERSION}"
- name: "Run scripts tests"
run: uv run --project . pytest --color=yes -n auto
working-directory: ./scripts/
tests-ui:
timeout-minutes: 15
name: React UI tests
runs-on: ${{ format(fromJSON(inputs.runners)[0], '8+16', '32+64') }}
if: inputs.run-ui-tests == 'true'
steps:
- name: "Cleanup repo"
shell: bash
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "RunsOn cost summary"
uses: ./.github/actions/runs-on-cost-summary
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 9
run_install: false
- name: "Setup node"
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: 'pnpm'
cache-dependency-path: |
airflow-core/src/airflow/ui/pnpm-lock.yaml
airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/pnpm-lock.yaml
# setup-node already caches pnpm's package store. Recreate node_modules rather than
# transferring another archive that excludes pnpm's hidden .pnpm directory by default.
- run: cd airflow-core/src/airflow/ui && pnpm install --frozen-lockfile
- run: cd airflow-core/src/airflow/ui && pnpm test
env:
FORCE_COLOR: 2
- run: cd airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui && pnpm install --frozen-lockfile
- run: cd airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui && pnpm test
env:
FORCE_COLOR: 2
check-translation-completness:
timeout-minutes: 15
name: "Check translation completeness"
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
- name: "Check translation completeness"
run: breeze ui check-translation-completeness || true
# Those checks are run if no image needs to be built for checks. This is for simple changes that
# Do not touch any of the python code or any of the important files that might require building
# The CI Docker image and they can be run entirely using the prek virtual environments on host
static-checks-basic-checks-only:
timeout-minutes: 30
name: "Static checks: basic checks only"
runs-on: ${{ format(fromJSON(inputs.runners)[0], '8+16', '32+64') }}
if: inputs.basic-checks-only == 'true'
steps:
- name: "Cleanup repo"
shell: bash
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
id: breeze
- name: "Install prek"
uses: ./.github/actions/install-prek
id: prek
with:
platform: ${{ inputs.platform }}
save-cache: true
- name: Fetch incoming commit ${{ github.sha }} with its parent
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 2
persist-credentials: false
- name: "Fetch the last tested push commit"
if: inputs.push-base-commit != ''
shell: bash
env:
PUSH_BASE_COMMIT: ${{ inputs.push-base-commit }}
run: |
git fetch --no-tags --depth=1 origin "${PUSH_BASE_COMMIT}" \
|| echo "::warning::Could not fetch ${PUSH_BASE_COMMIT}, all files will be checked"
- name: "Static checks: basic checks only"
# A push is checked from the last tested commit of the branch, like its test selection: the
# commits of earlier failed or cancelled push runs are checked again. `--from-ref` needs the
# merge base, which the shallow fetch does not have, so the changed files are listed here.
shell: bash
run: |
set -euo pipefail
prek_args=(--show-diff-on-failure --color always)
if [[ -z "${PUSH_BASE_COMMIT}" ]]; then
prek "${prek_args[@]}" --from-ref "${GITHUB_SHA}^" --to-ref "${GITHUB_SHA}"
elif git cat-file -e "${PUSH_BASE_COMMIT}^{commit}"; then
mapfile -t files < <(git diff --name-only --no-renames --diff-filter=d \
"${PUSH_BASE_COMMIT}" "${GITHUB_SHA}")
if (( ${#files[@]} > 0 )); then
prek "${prek_args[@]}" --files "${files[@]}"
fi
else
prek "${prek_args[@]}" --all-files
fi
env:
PUSH_BASE_COMMIT: ${{ inputs.push-base-commit }}
VERBOSE: "false"
SKIP_BREEZE_PREK_HOOKS: "true"
PR_NUMBER: ${{ inputs.newsfragment-pr-number }}
GITHUB_TOKEN: ${{ github.token }}
SKIP: ${{ inputs.skip-prek-hooks }}
COLUMNS: "202"
- name: "Fail if static checks modified tracked files"
# prek does not report files that a nested-project hook modifies outside its own project,
# so check the tree independently.
shell: bash
run: |
if ! git diff --quiet; then
echo "::error::Static checks passed but modified tracked files. Commit the changes below."
git diff --stat
git diff | head -n 500 || true
exit 1
fi
test-git-clone-on-windows:
timeout-minutes: 5
name: "Test git clone on Windows"
runs-on: ["windows-2025"]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
persist-credentials: false
test-airflow-release-commands:
timeout-minutes: 80
name: "Test Airflow release commands"
runs-on: ${{ format(fromJSON(inputs.runners)[0], '8+16', '32+64') }}
env:
PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}"
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_USERNAME: ${{ github.actor }}
VERBOSE: "true"
if: inputs.canary-run == 'true'
steps:
- name: "Cleanup repo"
shell: bash
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
- name: "Cleanup dist files"
run: rm -fv ./dist/*
- name: Setup git for tagging
run: |
git config --global user.email "bot@airflow.apache.org"
git config --global user.name "Your friendly bot"
- name: Install twine
run: pip install twine
- name: "Check Airflow create minor branch command"
run: breeze release-management create-minor-branch --version-branch 3-2 --answer yes --dry-run
- name: "Check Airflow RC process command"
run: >
breeze release-management start-rc-process --version 3.2.0rc1 --previous-version 3.1.0
--task-sdk-version 1.1.0rc1 --sync-branch v3-2-test --answer yes --dry-run
- name: "Check Airflow release process command"
run: >
breeze release-management start-release --version 3.2.7
--answer yes --dry-run
- name: "Test providers metadata generation"
run: |
git remote add upstream https://github.com/apache/airflow.git
git fetch upstream --tags
breeze release-management generate-providers-metadata --refresh-constraints-and-airflow-releases
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Fetch all git tags for origin"
run: git fetch --tags >/dev/null 2>&1 || true
- name: "Test airflow core issue generation automatically"
run: |
breeze release-management generate-issue-content-core \
--limit-pr-count 2 --previous-release 3.0.1 --current-release 3.0.2 --verbose
test-airflow-standalone:
timeout-minutes: 30
name: "Test Airflow standalone commands"
if: inputs.run-generic-basic-tests == 'true'
runs-on: ${{ format(fromJSON(inputs.runners)[0], '4+8', '16+32') }}
env:
AIRFLOW_HOME: ~/airflow
FORCE_COLOR: 1
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "RunsOn cost summary"
uses: ./.github/actions/runs-on-cost-summary
- name: "Install uv"
# Extract uv version from uv.lock. The format is stable: the line
# immediately after `name = "uv"` is `version = "<X.Y.Z>"`.
run: |
UV_VERSION=$(sed -n '/^name = "uv"$/{n;s/^version = "\(.*\)"$/\1/p;}' uv.lock)
if [[ -z "${UV_VERSION}" ]]; then
echo "Failed to extract uv version from uv.lock" >&2
exit 1
fi
echo "Installing uv==${UV_VERSION}"
pip install "uv==${UV_VERSION}"
- name: "Set up Airflow home directory"
run: |
echo "Setting AIRFLOW_HOME to $AIRFLOW_HOME"
mkdir -p $AIRFLOW_HOME
- name: "Install Airflow from current repo (simulating user installation)"
run: |
uv venv
set -x
uv pip install -e ./airflow-core
- name: "Test airflow standalone command"
run: |
uv run --no-sync airflow standalone 2>&1 | tee airflow_startup.log &
AIRFLOW_PID=$!
# Wait for ready message till timeout (10 minutes)
for i in {1..600}; do
if ! kill -0 $AIRFLOW_PID 2>/dev/null; then
wait $AIRFLOW_PID
EXIT_CODE=$?
echo "FAILED: Airflow standalone exited with code $EXIT_CODE"
exit $EXIT_CODE
fi
if grep -q "Airflow is ready" airflow_startup.log; then
echo "SUCCESS: Airflow standalone is ready!"
kill $AIRFLOW_PID
exit 0
fi
sleep 1
done
echo "FAILED: Airflow standalone did not become ready in time"
kill $AIRFLOW_PID 2>/dev/null || true
exit 1