From 408f11037c1408147ec6aa99564572a774f03e0b Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Wed, 30 Sep 2026 23:28:01 +0800 Subject: [PATCH 1/8] Keep the Breeze build cache volume until --cleanup-build-cache is passed breeze down --cleanup-build-cache and breeze doctor removed a volume named airflow-cache-volume, but compose prefixes the volume with the project name, so neither ever removed anything. A plain breeze down removed the volume every time instead, although the docs say build caches are only deleted with an explicit cache-cleanup flag. --- dev/breeze/doc/03_developer_tasks.rst | 3 + .../commands/developer_commands.py | 7 +-- .../utils/docker_command_utils.py | 17 +++++- dev/breeze/tests/test_developer_commands.py | 57 +++++++++++++++++- dev/breeze/tests/test_docker_command_utils.py | 59 +++++++++++++++++++ 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/dev/breeze/doc/03_developer_tasks.rst b/dev/breeze/doc/03_developer_tasks.rst index a630b6c2f3fdc..8d50ea345c05d 100644 --- a/dev/breeze/doc/03_developer_tasks.rst +++ b/dev/breeze/doc/03_developer_tasks.rst @@ -809,6 +809,9 @@ Discovery includes volumes and networks even when no containers remain. Resource directly through Docker, so deleted worktrees do not need their Compose files restored. ``--preserve-volumes`` keeps named and anonymous volumes, including those from deleted worktrees. Shared MyPy and bytecode caches remain controlled by their explicit cleanup flags. +The build cache volume of each project (``/root/.cache`` in the container) is kept as well until +``--cleanup-build-cache`` is passed, which also works together with ``--preserve-volumes``. +Build cache volumes of deleted worktrees are removed without the flag. Unlike ``breeze cleanup``, ``down`` does not delete local source files, environments, images, or build caches unless an explicit cache-cleanup flag is passed. diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py index a320cb633abd0..5d6f21609c53a 100644 --- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py @@ -1179,6 +1179,7 @@ def down( current_worktree = str(AIRFLOW_ROOT_PATH.resolve()) if get_main_git_dir_for_worktree() else "" brought_down = bring_compose_projects_down( preserve_volumes=preserve_volumes, + cleanup_build_cache=cleanup_build_cache, all_worktrees=all_worktrees, only_project=project_name, current_worktree=current_worktree, @@ -1214,9 +1215,6 @@ def down( shutil.rmtree(hook_dir) if cleanup_pycache: run_command(["docker", "volume", "rm", "--force", PYCACHE_VOLUME_NAME]) - if cleanup_build_cache: - command_to_execute = ["docker", "volume", "rm", "--force", "airflow-cache-volume"] - run_command(command_to_execute) @main.command(name="exec", help="Joins the interactive shell of running airflow container.") @@ -1357,8 +1355,7 @@ def doctor(ctx): shutil.rmtree(local_mypy_cache) console_print("\n[info]Cleaning build cache...\n") - command_to_execute = ["docker", "volume", "rm", "--force", "airflow-cache-volume"] - run_command(command_to_execute) + bring_compose_projects_down(all_worktrees=True, preserve_volumes=True, cleanup_build_cache=True) console_print("\n[info]Deleting .build cache dir...\n") dirpath = Path(".build") diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py index cc04933661dd8..b513885c2ef6a 100644 --- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py @@ -1076,9 +1076,15 @@ def _get_compose_resources( ], result.returncode != 0 +# Key of the `/root/.cache` volume in the compose files. Compose prefixes the real volume name with the +# project name, so the volume is recognised by the `com.docker.compose.volume` label instead. +BUILD_CACHE_VOLUME = "airflow-cache-volume" + + def bring_compose_projects_down( *, preserve_volumes: bool = False, + cleanup_build_cache: bool = False, all_worktrees: bool = False, only_project: str | None = None, current_worktree: str = "", @@ -1088,12 +1094,13 @@ def bring_compose_projects_down( projects: set[str] = set() failed = False for kind in targets: - if kind == "volume" and preserve_volumes: + if kind == "volume" and preserve_volumes and not cleanup_build_cache: continue resources, discovery_failed = _get_compose_resources(kind, stale_only=stale_only) failed |= discovery_failed for identifier, labels in resources: project = labels["com.docker.compose.project"] + worktree = labels.get("org.apache.airflow.breeze.worktree", "") breeze_owned = labels.get("org.apache.airflow.breeze") == "true" legacy = ( "org.apache.airflow.breeze" not in labels @@ -1108,7 +1115,6 @@ def bring_compose_projects_down( elif all_worktrees: selected = breeze_owned or legacy else: - worktree = labels.get("org.apache.airflow.breeze.worktree", "") selected = breeze_owned and ( (not stale_only and worktree in ("", current_worktree)) or _belongs_to_deleted_worktree( @@ -1117,6 +1123,13 @@ def bring_compose_projects_down( ) if not stale_only and legacy: selected = True + if selected and kind == "volume": + if labels.get("com.docker.compose.volume") == BUILD_CACHE_VOLUME: + # The build cache holds no state, so it outlives the project until it is asked for + # explicitly. A deleted worktree will never use its cache again. + selected = cleanup_build_cache or _worktree_is_missing(worktree) + else: + selected = not preserve_volumes if selected: targets[kind].append(identifier) projects.add(project) diff --git a/dev/breeze/tests/test_developer_commands.py b/dev/breeze/tests/test_developer_commands.py index b9c603f6da7f6..2074215c5b59e 100644 --- a/dev/breeze/tests/test_developer_commands.py +++ b/dev/breeze/tests/test_developer_commands.py @@ -17,7 +17,7 @@ from __future__ import annotations -from unittest.mock import create_autospec, patch +from unittest.mock import call, create_autospec, patch import click import pytest @@ -25,8 +25,9 @@ from click.testing import CliRunner from airflow_breeze.commands import developer_commands -from airflow_breeze.commands.developer_commands import build_docs, down, run +from airflow_breeze.commands.developer_commands import build_docs, doctor, down, run from airflow_breeze.global_constants import DEFAULT_PYTHON_MAJOR_MINOR_VERSION +from airflow_breeze.utils.confirm import Answer @pytest.fixture @@ -116,6 +117,58 @@ def test_down_lists_projects_left_in_other_worktrees(runner, tmp_path, args, lis assert "/work/other" not in output +@pytest.mark.parametrize(("args", "cleanup_build_cache"), [([], False), (["--cleanup-build-cache"], True)]) +def test_down_cleans_build_cache_only_on_request(runner, args, cleanup_build_cache): + with ( + patch("airflow_breeze.commands.developer_commands.perform_environment_checks", autospec=True), + patch( + "airflow_breeze.commands.developer_commands.bring_compose_projects_down", + autospec=True, + return_value=[], + ) as teardown, + patch( + "airflow_breeze.commands.developer_commands.find_other_worktree_projects", + autospec=True, + return_value={}, + ), + patch("airflow_breeze.commands.developer_commands.run_command", autospec=True) as run, + ): + result = runner.invoke(down, args) + assert result.exit_code == 0 + assert teardown.call_args.kwargs["cleanup_build_cache"] is cleanup_build_cache + run.assert_not_called() + + +@pytest.mark.parametrize("cache_removal_confirmed", [False, True]) +def test_doctor_removes_build_cache_volumes_after_confirmation(runner, tmp_path, cache_removal_confirmed): + answers = [Answer.NO, Answer.YES if cache_removal_confirmed else Answer.NO, Answer.NO] + with ( + patch("airflow_breeze.commands.developer_commands.ShellParams", autospec=True), + patch("airflow_breeze.commands.developer_commands.check_docker_resources", autospec=True), + patch("airflow_breeze.commands.developer_commands.perform_environment_checks", autospec=True), + patch("airflow_breeze.commands.developer_commands.fix_ownership_using_docker", autospec=True), + patch("airflow_breeze.commands.developer_commands.run_command", autospec=True), + patch("airflow_breeze.commands.developer_commands.AIRFLOW_ROOT_PATH", tmp_path), + patch("airflow_breeze.commands.developer_commands.user_confirm", autospec=True, side_effect=answers), + patch( + "airflow_breeze.commands.developer_commands.bring_compose_projects_down", + autospec=True, + return_value=[], + ) as teardown, + runner.isolated_filesystem(temp_dir=tmp_path), + ): + result = runner.invoke(doctor, catch_exceptions=False) + assert result.exit_code == 0 + assert teardown.call_args_list == [ + call(all_worktrees=True), + *( + [call(all_worktrees=True, preserve_volumes=True, cleanup_build_cache=True)] + if cache_removal_confirmed + else [] + ), + ] + + @pytest.fixture def docker_mocks(monkeypatch, tmp_path): monkeypatch.setenv("SKIP_SAVING_CHOICES", "true") diff --git a/dev/breeze/tests/test_docker_command_utils.py b/dev/breeze/tests/test_docker_command_utils.py index a6211db421232..5dc9a3f61535c 100644 --- a/dev/breeze/tests/test_docker_command_utils.py +++ b/dev/breeze/tests/test_docker_command_utils.py @@ -24,6 +24,7 @@ from unittest.mock import call import pytest +import yaml from airflow_breeze.global_constants import ( ALLOWED_POSTGRES_VERSIONS, @@ -45,6 +46,10 @@ prepare_docker_build_command, pull_images_with_retries, ) +from airflow_breeze.utils.path_utils import ( + SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH, + SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH, +) @pytest.mark.parametrize("listing_failed", [False, True]) @@ -745,6 +750,60 @@ def test_down_finds_volumes_without_containers(docker_resources, tmp_path, prese ) +@pytest.mark.parametrize( + "compose_file", + [SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH, SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH], + ids=lambda path: path.name, +) +def test_build_cache_volume_is_the_one_the_compose_files_mount(compose_file): + compose = yaml.safe_load(compose_file.read_text()) + assert docker_command_utils.BUILD_CACHE_VOLUME in compose["volumes"] + assert ( + f"{docker_command_utils.BUILD_CACHE_VOLUME}:/root/.cache" in compose["services"]["airflow"]["volumes"] + ) + + +@pytest.mark.parametrize( + ("preserve_volumes", "cleanup_build_cache", "expected"), + [ + (False, False, ["database", "deleted-worktree-cache"]), + (False, True, ["database", "cache", "deleted-worktree-cache"]), + (True, False, []), + (True, True, ["cache", "deleted-worktree-cache"]), + ], +) +def test_down_keeps_build_cache_volume_until_asked( + docker_resources, tmp_path, preserve_volumes, cleanup_build_cache, expected +): + resources, run = docker_resources + cache = {"com.docker.compose.project": "breeze", "com.docker.compose.volume": "airflow-cache-volume"} + resources["volume"] = [ + { + "Name": "database", + "Labels": { + "com.docker.compose.project": "breeze", + "com.docker.compose.volume": "root-airflow-volume", + }, + }, + {"Name": "cache", "Labels": cache}, + { + "Name": "deleted-worktree-cache", + "Labels": { + **cache, + "org.apache.airflow.breeze": "true", + "org.apache.airflow.breeze.worktree": str(tmp_path / "deleted"), + }, + }, + ] + + docker_command_utils.bring_compose_projects_down( + preserve_volumes=preserve_volumes, cleanup_build_cache=cleanup_build_cache + ) + + removals = [c.args[0] for c in run.call_args_list if c.args[0][2] == "rm"] + assert removals == ([["docker", "volume", "rm", *expected]] if expected else []) + + @pytest.mark.parametrize("failed_action", ["ls", "inspect", "stop", "rm"]) def test_startup_cleanup_continues_after_docker_failures(docker_resources, tmp_path, capsys, failed_action): resources, run = docker_resources From f4fcfceb55034faf84939beea9364699a48e381c Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Wed, 14 Oct 2026 00:26:35 +0800 Subject: [PATCH 2/8] Mention the build cache exception in the breeze down help text --- dev/breeze/doc/images/output_down.svg | 4 ++-- dev/breeze/doc/images/output_down.txt | 2 +- dev/breeze/src/airflow_breeze/commands/developer_commands.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dev/breeze/doc/images/output_down.svg b/dev/breeze/doc/images/output_down.svg index 142e246c26cd9..3240a37c9db4d 100644 --- a/dev/breeze/doc/images/output_down.svg +++ b/dev/breeze/doc/images/output_down.svg @@ -127,8 +127,8 @@ Usage:breeze down[OPTIONS] Stop Breeze projects with no worktree, in this checkout, or belonging to deleted worktrees. Removes containers,  -networks and volumes without changing local files or images. Use --all-worktrees to include other checkouts, or  ---project-name for one exact project. +networks and volumes, except the build cache volume, without changing local files or images. Use --all-worktrees to  +include other checkouts, or --project-name for one exact project. ╭─ Down flags ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │--preserve-volumes   -pKeep all volumes, including those belonging to deleted worktrees.│ diff --git a/dev/breeze/doc/images/output_down.txt b/dev/breeze/doc/images/output_down.txt index f9304b16b7dc7..f5ceb39fc6ae3 100644 --- a/dev/breeze/doc/images/output_down.txt +++ b/dev/breeze/doc/images/output_down.txt @@ -1 +1 @@ -61e3246738b37b2284ee184454d8a2b4 +a17573114b1d28c35fe64a5e8534aa5a diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py index 5d6f21609c53a..6e29256524b99 100644 --- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py @@ -1122,7 +1122,8 @@ def build_docs( name="down", help=( "Stop Breeze projects with no worktree, in this checkout, or belonging to deleted worktrees. " - "Removes containers, networks and volumes without changing local files or images. " + "Removes containers, networks and volumes, except the build cache volume, without changing " + "local files or images. " "Use --all-worktrees to include other checkouts, or --project-name for one exact project." ), ) From 7bd95c4d1db9f6e3d07e93d0eeeec85fcd5ee8a7 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Wed, 14 Oct 2026 00:30:01 +0800 Subject: [PATCH 3/8] Split the breeze down help text into two sentences --- dev/breeze/doc/images/output_down.svg | 2 +- dev/breeze/doc/images/output_down.txt | 2 +- dev/breeze/src/airflow_breeze/commands/developer_commands.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/breeze/doc/images/output_down.svg b/dev/breeze/doc/images/output_down.svg index 3240a37c9db4d..aa318fa7afa26 100644 --- a/dev/breeze/doc/images/output_down.svg +++ b/dev/breeze/doc/images/output_down.svg @@ -127,7 +127,7 @@ Usage:breeze down[OPTIONS] Stop Breeze projects with no worktree, in this checkout, or belonging to deleted worktrees. Removes containers,  -networks and volumes, except the build cache volume, without changing local files or images. Use --all-worktrees to  +networks and volumes, except the build cache volume. Local files and images are not changed. Use --all-worktrees to  include other checkouts, or --project-name for one exact project. ╭─ Down flags ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ diff --git a/dev/breeze/doc/images/output_down.txt b/dev/breeze/doc/images/output_down.txt index f5ceb39fc6ae3..88c7a7df0e48b 100644 --- a/dev/breeze/doc/images/output_down.txt +++ b/dev/breeze/doc/images/output_down.txt @@ -1 +1 @@ -a17573114b1d28c35fe64a5e8534aa5a +a583c66b5cedf94d5ba58a81a533b462 diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py index 6e29256524b99..695336cbce71b 100644 --- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py @@ -1122,8 +1122,8 @@ def build_docs( name="down", help=( "Stop Breeze projects with no worktree, in this checkout, or belonging to deleted worktrees. " - "Removes containers, networks and volumes, except the build cache volume, without changing " - "local files or images. " + "Removes containers, networks and volumes, except the build cache volume. " + "Local files and images are not changed. " "Use --all-worktrees to include other checkouts, or --project-name for one exact project." ), ) From 5a6d716baa3c140fb1f02769f1cf043a8251d9c4 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Thu, 15 Oct 2026 18:18:40 +0800 Subject: [PATCH 4/8] Mention build cache volumes in the breeze doctor cleanup prompt --- dev/breeze/src/airflow_breeze/commands/developer_commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py index 695336cbce71b..6df06af835125 100644 --- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py @@ -1345,7 +1345,9 @@ def doctor(ctx): bring_compose_projects_down(all_worktrees=True) - given_answer = user_confirm("Are you sure with the removal of mypy cache and build cache dir?") + given_answer = user_confirm( + "Are you sure with the removal of mypy cache, build cache volumes and build cache dir?" + ) if given_answer == Answer.YES: console_print("\n[info]Cleaning mypy cache...\n") command_to_execute = ["docker", "volume", "rm", "--force", "mypy-cache-volume"] From 14ceabbeda01384e352d69144dfae0933df93ef2 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Mon, 19 Oct 2026 13:01:39 +0800 Subject: [PATCH 5/8] Scope the build cache docs to breeze down and doctor Commands that use the shared single-project teardown still remove the build cache volume, so the docs only promise it for down and doctor. Keeping one preserve/cleanup decision in a single helper stops the docs and the --preserve-volumes behaviour from drifting apart again, and doctor no longer runs the teardown discovery twice. --- dev/breeze/doc/03_developer_tasks.rst | 7 ++-- dev/breeze/doc/images/output_down.svg | 34 +++++++++++-------- dev/breeze/doc/images/output_down.txt | 2 +- .../commands/developer_commands.py | 17 +++++----- .../utils/docker_command_utils.py | 27 ++++++++------- .../src/airflow_breeze/utils/path_utils.py | 3 ++ dev/breeze/tests/test_developer_commands.py | 9 +---- dev/breeze/tests/test_docker_command_utils.py | 7 ++-- 8 files changed, 55 insertions(+), 51 deletions(-) diff --git a/dev/breeze/doc/03_developer_tasks.rst b/dev/breeze/doc/03_developer_tasks.rst index 8d50ea345c05d..8083a40b23edd 100644 --- a/dev/breeze/doc/03_developer_tasks.rst +++ b/dev/breeze/doc/03_developer_tasks.rst @@ -809,9 +809,12 @@ Discovery includes volumes and networks even when no containers remain. Resource directly through Docker, so deleted worktrees do not need their Compose files restored. ``--preserve-volumes`` keeps named and anonymous volumes, including those from deleted worktrees. Shared MyPy and bytecode caches remain controlled by their explicit cleanup flags. -The build cache volume of each project (``/root/.cache`` in the container) is kept as well until +``down`` keeps the build cache volume of each project (``/root/.cache`` in the container) until ``--cleanup-build-cache`` is passed, which also works together with ``--preserve-volumes``. -Build cache volumes of deleted worktrees are removed without the flag. +Without ``--preserve-volumes``, build cache volumes of deleted worktrees are removed without the flag. +``breeze doctor`` removes them only when you confirm the cache removal. Other commands that bring +a project down, such as ``breeze shell --restart``, still remove its build cache volume with the +other volumes. Unlike ``breeze cleanup``, ``down`` does not delete local source files, environments, images, or build caches unless an explicit cache-cleanup flag is passed. diff --git a/dev/breeze/doc/images/output_down.svg b/dev/breeze/doc/images/output_down.svg index aa318fa7afa26..4bfec477eb1b2 100644 --- a/dev/breeze/doc/images/output_down.svg +++ b/dev/breeze/doc/images/output_down.svg @@ -1,4 +1,4 @@ - + │--preserve-volumes   -pKeep all volumes, including those belonging to deleted worktrees.│ │--cleanup-mypy-cache -cAdditionally cleanup MyPy cache.│ │--cleanup-pycache    Additionally cleanup the Python bytecode cache volume used by Breeze shells.│ -│--cleanup-build-cache-bAdditionally cleanup Build (pip/uv) cache.│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Project selection ──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│--all-worktreesRemove Breeze-owned resources across all checkouts, including leftover volumes. Unrelated Docker   │ -│projects are left alone.                                                                           │ -│--project-name Restrict removal to this exact Compose project, without cleaning up other stale worktrees. (TEXT)│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│--verbose-vPrint verbose information about performed steps.│ -│--dry-run-DIf dry-run is set, commands are only printed, not executed.│ -│--help   -hShow this message and exit.│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +│--cleanup-build-cache-bAlso remove the build (pip/uv) cache volumes of the projects being brought down (this    │ +│checkout unless --all-worktrees or --project-name is used).                              │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Project selection ──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│--all-worktreesRemove Breeze-owned resources across all checkouts, including leftover volumes. Unrelated Docker   │ +│projects are left alone.                                                                           │ +│--project-name Restrict removal to this exact Compose project, without cleaning up other stale worktrees. (TEXT)│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│--verbose-vPrint verbose information about performed steps.│ +│--dry-run-DIf dry-run is set, commands are only printed, not executed.│ +│--help   -hShow this message and exit.│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_down.txt b/dev/breeze/doc/images/output_down.txt index 88c7a7df0e48b..74804544404cc 100644 --- a/dev/breeze/doc/images/output_down.txt +++ b/dev/breeze/doc/images/output_down.txt @@ -1 +1 @@ -a583c66b5cedf94d5ba58a81a533b462 +8c22f3aea0baaec2c789c4075359aba2 diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py index 6df06af835125..2cd1a304f9b45 100644 --- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py @@ -1147,7 +1147,8 @@ def build_docs( @click.option( "-b", "--cleanup-build-cache", - help="Additionally cleanup Build (pip/uv) cache.", + help="Also remove the build (pip/uv) cache volumes of the projects being brought down " + "(this checkout unless --all-worktrees or --project-name is used).", is_flag=True, ) @click.option( @@ -1343,12 +1344,13 @@ def doctor(ctx): if not get_dry_run() and given_answer == Answer.YES: cleanup_python_generated_files() - bring_compose_projects_down(all_worktrees=True) - - given_answer = user_confirm( - "Are you sure with the removal of mypy cache, build cache volumes and build cache dir?" + cleanup_caches = ( + user_confirm("Are you sure with the removal of mypy cache, build cache volumes and build cache dir?") + == Answer.YES ) - if given_answer == Answer.YES: + bring_compose_projects_down(all_worktrees=True, cleanup_build_cache=cleanup_caches) + + if cleanup_caches: console_print("\n[info]Cleaning mypy cache...\n") command_to_execute = ["docker", "volume", "rm", "--force", "mypy-cache-volume"] run_command(command_to_execute) @@ -1357,9 +1359,6 @@ def doctor(ctx): console_print(f"\n[info]Removing local mypy cache: {local_mypy_cache}\n") shutil.rmtree(local_mypy_cache) - console_print("\n[info]Cleaning build cache...\n") - bring_compose_projects_down(all_worktrees=True, preserve_volumes=True, cleanup_build_cache=True) - console_print("\n[info]Deleting .build cache dir...\n") dirpath = Path(".build") if not get_dry_run() and dirpath.exists() and dirpath.is_dir(): diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py index b513885c2ef6a..b076b160ed272 100644 --- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py @@ -38,6 +38,7 @@ from airflow_breeze.utils.host_info_utils import get_host_group_id, get_host_os, get_host_user_id from airflow_breeze.utils.path_utils import ( AIRFLOW_ROOT_PATH, + BUILD_CACHE_VOLUME, SCRIPTS_DOCKER_PATH, cleanup_python_generated_files, create_mypy_volume_if_needed, @@ -1076,9 +1077,16 @@ def _get_compose_resources( ], result.returncode != 0 -# Key of the `/root/.cache` volume in the compose files. Compose prefixes the real volume name with the -# project name, so the volume is recognised by the `com.docker.compose.volume` label instead. -BUILD_CACHE_VOLUME = "airflow-cache-volume" +def _should_remove_volume( + labels: dict[str, str], *, preserve_volumes: bool, cleanup_build_cache: bool +) -> bool: + if labels.get("com.docker.compose.volume") != BUILD_CACHE_VOLUME: + return not preserve_volumes + # The build cache holds no state, so it outlives the project until it is asked for explicitly. + # A deleted worktree will never use its cache again. + return cleanup_build_cache or ( + not preserve_volumes and _worktree_is_missing(labels.get("org.apache.airflow.breeze.worktree", "")) + ) def bring_compose_projects_down( @@ -1094,13 +1102,10 @@ def bring_compose_projects_down( projects: set[str] = set() failed = False for kind in targets: - if kind == "volume" and preserve_volumes and not cleanup_build_cache: - continue resources, discovery_failed = _get_compose_resources(kind, stale_only=stale_only) failed |= discovery_failed for identifier, labels in resources: project = labels["com.docker.compose.project"] - worktree = labels.get("org.apache.airflow.breeze.worktree", "") breeze_owned = labels.get("org.apache.airflow.breeze") == "true" legacy = ( "org.apache.airflow.breeze" not in labels @@ -1115,6 +1120,7 @@ def bring_compose_projects_down( elif all_worktrees: selected = breeze_owned or legacy else: + worktree = labels.get("org.apache.airflow.breeze.worktree", "") selected = breeze_owned and ( (not stale_only and worktree in ("", current_worktree)) or _belongs_to_deleted_worktree( @@ -1124,12 +1130,9 @@ def bring_compose_projects_down( if not stale_only and legacy: selected = True if selected and kind == "volume": - if labels.get("com.docker.compose.volume") == BUILD_CACHE_VOLUME: - # The build cache holds no state, so it outlives the project until it is asked for - # explicitly. A deleted worktree will never use its cache again. - selected = cleanup_build_cache or _worktree_is_missing(worktree) - else: - selected = not preserve_volumes + selected = _should_remove_volume( + labels, preserve_volumes=preserve_volumes, cleanup_build_cache=cleanup_build_cache + ) if selected: targets[kind].append(identifier) projects.add(project) diff --git a/dev/breeze/src/airflow_breeze/utils/path_utils.py b/dev/breeze/src/airflow_breeze/utils/path_utils.py index 34b2b571a6a3c..02a6b1c85188e 100644 --- a/dev/breeze/src/airflow_breeze/utils/path_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/path_utils.py @@ -553,6 +553,9 @@ def find_airflow_root_path_to_operate_on() -> Path: PYCACHE_VOLUME_NAME = "airflow-pycache-volume" +# Key of the `/root/.cache` volume in the compose files. Compose prefixes the real volume name with the +# project name, so the volume is recognised by the `com.docker.compose.volume` label instead. +BUILD_CACHE_VOLUME = "airflow-cache-volume" def create_volume_if_missing(volume_name: str): diff --git a/dev/breeze/tests/test_developer_commands.py b/dev/breeze/tests/test_developer_commands.py index 2074215c5b59e..9456be5346b65 100644 --- a/dev/breeze/tests/test_developer_commands.py +++ b/dev/breeze/tests/test_developer_commands.py @@ -159,14 +159,7 @@ def test_doctor_removes_build_cache_volumes_after_confirmation(runner, tmp_path, ): result = runner.invoke(doctor, catch_exceptions=False) assert result.exit_code == 0 - assert teardown.call_args_list == [ - call(all_worktrees=True), - *( - [call(all_worktrees=True, preserve_volumes=True, cleanup_build_cache=True)] - if cache_removal_confirmed - else [] - ), - ] + assert teardown.call_args_list == [call(all_worktrees=True, cleanup_build_cache=cache_removal_confirmed)] @pytest.fixture diff --git a/dev/breeze/tests/test_docker_command_utils.py b/dev/breeze/tests/test_docker_command_utils.py index 5dc9a3f61535c..4bf61fbfd91dd 100644 --- a/dev/breeze/tests/test_docker_command_utils.py +++ b/dev/breeze/tests/test_docker_command_utils.py @@ -47,6 +47,7 @@ pull_images_with_retries, ) from airflow_breeze.utils.path_utils import ( + BUILD_CACHE_VOLUME, SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH, SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH, ) @@ -757,10 +758,8 @@ def test_down_finds_volumes_without_containers(docker_resources, tmp_path, prese ) def test_build_cache_volume_is_the_one_the_compose_files_mount(compose_file): compose = yaml.safe_load(compose_file.read_text()) - assert docker_command_utils.BUILD_CACHE_VOLUME in compose["volumes"] - assert ( - f"{docker_command_utils.BUILD_CACHE_VOLUME}:/root/.cache" in compose["services"]["airflow"]["volumes"] - ) + assert BUILD_CACHE_VOLUME in compose["volumes"] + assert f"{BUILD_CACHE_VOLUME}:/root/.cache" in compose["services"]["airflow"]["volumes"] @pytest.mark.parametrize( From 59f4f7c4e7150baf3315bcde06f4aabdf4577249 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Wed, 21 Oct 2026 20:45:28 +0800 Subject: [PATCH 6/8] Keep build cache volumes of other hosts' worktrees A Docker daemon can be shared between machines, and a worktree path from another host never exists locally. Checking only the path made breeze down --all-worktrees remove those build cache volumes without --cleanup-build-cache, so use the same host-aware check as the rest of the teardown. --- .../utils/docker_command_utils.py | 6 +++++- dev/breeze/tests/test_docker_command_utils.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py index b076b160ed272..a3383b8d4cc34 100644 --- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py @@ -1085,7 +1085,11 @@ def _should_remove_volume( # The build cache holds no state, so it outlives the project until it is asked for explicitly. # A deleted worktree will never use its cache again. return cleanup_build_cache or ( - not preserve_volumes and _worktree_is_missing(labels.get("org.apache.airflow.breeze.worktree", "")) + not preserve_volumes + and _belongs_to_deleted_worktree( + labels.get("org.apache.airflow.breeze.worktree", ""), + labels.get("org.apache.airflow.breeze.host", ""), + ) ) diff --git a/dev/breeze/tests/test_docker_command_utils.py b/dev/breeze/tests/test_docker_command_utils.py index 4bf61fbfd91dd..0697f2e88bd0d 100644 --- a/dev/breeze/tests/test_docker_command_utils.py +++ b/dev/breeze/tests/test_docker_command_utils.py @@ -803,6 +803,27 @@ def test_down_keeps_build_cache_volume_until_asked( assert removals == ([["docker", "volume", "rm", *expected]] if expected else []) +@mock.patch("airflow_breeze.utils.docker_command_utils.get_host_id", autospec=True, return_value="this-host") +def test_down_keeps_build_cache_volume_of_another_host(_host_id, docker_resources, tmp_path): + resources, run = docker_resources + resources["volume"] = [ + { + "Name": "remote-cache", + "Labels": { + "com.docker.compose.project": "breeze-remote", + "com.docker.compose.volume": BUILD_CACHE_VOLUME, + "org.apache.airflow.breeze": "true", + "org.apache.airflow.breeze.worktree": str(tmp_path / "remote"), + "org.apache.airflow.breeze.host": "other-host", + }, + }, + ] + + docker_command_utils.bring_compose_projects_down(all_worktrees=True) + + assert [c.args[0] for c in run.call_args_list if c.args[0][2] == "rm"] == [] + + @pytest.mark.parametrize("failed_action", ["ls", "inspect", "stop", "rm"]) def test_startup_cleanup_continues_after_docker_failures(docker_resources, tmp_path, capsys, failed_action): resources, run = docker_resources From 8d6d307e8d9baae50d3b65c080ec80286c91e16c Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Wed, 21 Oct 2026 20:46:44 +0800 Subject: [PATCH 7/8] Regenerate the breeze commands overview image --- dev/breeze/doc/images/output-commands.svg | 88 ++++++++++++----------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/dev/breeze/doc/images/output-commands.svg b/dev/breeze/doc/images/output-commands.svg index 93c54b8a97cc4..1eb94402a776a 100644 --- a/dev/breeze/doc/images/output-commands.svg +++ b/dev/breeze/doc/images/output-commands.svg @@ -1,4 +1,4 @@ - +