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

jason810496 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 f63d32129fe Honor the API server Dag cache TTL when no size limit is 
set (#71814)
f63d32129fe is described below

commit f63d32129fe90321d4c81e96559785032a6db8f3
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Aug 19 23:32:10 2026 +0800

    Honor the API server Dag cache TTL when no size limit is set (#71814)
    
    * Honor the API server Dag cache TTL when no size limit is set
    
    `[api] dag_cache_size = 0` reads as "no size limit", but it selected a 
mapping
    that never evicted at all and silently ignored `[api] dag_cache_ttl`. 
Age-based
    eviction could therefore only be enabled by also accepting a size cap, 
which is
    backwards for a deployment that wants to bound staleness rather than count.
    
    A TTL now applies with or without a size limit, and only setting both 
options to
    0 disables eviction. Deployments on `dag_cache_size = 0` start evicting on 
the
    default hour-long TTL; setting `dag_cache_ttl = 0` restores the old 
behaviour.
    
    The options were also documented as added in 3.3.0. They shipped in 3.2.2.
    
    * Reject negative API Dag cache values and clarify TTL behavior
    
    Negative cache configuration should fail fast, and operator guidance must 
reflect that TTL refreshes happen only at revalidation boundaries.
    
    * Reject negative cache values for every DBDagBag caller
    
    Validating at the shared boundary prevents non-API callers from silently 
clamping invalid cache settings.
    
    * Clarify cache validation context and docs wording
    
    Context-specific errors help operators identify invalid API settings, while 
defensive constructor checks protect other callers and documentation remains 
compatible with the spellchecker.
    
    * Clarify API cache TTL documentation layout
---
 .../administration-and-deployment/web-stack.rst    | 12 +++--
 airflow-core/docs/faq.rst                          | 18 ++++---
 airflow-core/newsfragments/71814.bugfix.rst        |  1 +
 .../src/airflow/api_fastapi/common/dagbag.py       | 20 ++------
 .../src/airflow/config_templates/config.yml        | 33 ++++++++----
 airflow-core/src/airflow/models/dagbag.py          | 38 +++++++++-----
 .../tests/unit/api_fastapi/common/test_dagbag.py   | 55 ++++++++++++++------
 airflow-core/tests/unit/models/test_dagbag.py      | 60 +++++++++++++---------
 8 files changed, 146 insertions(+), 91 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/web-stack.rst 
b/airflow-core/docs/administration-and-deployment/web-stack.rst
index f043789d5e5..ed8264efc4b 100644
--- a/airflow-core/docs/administration-and-deployment/web-stack.rst
+++ b/airflow-core/docs/administration-and-deployment/web-stack.rst
@@ -142,8 +142,8 @@ The following configuration options are available in the 
``[api]`` section:
 - ``server_type``: ``uvicorn`` (default) or ``gunicorn``
 - ``worker_refresh_interval``: Seconds between worker refresh cycles (0 = 
disabled, default)
 - ``worker_refresh_batch_size``: Number of workers to refresh per cycle 
(default: 1)
-- ``dag_cache_size``: Max cached SerializedDAG versions in the API server 
(default: 64, 0 = unbounded)
-- ``dag_cache_ttl``: TTL in seconds for cached DAGs (default: 3600, 0 = LRU 
only)
+- ``dag_cache_size``: Max cached SerializedDAG versions in the API server 
(default: 64, 0 = no size limit)
+- ``dag_cache_ttl``: TTL in seconds for cached Dags (default: 3600, 0 = no 
TTL; both 0 = no eviction)
 
 When to Use Gunicorn
 ^^^^^^^^^^^^^^^^^^^^
@@ -188,9 +188,11 @@ For example, to trigger a rolling restart of the API 
server pods:
 
    kubectl rollout restart deployment airflow-api-server
 
-The API server also supports bounded DAG caching via ``dag_cache_size`` and
-``dag_cache_ttl``, which limits memory consumed by cached SerializedDAG 
objects.
-This reduces memory growth from DAG version accumulation regardless of server 
type.
+The API server also evicts cached SerializedDAG objects via ``dag_cache_size`` 
and
+``dag_cache_ttl``, which reduces memory growth from Dag version accumulation 
regardless of
+server type. Note that only ``dag_cache_size`` caps memory outright. A cached 
entry's TTL is
+refreshed only when the entry is checked against the database after ``[core] 
min_serialized_dag_update_interval``, not on every request.
+If the TTL is shorter than that interval, even frequently requested entries 
can expire and reload between checks.
 
 In many Kubernetes environments, relying solely on Kubernetes OOM kills or
 crash restarts is not recommended, as memory growth may not always trigger an
diff --git a/airflow-core/docs/faq.rst b/airflow-core/docs/faq.rst
index ce89ecac393..46c783317db 100644
--- a/airflow-core/docs/faq.rst
+++ b/airflow-core/docs/faq.rst
@@ -717,16 +717,22 @@ The API server caches serialized Dag objects in memory. 
Over time, as Dag versio
 
 There are two complementary approaches:
 
-**1. Bounded DAG caching (available since Airflow 3.3.0)**
+**1. Dag cache eviction (available since Airflow 3.2.2)**
 
-The API server supports LRU+TTL caching that bounds how many serialized Dag 
versions are kept
-in memory. Configure this in the ``[api]`` section:
+The API server can evict cached serialized Dag versions by size, by age, or 
both. Configure
+this in the ``[api]`` section:
 
 .. code-block:: ini
 
     [api]
-    dag_cache_size = 64    ; max cached versions (0 = unbounded, pre-3.2 
behavior)
-    dag_cache_ttl = 3600   ; seconds before a cached entry expires (0 = LRU 
only)
+    dag_cache_size = 64    ; max cached versions (0 = no size limit)
+    dag_cache_ttl = 3600   ; seconds before a cached entry expires (0 = no TTL)
+
+``dag_cache_size`` is the only hard ceiling on memory. An entry's TTL is 
refreshed only when the
+entry is checked against the database after ``[core] 
min_serialized_dag_update_interval``, not on
+every request. With a shorter TTL, even frequently requested entries can 
expire and reload
+between checks. Setting both options to 0 uses an unbounded dict with no 
eviction, matching the
+behavior before 3.2.2.
 
 The cache is keyed by Dag version ID. After a Dag is updated, the API server 
may serve the
 previous version until the cached entry expires (controlled by 
``dag_cache_ttl``).
@@ -758,7 +764,7 @@ See :ref:`config:api__server_type`, 
:ref:`config:api__worker_refresh_interval`,
 .. note::
 
     Worker recycling handles memory growth from *any* source, not just the Dag 
cache.
-    For production deployments, using both bounded caching and gunicorn worker 
recycling
+    For production deployments, using both cache eviction and gunicorn worker 
recycling
     provides the best results.
 
 
diff --git a/airflow-core/newsfragments/71814.bugfix.rst 
b/airflow-core/newsfragments/71814.bugfix.rst
new file mode 100644
index 00000000000..220e3141d3d
--- /dev/null
+++ b/airflow-core/newsfragments/71814.bugfix.rst
@@ -0,0 +1 @@
+The API server now honors ``[api] dag_cache_ttl`` when ``dag_cache_size`` is 
0, so cached serialized Dags can expire even when their count is not limited.
diff --git a/airflow-core/src/airflow/api_fastapi/common/dagbag.py 
b/airflow-core/src/airflow/api_fastapi/common/dagbag.py
index d87aca49a52..85ce253fcb8 100644
--- a/airflow-core/src/airflow/api_fastapi/common/dagbag.py
+++ b/airflow-core/src/airflow/api_fastapi/common/dagbag.py
@@ -16,7 +16,6 @@
 # under the License.
 from __future__ import annotations
 
-import logging
 from typing import TYPE_CHECKING, Annotated
 
 from fastapi import Depends, HTTPException, Request, status
@@ -30,27 +29,16 @@ if TYPE_CHECKING:
     from airflow.models.dagrun import DagRun
     from airflow.serialization.definitions.dag import SerializedDAG
 
-log = logging.getLogger(__name__)
-
 
 def create_dag_bag() -> DBDagBag:
     """Create DagBag with configurable LRU+TTL caching for API server usage."""
     cache_size = conf.getint("api", "dag_cache_size", fallback=64)
-    cache_ttl_config = conf.getint("api", "dag_cache_ttl", fallback=3600)
+    cache_ttl = conf.getint("api", "dag_cache_ttl", fallback=3600)
 
     if cache_size < 0:
-        log.warning("dag_cache_size must be >= 0, using unbounded dict")
-        cache_size = 0
-    if cache_ttl_config < 0:
-        log.warning("dag_cache_ttl must be >= 0, disabling TTL")
-        cache_ttl_config = 0
-
-    # Use unbounded dict (no eviction) if cache_size is 0
-    if cache_size <= 0:
-        return DBDagBag(cache_size=0)
-
-    # Disable TTL if cache_ttl is 0
-    cache_ttl: int | None = cache_ttl_config if cache_ttl_config > 0 else None
+        raise ValueError("[api] dag_cache_size must be greater than or equal 
to 0")
+    if cache_ttl < 0:
+        raise ValueError("[api] dag_cache_ttl must be greater than or equal to 
0")
 
     return DBDagBag(cache_size=cache_size, cache_ttl=cache_ttl)
 
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 6d2438fd213..49ae0c1f309 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1714,26 +1714,37 @@ api:
       default: "False"
     dag_cache_size:
       description: |
-        Size of the LRU cache for SerializedDAG objects in the API server.
-        Set to 0 to use an unbounded dict (no eviction, matching pre-3.2 
behavior).
+        Max number of deserialized SerializedDAG objects the API server keeps 
in memory.
+        Set to 0 for no size limit, leaving eviction to ``dag_cache_ttl``. Set 
both this and
+        ``dag_cache_ttl`` to 0 to use an unbounded dict with no eviction, 
matching the behavior
+        before 3.2.2.
+
         The cache is keyed by Dag version ID, so lookups by Dag ID
         (e.g., viewing a Dag's details) always query the database for the 
latest
         version, but the deserialized result is cached for subsequent
         version-specific lookups.
-      version_added: 3.3.0
+      version_added: 3.2.2
       type: integer
       example: ~
       default: "64"
     dag_cache_ttl:
       description: |
-        Time-to-live (seconds) for cached SerializedDAG objects in the API 
server.
-        After this time, cached DAGs will be re-fetched from the database on 
next access.
-        Set to 0 to disable TTL (cache entries will only be evicted by LRU 
policy).
-
-        Note: After a DAG is updated, the API server may serve the previous 
version
-        until the cached entry expires. Lower values reduce staleness but 
increase
-        database load.
-      version_added: 3.3.0
+        Seconds a deserialized SerializedDAG stays in the API server's cache, 
applied whether or
+        not ``dag_cache_size`` sets a size limit. After this time the Dag is 
re-fetched from the
+        database on next access. Set to 0 to disable TTL, leaving eviction to 
the
+        ``dag_cache_size`` LRU policy.
+
+        An entry's TTL is refreshed only when the entry is checked against the 
database after
+        ``[core] min_serialized_dag_update_interval``, not on every request. 
If the TTL is shorter
+        than that interval, even frequently requested entries can expire and 
reload between
+        checks. ``dag_cache_size`` remains the only hard ceiling on memory.
+
+        Note: this does not govern staleness. A Dag update that creates a new 
version is picked
+        up immediately, because the new version is a different cache key. A 
version rewritten in
+        place is re-checked against its current ``dag_hash`` once
+        ``[core] min_serialized_dag_update_interval`` has elapsed since the 
entry was last
+        validated, so that option bounds how long a rewritten version can be 
served stale.
+      version_added: 3.2.2
       type: integer
       example: ~
       default: "3600"
diff --git a/airflow-core/src/airflow/models/dagbag.py 
b/airflow-core/src/airflow/models/dagbag.py
index 5c0556974ff..dfa336f2fe0 100644
--- a/airflow-core/src/airflow/models/dagbag.py
+++ b/airflow-core/src/airflow/models/dagbag.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import hashlib
+import math
 import time
 from collections.abc import MutableMapping
 from contextlib import nullcontext
@@ -62,9 +63,9 @@ class DBDagBag:
     """
     Internal class for retrieving dags from the database.
 
-    Optionally supports LRU+TTL caching when cache_size is provided.
-    The scheduler uses this without caching, while the API server can
-    enable caching via configuration.
+    Optionally caches deserialized dags: a size limit enables LRU eviction, 
and a TTL enables
+    age-based eviction with or without a size limit. Callers that pass neither 
get a plain dict
+    that never evicts.
 
     :meta private:
     """
@@ -79,26 +80,37 @@ class DBDagBag:
         Initialize DBDagBag.
 
         :param load_op_links: Should the extra operator link be loaded when 
de-serializing the DAG?
-        :param cache_size: Size of LRU cache. If None or 0, uses unbounded 
dict (no eviction).
-        :param cache_ttl: Time-to-live for cache entries in seconds. If None 
or 0, no TTL (LRU only).
+        :param cache_size: Max cached entries. 0 or None means no size limit.
+        :param cache_ttl: Seconds until a cached entry expires, applied with 
or without a size limit.
+            0 or None disables TTL. With neither a size limit nor a TTL the 
cache never evicts.
+        :raises ValueError: If ``cache_size`` or ``cache_ttl`` is negative.
         """
+        # Callers should reject negative values with their own context; 
validate again defensively.
+        if cache_size is not None and cache_size < 0:
+            raise ValueError("cache_size must be greater than or equal to 0")
+        if cache_ttl is not None and cache_ttl < 0:
+            raise ValueError("cache_ttl must be greater than or equal to 0")
+
         self.load_op_links = load_op_links
         self._dags: MutableMapping[UUID | str, _CacheEntry] = {}
         self._use_cache = False
 
         self._revalidation_interval = conf.getint("core", 
"min_serialized_dag_update_interval")
 
-        # Initialize bounded cache if cache_size is provided and > 0
-        if cache_size and cache_size > 0:
-            if cache_ttl and cache_ttl > 0:
-                self._dags = TTLCache(maxsize=cache_size, ttl=cache_ttl)
-            else:
-                self._dags = LRUCache(maxsize=cache_size)
+        # A TTL applies with or without a size limit: an uncapped TTLCache is 
what lets
+        # ``dag_cache_size = 0`` mean "no size limit" rather than "no eviction 
at all".
+        size = cache_size or 0
+        ttl = cache_ttl or 0
+        if ttl > 0:
+            self._dags = TTLCache(maxsize=size or math.inf, ttl=ttl)
+            self._use_cache = True
+        elif size > 0:
+            self._dags = LRUCache(maxsize=size)
             self._use_cache = True
 
         # Lock required for bounded caches: cachetools caches are NOT 
thread-safe
-        # (LRU reordering and TTL cleanup mutate internal linked lists).
-        # nullcontext for unbounded dict avoids lock overhead in the scheduler 
path.
+        # (LRU reordering and TTL cleanup mutate internal linked lists). A 
plain dict needs no
+        # lock, so it uses nullcontext.
         self._lock: RLock | nullcontext = RLock() if self._use_cache else 
nullcontext()
 
     def _read_dag(self, serdag: SerializedDagModel) -> SerializedDAG | None:
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py 
b/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py
index 48c6f706ba7..d56e96a24a3 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py
@@ -16,14 +16,18 @@
 # under the License.
 from __future__ import annotations
 
+import math
+import re
 from unittest import mock
 
 import pytest
 from cachetools import LRUCache, TTLCache
 
 from airflow.api_fastapi.app import purge_cached_app
+from airflow.api_fastapi.common.dagbag import create_dag_bag
 from airflow.sdk import BaseOperator
 
+from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import clear_db_dags, clear_db_runs, 
clear_db_serialized_dags
 
 pytestmark = pytest.mark.db_test
@@ -89,24 +93,41 @@ class TestCreateDagBag:
     """Tests for create_dag_bag() function."""
 
     @pytest.mark.parametrize(
-        ("cache_size", "cache_ttl", "expected_use_cache", 
"expected_dags_type"),
+        ("cache_size", "cache_ttl", "expected_dags_type", "expected_maxsize"),
         [
-            pytest.param(64, 3600, True, TTLCache, id="default_ttl_cache"),
-            pytest.param(0, 3600, False, dict, id="size_zero_unbounded"),
-            pytest.param(64, 0, True, LRUCache, id="ttl_zero_lru_only"),
+            pytest.param("64", "3600", TTLCache, 64, id="default_ttl_cache"),
+            pytest.param("0", "3600", TTLCache, math.inf, 
id="size_zero_ttl_only"),
+            pytest.param("64", "0", LRUCache, 64, id="ttl_zero_lru_only"),
+            pytest.param("0", "0", dict, None, id="both_zero_no_eviction"),
         ],
     )
-    @mock.patch("airflow.api_fastapi.common.dagbag.conf")
-    def test_create_dag_bag_cache_modes(
-        self, mock_conf, cache_size, cache_ttl, expected_use_cache, 
expected_dags_type
-    ):
-        from airflow.api_fastapi.common.dagbag import create_dag_bag
-
-        mock_conf.getint.side_effect = lambda section, key, fallback: {
-            "dag_cache_size": cache_size,
-            "dag_cache_ttl": cache_ttl,
-        }.get(key, fallback)
-
-        dag_bag = create_dag_bag()
-        assert dag_bag._use_cache is expected_use_cache
+    def test_create_dag_bag_cache_modes(self, cache_size, cache_ttl, 
expected_dags_type, expected_maxsize):
+        with conf_vars({("api", "dag_cache_size"): cache_size, ("api", 
"dag_cache_ttl"): cache_ttl}):
+            dag_bag = create_dag_bag()
+
         assert isinstance(dag_bag._dags, expected_dags_type)
+        assert dag_bag._use_cache is (expected_dags_type is not dict)
+        if expected_maxsize is not None:
+            assert dag_bag._dags.maxsize == expected_maxsize
+
+    @pytest.mark.parametrize(
+        ("cache_size", "cache_ttl", "expected_message"),
+        [
+            pytest.param(
+                "-1",
+                "3600",
+                "[api] dag_cache_size must be greater than or equal to 0",
+                id="negative_size",
+            ),
+            pytest.param(
+                "64",
+                "-1",
+                "[api] dag_cache_ttl must be greater than or equal to 0",
+                id="negative_ttl",
+            ),
+        ],
+    )
+    def test_create_dag_bag_rejects_negative_config(self, cache_size, 
cache_ttl, expected_message):
+        with conf_vars({("api", "dag_cache_size"): cache_size, ("api", 
"dag_cache_ttl"): cache_ttl}):
+            with pytest.raises(ValueError, match=re.escape(expected_message)):
+                create_dag_bag()
diff --git a/airflow-core/tests/unit/models/test_dagbag.py 
b/airflow-core/tests/unit/models/test_dagbag.py
index 79668d4fe54..8dbc35ea385 100644
--- a/airflow-core/tests/unit/models/test_dagbag.py
+++ b/airflow-core/tests/unit/models/test_dagbag.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import math
 import time
 from concurrent.futures import ThreadPoolExecutor
 from unittest.mock import MagicMock, patch
@@ -246,29 +247,42 @@ class TestDBDagBag:
 class TestDBDagBagCache:
     """Tests for DBDagBag optional caching behavior."""
 
-    def test_no_caching_by_default(self):
-        """Test that DBDagBag uses a simple dict without caching by default."""
-        dag_bag = DBDagBag()
-        assert dag_bag._use_cache is False
-        assert isinstance(dag_bag._dags, dict)
-
-    def test_lru_cache_enabled_with_cache_size(self):
-        """Test that LRU cache is enabled when cache_size is provided."""
-        dag_bag = DBDagBag(cache_size=10)
-        assert dag_bag._use_cache is True
-        assert isinstance(dag_bag._dags, LRUCache)
-
-    def test_ttl_cache_enabled_with_cache_size_and_ttl(self):
-        """Test that TTL cache is enabled when both cache_size and cache_ttl 
are provided."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
-        assert dag_bag._use_cache is True
-        assert isinstance(dag_bag._dags, TTLCache)
-
-    def test_zero_cache_size_uses_unbounded_dict(self):
-        """Test that cache_size=0 uses unbounded dict (same as no caching)."""
-        dag_bag = DBDagBag(cache_size=0, cache_ttl=60)
-        assert dag_bag._use_cache is False
-        assert isinstance(dag_bag._dags, dict)
+    @pytest.mark.parametrize(
+        ("cache_size", "cache_ttl", "expected_type", "expected_maxsize"),
+        [
+            pytest.param(None, None, dict, None, id="neither_plain_dict"),
+            pytest.param(10, None, LRUCache, 10, id="size_only_lru"),
+            pytest.param(10, 60, TTLCache, 10, id="size_and_ttl_bounded_ttl"),
+            pytest.param(0, 60, TTLCache, math.inf, id="ttl_only_uncapped"),
+        ],
+    )
+    def test_cache_selection(self, cache_size, cache_ttl, expected_type, 
expected_maxsize):
+        dag_bag = DBDagBag(cache_size=cache_size, cache_ttl=cache_ttl)
+        assert isinstance(dag_bag._dags, expected_type)
+        assert dag_bag._use_cache is (expected_type is not dict)
+        if expected_maxsize is not None:
+            assert dag_bag._dags.maxsize == expected_maxsize
+
+    @pytest.mark.parametrize(
+        ("cache_size", "cache_ttl", "expected_message"),
+        [
+            pytest.param(
+                -1,
+                None,
+                "cache_size must be greater than or equal to 0",
+                id="negative_size",
+            ),
+            pytest.param(
+                None,
+                -1,
+                "cache_ttl must be greater than or equal to 0",
+                id="negative_ttl",
+            ),
+        ],
+    )
+    def test_rejects_negative_cache_configuration(self, cache_size, cache_ttl, 
expected_message):
+        with pytest.raises(ValueError, match=expected_message):
+            DBDagBag(cache_size=cache_size, cache_ttl=cache_ttl)
 
     def test_clear_cache_with_caching(self):
         """Test clear_cache() with caching enabled."""

Reply via email to