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

vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit caffeafaa258bfb5af33466924ce02c8b5dee04e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 21:27:08 2026 +0530

    [v3-4-test] Freeze the GC before the triggerer's forks so memory stays flat 
(#76016) (#76029)
    
    * Apply gc.freeze before forking serve_logs in the triggerer
    
    The triggerer forks serve_logs from its main process. Under the fork start
    method the child shares the parent's pages copy-on-write; a later full GC 
writes
    to every object's header and splits those shared pages into private copies,
    inflating anonymous memory with no new allocation. Freeze the GC before the 
fork
    and unfreeze right after, mirroring the executors (#58365), the Dag 
processor
    manager (#60505), and the Celery worker (#62212). No-op under 
forkserver/spawn.
    
    * Trim the gc.freeze comment to the non-obvious point
    
    * Keep the GC frozen after forking serve_logs (per review)
    
    Unfreezing right after the fork let the main process's next full GC split 
the
    COW-shared pages, cancelling the benefit. Freeze and stay frozen under the 
fork
    start method only (forks happen once, like the Dag processor manager); skip 
the
    freeze entirely for forkserver/spawn.
    
    * 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).
    
    * Freeze the GC before the serve_logs fork too, not only the runner fork
    
    Ephraim's 4-way PSS measurement on 3.4.0a4 (fork start method, GC forced in
    all three triggerer processes) showed the runner-fork freeze alone still 
steps
    (245 -> 341 MB): serve_logs is forked earlier, shares the heap, and its own
    full GC copies it. Freezing before the serve_logs fork keeps it flat and the
    runner inherits the frozen generation. Keep the runner-fork freeze for
    --skip-serve-logs and forkserver, where nothing is frozen beforehand.
    
    
    
    ---------
    (cherry picked from commit 5af4c5bf12da93f975d3329c92deb660578462e9)
    
    Co-authored-by: Rahul Vats <[email protected]>
    Co-authored-by: Claude <[email protected]>
---
 .../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, 66 insertions(+), 1 deletion(-)

diff --git a/airflow-core/src/airflow/cli/commands/triggerer_command.py 
b/airflow-core/src/airflow/cli/commands/triggerer_command.py
index 3f881e35c51..feeb50fbd74 100644
--- a/airflow-core/src/airflow/cli/commands/triggerer_command.py
+++ b/airflow-core/src/airflow/cli/commands/triggerer_command.py
@@ -18,10 +18,11 @@
 
 from __future__ import annotations
 
+import gc
 from collections.abc import Generator
 from contextlib import contextmanager
 from functools import partial
-from multiprocessing import Process
+from multiprocessing import Process, get_start_method
 
 from airflow.cli.commands.daemon_utils import run_command_with_daemon_option
 from airflow.configuration import conf
@@ -43,6 +44,12 @@ 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 4c6cc56ceea..c6399e2027f 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 449ed61626c..472c8927db3 100644
--- a/airflow-core/tests/unit/cli/commands/test_triggerer_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_triggerer_command.py
@@ -84,6 +84,33 @@ 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 bbf01eba0f1..c1d09ac3b9d 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