This is an automated email from the ASF dual-hosted git repository.
dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new d9bfef031f9 Make sure stale file handler doesn't crash the
DagFileProcessorManager (#69324)
d9bfef031f9 is described below
commit d9bfef031f99ed310d657789b7a2057f2d5f9682
Author: David Blain <[email protected]>
AuthorDate: Thu Jul 9 13:05:02 2026 +0200
Make sure stale file handler doesn't crash the DagFileProcessorManager
(#69324)
* refactor: Make sure state file handler doesn't crash the
DagProcessorManager
* refactor: Added close method on DagFileProcessorProcess so we have a more
DRY approach on how to close the logger file handler and catch OSError if file
handler is stale
* refactor: Added more context to the warning statement if close of file
handler failed
* refactor: Replaced MagicMock with mock_processor in
test_terminate_orphan_processes_tolerates_stale_file_handle_on_close
---
airflow-core/src/airflow/dag_processing/manager.py | 6 +--
.../src/airflow/dag_processing/processor.py | 13 ++++-
.../tests/unit/dag_processing/test_manager.py | 55 ++++++++++++++++++++++
3 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/airflow-core/src/airflow/dag_processing/manager.py
b/airflow-core/src/airflow/dag_processing/manager.py
index f41883edf31..cd6e48b542c 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -1185,7 +1185,7 @@ class DagFileProcessorManager(LoggingMixin):
),
)
processor.kill(signal.SIGKILL)
- processor.logger_filehandle.close()
+ processor.close()
self._file_stats.pop(file, None)
@provide_session
@@ -1316,7 +1316,7 @@ class DagFileProcessorManager(LoggingMixin):
for file in finished:
processor = self._processors.pop(file)
- processor.logger_filehandle.close()
+ processor.close()
def _get_log_dir(self) -> str:
return os.path.join(self.base_log_dir,
timezone.utcnow().strftime("%Y-%m-%d"))
@@ -1622,7 +1622,7 @@ class DagFileProcessorManager(LoggingMixin):
# Clean up `self._processors` after iterating over it
for proc in processors_to_remove:
processor = self._processors.pop(proc)
- processor.logger_filehandle.close()
+ processor.close()
def _add_files_to_queue(
self,
diff --git a/airflow-core/src/airflow/dag_processing/processor.py
b/airflow-core/src/airflow/dag_processing/processor.py
index 88a486f99c6..ca7539131f6 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -93,6 +93,7 @@ from airflow.serialization.serialized_objects import
DagSerialization, LazyDeser
from airflow.utils.dag_version_inflation_checker import
check_dag_file_stability
from airflow.utils.file import iter_airflow_imports
from airflow.utils.helpers import prune_dict
+from airflow.utils.log.logging_mixin import LoggingMixin
from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
@@ -550,7 +551,7 @@ def in_process_api_server() -> InProcessExecutionAPI:
@attrs.define(kw_only=True)
-class DagFileProcessorProcess(WatchedSubprocess):
+class DagFileProcessorProcess(WatchedSubprocess, LoggingMixin):
"""
Parses dags with Task SDK API.
@@ -730,3 +731,13 @@ class DagFileProcessorProcess(WatchedSubprocess):
def wait(self) -> int:
raise NotImplementedError(f"Don't call wait on {type(self).__name__}
objects")
+
+ def close(self):
+ try:
+ self.logger_filehandle.close()
+ except OSError:
+ self.log.warning(
+ "Failed to close log file handle for %s",
+ self.dag_file_rel_path,
+ exc_info=True,
+ )
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py
b/airflow-core/tests/unit/dag_processing/test_manager.py
index ee2d7cd76d4..ae28f3317fb 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -497,6 +497,23 @@ class TestDagFileProcessorManager:
)
processor.kill.assert_called_once_with(signal.SIGKILL)
+ def
test_terminate_orphan_processes_tolerates_stale_file_handle_on_close(self):
+ """A stale NFS file handle on close (e.g. OpenShift) must not crash
the manager."""
+ manager = DagFileProcessorManager(max_runs=1)
+ versioned_file = _get_versioned_file_info("callbacks.py")
+ processor, _ = self.mock_processor()
+ processor.logger_filehandle.close.side_effect = OSError(116, "Stale
file handle")
+
+ manager._processors[versioned_file] = processor
+
+ with (
+ mock.patch.object(type(processor), "kill"),
+ mock.patch("airflow.dag_processing.manager.stats.decr"),
+ ):
+ manager.terminate_orphan_processes(present=set())
+
+ assert manager._processors == {}
+
def
test_remove_orphaned_file_stats_keeps_versioned_callback_stats_when_unversioned_file_is_present(self):
manager = DagFileProcessorManager(max_runs=1)
versioned_file = _get_versioned_file_info("callbacks.py")
@@ -1184,6 +1201,26 @@ class TestDagFileProcessorManager:
assert len(manager._processors) == 0
processor.logger_filehandle.close.assert_called()
+ def
test_kill_timed_out_processors_tolerates_stale_file_handle_on_close(self):
+ """A stale NFS file handle on close (e.g. OpenShift) must not crash
the manager."""
+ manager = DagFileProcessorManager(max_runs=1, processor_timeout=5)
+ start_time = time.monotonic() - manager.processor_timeout - 1
+ processor, _ = self.mock_processor(start_time=start_time)
+ processor.logger_filehandle.close.side_effect = OSError(116, "Stale
file handle")
+ manager._processors = {
+ DagFileInfo(
+ bundle_name="testing", rel_path=Path("abc.py"),
bundle_path=TEST_DAGS_FOLDER
+ ): processor
+ }
+ with (
+ mock.patch.object(type(processor), "kill"),
+ mock.patch("airflow.dag_processing.manager.stats.decr"),
+ mock.patch("airflow.dag_processing.manager.stats.incr"),
+ ):
+ manager._kill_timed_out_processors()
+
+ assert len(manager._processors) == 0
+
def test_kill_timed_out_processors_no_kill(self):
manager = DagFileProcessorManager(
max_runs=1,
@@ -1338,6 +1375,24 @@ class TestDagFileProcessorManager:
assert manager._file_stats[file_b].run_count == 2
assert len(manager._processors) == 0
+ def test_collect_results_tolerates_stale_file_handle_on_close(self):
+ """A stale NFS file handle on close (e.g. OpenShift) must not crash
the manager."""
+ manager = DagFileProcessorManager(max_runs=1)
+ file = DagFileInfo(bundle_name="testing", rel_path=Path("a.py"),
bundle_path=TEST_DAGS_FOLDER)
+ manager._file_stats[file] = DagFileStat()
+ manager._bundle_versions["testing"] = "v1"
+
+ proc, _ = self.mock_processor(start_time=time.monotonic() - 1)
+ proc.had_callbacks = False
+ proc.parsing_result = DagFileParsingResult(fileloc="a.py",
serialized_dags=[])
+ proc.logger_filehandle.close.side_effect = OSError(116, "Stale file
handle")
+ manager._processors = {file: proc}
+
+ with mock.patch.object(manager, "persist_parsing_result"):
+ manager._collect_results()
+
+ assert len(manager._processors) == 0
+
@pytest.mark.usefixtures("testing_dag_bundle")
@pytest.mark.parametrize(
("callbacks", "path", "expected_body"),