vuonghoangbntt opened a new issue, #73432:
URL: https://github.com/apache/airflow/issues/73432

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   3.3.1
   
   ### What happened and how to reproduce it?
   
   ### Issue Description
   
   A Dag with a Dag-level `on_failure_callback` parses on schedule until one of 
its runs
   fails. After that the Dag processor reparses that file on every loop and 
stops applying
   `[dag_processor] min_file_process_interval` to it. It never recovers on its 
own.
   Restarting the Dag processor clears it. The next failed run brings it 
straight back.
   
   We hit this in production on 3.3.1, where it held a Dag processor at its 1 
CPU limit for
   days. Two Dag files were reparsing roughly 43x more often than configured. 
The other
   five files in the same bundle stayed exactly on interval.
   
   The `DAG File Processing Stats` table gives no hint. It shows recent, healthy
   `Last Run At` values for the affected file the whole time.
   
   ### Root cause
   
   Of the five `DagFileInfo(...)` construction sites, only 
`_add_callback_to_queue` sets
   `bundle_version`. The bundle scan leaves it unset, and that's on purpose: 
`bundle_path`
   is `field(compare=False)`, so `bundle_version` is the only field that keeps a
   version-pinned callback distinct from the scan entry. Drop it and a pinned 
callback
   parses against the tracking checkout instead of the version its Dag run used.
   
   The side effect is that one physical file can end up holding two 
`_file_stats` entries.
   `prepare_file_queue` collapses them:
   
   ```python
   file_stats_by_presence_key = {file.presence_key: stat for file, stat in 
self._file_stats.items()}
   ```
   
   A dict comprehension keeps whichever key went in last. That is the callback 
entry, since
   the file has to be parsed before a run can fail, so it shadows the scan 
entry.
   
   That entry never gets a timestamp. `process_parse_results` builds a 
callback-only stat
   without `last_finish_time` (deliberately, so it does not disturb stale-Dag 
detection),
   and `handle_parsing_result` writes it over the entry wholesale, so the value 
is `None`.
   It stays `None`, because every later parse writes to the scan key instead. 
From then on
   the interval check sees a file that has apparently never been parsed, and 
requeues it
   every loop.
   
   Nothing cleans it up, either. `remove_orphaned_file_stats` matches on 
`presence_key`,
   which is still present.
   
   Two nearby places resolve the same lookup and disagree with each other. The 
identical
   collapse is duplicated in `_sort_by_mtime`, which feeds `changed_recently`, 
the other
   route that bypasses the interval. And `processed_recently` resolves it by 
first match,
   the opposite tiebreak.
   
   What keeps it hidden is `_log_file_processing_stats`, which reads 
`_file_stats` by exact
   key. It reports the scan entry's healthy timestamp while the queue logic 
reads the
   frozen one.
   
   ### Scope
   
   This only bites bundles that support versioning. When a request carries no 
version,
   `request.bundle_version` is `None` (since #72930), the two keys coincide, 
and merging
   them is correct.
   
   ### Steps to reproduce
   
   1. Airflow with a standalone `dag-processor` and a versioning bundle 
(`GitDagBundle`),
      so the bundle reports a non-null version.
   2. Set `[dag_processor] min_file_process_interval = 30`.
   3. Add two Dags to the bundle. One has a **Dag-level** `on_failure_callback` 
and a task
      that raises; the other is a control with no callback:
   
   ```python
   with DAG(dag_id="callback_dag", schedule=None, on_failure_callback=_notify):
       PythonOperator(task_id="boom", python_callable=_boom)  # raises
   ```
   
   4. Trigger `callback_dag` and let the run fail, so the Dag-level callback is 
dispatched.
   5. Watch the reparse rate, e.g. by polling `dag.last_parsed_time`, or the 
statsd counter
      `dag_processing.last_duration.<file>`.
   
   Measured on main (sqlite, `GitDagBundle`, `min_file_process_interval = 30`):
   
   | phase                              | callback_dag | control_dag |
   |------------------------------------|--------------|-------------|
   | baseline, before any callback       | 1 per 30.0s  | 1 per 30.0s |
   | after the Dag-level callback fired  | 1 per 0.4s   | 1 per 30.0s |
   
   Restarting the Dag processor clears it, because the bad state lives in 
memory. The next
   failed Dag run re-arms it.
   
   
   
   ### What you think should happen instead?
   
   min_file_process_interval` should keep applying to a file whether or not a 
Dag-level
   callback has ever run for it. One callback should not change that file's 
parse cadence
   permanently.
   
   ### Operating System
   
   _No response_
   
   ### Deployment
   
   Official Apache Airflow Helm Chart
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   _No response_
   
   ### Official Helm Chart version
   
   1.22.0 (latest released)
   
   ### Kubernetes Version
   
   1.34.9
   
   ### Helm Chart configuration
   
   _No response_
   
   ### Docker Image customizations
   
   _No response_
   
   ### Anything else?
   
   _No response_
   
   ### Are you willing to submit PR?
   
   - [x] 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