This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a commit to branch gc-freeze-triggerer-serve-logs
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/gc-freeze-triggerer-serve-logs 
by this push:
     new 14109e13edd Move the gc.freeze to before the trigger-runner fork
14109e13edd is described below

commit 14109e13edd93c2f6ca276dd3cc54e3f20df6ef9
Author: Rahul Vats <[email protected]>
AuthorDate: Fri Oct 30 14:42:50 2026 +0530

    Move the gc.freeze to before the trigger-runner fork
    
    Freezing before the serve_logs fork was too early -- the heavy object graph 
is
    built afterward, so the pages shared with the later trigger-runner fork were
    still split by the next full GC and memory still stepped (~246->431 MB PSS).
    Freeze right before the runner's bare fork instead, gated on not 
_should_use_exec()
    (no COW sharing when the child execs), and stay frozen. Reverts the 
serve_logs
    change; measured flat (~245->246 MB PSS).
---
 .../src/airflow/cli/commands/triggerer_command.py  |  9 +-------
 .../src/airflow/jobs/triggerer_job_runner.py       |  7 ++++++
 .../unit/cli/commands/test_triggerer_command.py    | 27 ----------------------
 airflow-core/tests/unit/jobs/test_triggerer_job.py | 24 +++++++++++++++++++
 4 files changed, 32 insertions(+), 35 deletions(-)

diff --git a/airflow-core/src/airflow/cli/commands/triggerer_command.py 
b/airflow-core/src/airflow/cli/commands/triggerer_command.py
index feeb50fbd74..3f881e35c51 100644
--- a/airflow-core/src/airflow/cli/commands/triggerer_command.py
+++ b/airflow-core/src/airflow/cli/commands/triggerer_command.py
@@ -18,11 +18,10 @@
 
 from __future__ import annotations
 
-import gc
 from collections.abc import Generator
 from contextlib import contextmanager
 from functools import partial
-from multiprocessing import Process, get_start_method
+from multiprocessing import Process
 
 from airflow.cli.commands.daemon_utils import run_command_with_daemon_option
 from airflow.configuration import conf
@@ -44,12 +43,6 @@ def _serve_logs(skip_serve_logs: bool = False) -> 
Generator[None, None, None]:
     if skip_serve_logs is False:
         port = conf.getint("logging", "trigger_log_server_port", fallback=8794)
         sub_proc = Process(target=partial(serve_logs, port=port))
-        if get_start_method() == "fork":
-            # Freeze and stay frozen: unfreezing would let this process's next 
full GC split the
-            # pages the forked child shares copy-on-write (a memory jump with 
no new allocation).
-            # The triggerer forks its children once, like the Dag processor 
manager. No effect
-            # under forkserver/spawn, where the child re-imports instead of 
sharing.
-            gc.freeze()
         sub_proc.start()
     try:
         yield
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 1c67986f564..2680759dbfb 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import contextvars
 import functools
+import gc
 import logging
 import math
 import os
@@ -566,6 +567,12 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         **kwargs,
     ):
         proc_id = job.id if job is not None else uuid4()
+        if not supervisor._should_use_exec():
+            # Freeze before the bare fork and stay frozen: unfreezing would 
let this process's next
+            # full GC split the pages the runner child shares copy-on-write (a 
memory jump with no
+            # new allocation). Forked once, like the Dag processor manager. 
Skipped when the child
+            # execs, which discards the inherited pages.
+            gc.freeze()
         # Triggers run user code that polls APIs / watches queues / hits HTTP
         # endpoints -- almost always network calls that trigger macOS-unsafe 
ObjC
         # initialization. Fork+exec a clean interpreter for the runner child.
diff --git a/airflow-core/tests/unit/cli/commands/test_triggerer_command.py 
b/airflow-core/tests/unit/cli/commands/test_triggerer_command.py
index 472c8927db3..449ed61626c 100644
--- a/airflow-core/tests/unit/cli/commands/test_triggerer_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_triggerer_command.py
@@ -84,33 +84,6 @@ class TestTriggererCommand:
             execute_callable=mock_trigger_job_runner.return_value._execute,
         )
 
-    @pytest.mark.parametrize(
-        ("start_method", "should_freeze"),
-        [("fork", True), ("forkserver", False), ("spawn", False)],
-    )
-    @mock.patch("airflow.cli.commands.triggerer_command.gc", autospec=True)
-    @mock.patch("airflow.cli.commands.triggerer_command.get_start_method", 
autospec=True)
-    @mock.patch("airflow.cli.commands.triggerer_command.Process", 
autospec=True)
-    def test_serve_logs_freezes_gc_before_fork_only_under_fork(
-        self, mock_process, mock_get_start_method, mock_gc, start_method, 
should_freeze
-    ):
-        """Under ``fork`` serve_logs is forked with the GC frozen and left 
frozen (no unfreeze, or a
-        later GC would split the COW-shared pages); other start methods skip 
the freeze entirely."""
-        mock_get_start_method.return_value = start_method
-        manager = mock.Mock()
-        manager.attach_mock(mock_gc.freeze, "freeze")
-        manager.attach_mock(mock_process.return_value.start, "start")
-
-        with triggerer_command._serve_logs(skip_serve_logs=False):
-            pass
-
-        mock_gc.unfreeze.assert_not_called()
-        if should_freeze:
-            assert manager.mock_calls == [mock.call.freeze(), 
mock.call.start()]
-        else:
-            mock_gc.freeze.assert_not_called()
-            mock_process.return_value.start.assert_called_once()
-
     @mock.patch("airflow.cli.hot_reload.run_with_reloader")
     def test_triggerer_with_dev_flag(self, mock_reloader):
         """Ensure that triggerer with --dev flag uses hot-reload"""
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 6cd5c17a226..888213d2753 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -336,6 +336,30 @@ def test_start_opts_into_fork_exec(monkeypatch, mocker, 
platform_uses_exec):
     assert base_start.call_args.kwargs["target"] == 
TriggerRunnerSupervisor.run_in_process
 
 
[email protected]("platform_uses_exec", [True, False])
+def test_start_freezes_gc_before_bare_fork(monkeypatch, mocker, 
platform_uses_exec):
+    """On bare-fork platforms the runner is forked with the GC frozen and left 
frozen, so a later
+    full GC can't split the COW-shared pages; when the child execs there is no 
sharing, so skip it."""
+    monkeypatch.setattr(supervisor, "_should_use_exec", lambda: 
platform_uses_exec)
+    mock_gc = mocker.patch("airflow.jobs.triggerer_job_runner.gc", 
autospec=True)
+    base_start = mocker.patch(
+        "airflow.sdk.execution_time.supervisor.WatchedSubprocess.start", 
return_value=MagicMock()
+    )
+    manager = mocker.MagicMock()
+    manager.attach_mock(mock_gc.freeze, "freeze")
+    manager.attach_mock(base_start, "start")
+
+    TriggerRunnerSupervisor.start(job=Job(id=999), capacity=10)
+
+    mock_gc.unfreeze.assert_not_called()
+    if platform_uses_exec:
+        mock_gc.freeze.assert_not_called()
+        base_start.assert_called_once()
+    else:
+        # Freeze must happen before the fork (super().start) and never 
unfreeze.
+        assert [c[0] for c in manager.mock_calls] == ["freeze", "start"]
+
+
 @pytest.mark.parametrize("option_value", ["True", "False"])
 def test_start_ignores_execute_tasks_new_python_interpreter(mocker, 
option_value):
     """

Reply via email to