harel-sht opened a new issue, #72838:
URL: https://github.com/apache/airflow/issues/72838

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   3.1.7
   
   ### What happened and how to reproduce it?
   
   **Issue Description**
   
   On Airflow 3.1.7 with `LocalExecutor` and a versioned `GitDagBundle`, the 
scheduler job dies every `dag_processor.stale_bundle_cleanup_interval` seconds 
(default 1800). `BundleUsageTrackingManager._remove_stale_bundle` calls 
`shutil.rmtree(bundle_version_path)` and only catches `BlockingIOError`. If the 
`versions/<sha>` directory is already gone but the tracking/lock file under 
`_tracking/<bundle>/<sha>` remains, `rmtree` raises `FileNotFoundError`. That 
exception is invoked from `SchedulerJobRunner._run_scheduler_loop` via 
`timers.run()`, is logged as `Exception when executing 
SchedulerJob._run_scheduler_loop`, and kills the scheduler loop.
   
   After that, `LocalExecutor.end()` waits on in-flight children with an 
unbounded `join()`. Heartbeats stop, the scheduler health endpoint returns 503, 
and Docker `restart: always` does not recycle the container because the process 
is still alive. We saw 43 failed `SchedulerJob` rows in 24 hours from this 
single orphan SHA.
   
   The **dag-processor** already wraps the same call:
   
   ```python
   # airflow/dag_processing/manager.py (_cleanup_stale_bundle_versions)
   try:
       BundleUsageTrackingManager().remove_stale_bundle_versions()
   except Exception:
       self.log.exception("Error removing stale bundle versions")
   ```
   
   The scheduler timer does not.
   
   Still present on `main` (`_remove_stale_bundle` excepts only 
`BlockingIOError`). Confirmed `_filter_for_min_versions` is `sorted(found, dt 
desc)[min_versions:]` and reads `conf.getint` at call time (default 
`stale_bundle_cleanup_min_versions=10`).
   
   **How the orphan tracking file is created**
   
   `_remove_stale_bundle` deletes the tracking/lock file **only after** 
`rmtree` succeeds:
   
   ```python
   with open(info.lock_file_path, "a") as f:
       flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
       shutil.rmtree(bundle_version_path)
       os.remove(info.lock_file_path)
   except BlockingIOError:
       ...
   ```
   
   Two processes share the bundle storage (scheduler timer + dag-processor):
   
   1. Both list tracking files and pick the same candidate past `min_versions`.
   2. A `rmtree`s the version dir and then `os.remove`s the lock.
   3. B's `open(lock, "a")` recreates the lock file, `flock` succeeds, `rmtree` 
raises `FileNotFoundError`, the lock file stays.
   4. Every subsequent cleanup tick retries the same delete.
   
   Unpaired `rm -rf versions/<sha>` without the tracking file produces the same 
state.
   
   **Steps to reproduce**
   
   1. Run 3.1.7 with `LocalExecutor` and a versioned `GitDagBundle`. Scheduler 
and dag-processor must share `dag_bundle_storage_path`.
   2. Keep default `stale_bundle_cleanup_min_versions=10`.
   3. Create 11 tracking files under `_tracking/<bundle>/` and only 10 
directories under `versions/` — the extra tracking file points at a SHA whose 
version dir is missing (or let the TOCTOU above mint one).
   4. Wait `stale_bundle_cleanup_interval` (default 30 minutes), or temporarily 
lower it.
   5. Scheduler logs `Exception when executing 
SchedulerJob._run_scheduler_loop` and the job dies. Dag-processor logs `Error 
removing stale bundle versions` and continues.
   
   ### What you think should happen instead?
   
   `_remove_stale_bundle` should treat a missing version directory (and a 
missing lock file) as "already gone": catch `FileNotFoundError` / `OSError` 
with errno ENOENT, remove the leftover tracking file if it still exists, and 
return. The scheduler timer should not be able to take down 
`_run_scheduler_loop` for a cleanup race the dag-processor already swallows.
   
   Alternatively, wrap `bundle_cleanup_mgr.remove_stale_bundle_versions` in the 
scheduler the same way `DagFileProcessorManager._cleanup_stale_bundle_versions` 
already wraps it (`except Exception: log`).
   
   ```
   2026-09-07T20:40:24.213855Z [info] removing stale bundle. 
bundle_name=amiio-repo bundle_version=1443d9ca4dfdeb69b68b6bc6ea0cc710abd34738 
bundle_path=/opt/airflow/dag-bundles/amiio-repo/versions/1443d9ca4dfdeb69b68b6bc6ea0cc710abd34738
 
lock_file=/opt/airflow/dag-bundles/_tracking/amiio-repo/1443d9ca4dfdeb69b68b6bc6ea0cc710abd34738
 loc=base.py:158
   2026-09-07T20:40:24.214757Z [error] Exception when executing 
SchedulerJob._run_scheduler_loop loc=scheduler_job_runner.py:1086
   Traceback (most recent call last):
     File ".../airflow/jobs/scheduler_job_runner.py", line 1082, in _execute
       self._run_scheduler_loop()
     File ".../airflow/jobs/scheduler_job_runner.py", line 1412, in 
_run_scheduler_loop
       next_event = timers.run(blocking=False)
     File ".../airflow/dag_processing/bundles/base.py", line 229, in 
remove_stale_bundle_versions
       self._remove_stale_bundle_versions_for_bundle(bundle_name=bundle.name)
     File ".../airflow/dag_processing/bundles/base.py", line 210, in 
_remove_stale_bundle_versions_for_bundle
       self._remove_stale_bundle(bundle_name=bundle_name, info=info)
     File ".../airflow/dag_processing/bundles/base.py", line 172, in 
_remove_stale_bundle
       shutil.rmtree(bundle_version_path)
     File "/usr/python/lib/python3.12/shutil.py", line 759, in rmtree
       _rmtree_safe_fd(stack, onexc)
     File "/usr/python/lib/python3.12/shutil.py", line 669, in _rmtree_safe_fd
   FileNotFoundError: [Errno 2] No such file or directory: 
PosixPath('/opt/airflow/dag-bundles/amiio-repo/versions/1443d9ca4dfdeb69b68b6bc6ea0cc710abd34738')
   ```
   
   ### Operating System
   
   Ubuntu 24.04 (GCE VM), official Airflow Docker image, Python 3.12
   
   ### Deployment
   
   Docker-Compose
   
   ### Versions of Apache Airflow Providers
   
   apache-airflow-providers-git (GitDagBundle). Core bug is in 
`airflow.dag_processing.bundles.base`, not the git provider.
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   Not Applicable
   
   ### Helm Chart configuration
   
   Not Applicable
   
   ### Docker Image customizations
   
   Official `apache/airflow:3.1.7` image plus Python deps. Not required to 
reproduce — stock 3.1.7 source of `_remove_stale_bundle` is enough.
   
   ### Anything else?
   
   - Default `min_versions=10`. With 11 tracking files and 10 version dirs, 
`_filter_for_min_versions` returns exactly the orphan, which is why `rmtree` 
runs at all.
   - Workaround: raise 
`AIRFLOW__DAG_PROCESSOR__STALE_BUNDLE_CLEANUP_MIN_VERSIONS` so the candidate 
slice stays empty. Do not set `stale_bundle_cleanup_interval=0` as the primary 
workaround (3.1.7 does guard `if check_interval > 0` on the scheduler, but 
starving cleanup is the safer knob).
   - Related but different: #62512 (Celery spawn / local function), #47567 
(slash in bundle name / lock path).
   
   ### Are you willing to submit PR?
   
   - [ ] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to