Skip to content

Commit 379aa9e

Browse files
committed
Allow DBDagBag TTL cache eviction without a size cap
1 parent a524242 commit 379aa9e

2 files changed

Lines changed: 43 additions & 14 deletions

File tree

airflow-core/src/airflow/models/dagbag.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from __future__ import annotations
1919

2020
import hashlib
21+
import math
2122
import time
2223
from collections.abc import MutableMapping
2324
from contextlib import nullcontext
@@ -62,9 +63,9 @@ class DBDagBag:
6263
"""
6364
Internal class for retrieving dags from the database.
6465
65-
Optionally supports LRU+TTL caching when cache_size is provided.
66-
The scheduler uses this without caching, while the API server can
67-
enable caching via configuration.
66+
Optionally caches deserialized dags. A size cap enables LRU eviction and a TTL
67+
enables age-based eviction, with or without a size cap. The API server enables
68+
caching via configuration.
6869
6970
:meta private:
7071
"""
@@ -79,8 +80,9 @@ def __init__(
7980
Initialize DBDagBag.
8081
8182
:param load_op_links: Should the extra operator link be loaded when de-serializing the DAG?
82-
:param cache_size: Size of LRU cache. If None or 0, uses unbounded dict (no eviction).
83-
:param cache_ttl: Time-to-live for cache entries in seconds. If None or 0, no TTL (LRU only).
83+
:param cache_size: Max cached entries; 0 or None means no size cap.
84+
:param cache_ttl: Seconds until a cached entry expires. If > 0, entries are evicted by
85+
age regardless of ``cache_size`` (with no size cap this gives TTL-only eviction).
8486
"""
8587
self.load_op_links = load_op_links
8688
self._dags: MutableMapping[UUID | str, _CacheEntry] = {}
@@ -89,11 +91,12 @@ def __init__(
8991
self._revalidation_interval = conf.getint("core", "min_serialized_dag_update_interval")
9092

9193
# Initialize bounded cache if cache_size is provided and > 0
92-
if cache_size and cache_size > 0:
93-
if cache_ttl and cache_ttl > 0:
94-
self._dags = TTLCache(maxsize=cache_size, ttl=cache_ttl)
95-
else:
96-
self._dags = LRUCache(maxsize=cache_size)
94+
if cache_ttl and cache_ttl > 0:
95+
maxsize = cache_size if cache_size and cache_size > 0 else math.inf
96+
self._dags = TTLCache(maxsize=maxsize, ttl=cache_ttl)
97+
self._use_cache = True
98+
elif cache_size and cache_size > 0:
99+
self._dags = LRUCache(maxsize=cache_size)
97100
self._use_cache = True
98101

99102
# Lock required for bounded caches: cachetools caches are NOT thread-safe

airflow-core/tests/unit/models/test_dagbag.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
import math
1920
import time
2021
from concurrent.futures import ThreadPoolExecutor
2122
from unittest.mock import MagicMock, patch
@@ -259,14 +260,24 @@ def test_lru_cache_enabled_with_cache_size(self):
259260
assert isinstance(dag_bag._dags, LRUCache)
260261

261262
def test_ttl_cache_enabled_with_cache_size_and_ttl(self):
262-
"""Test that TTL cache is enabled when both cache_size and cache_ttl are provided."""
263+
"""Test that a bounded TTL cache is used when both cache_size and cache_ttl are provided."""
263264
dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
264265
assert dag_bag._use_cache is True
265266
assert isinstance(dag_bag._dags, TTLCache)
267+
assert dag_bag._dags.maxsize == 10
266268

267-
def test_zero_cache_size_uses_unbounded_dict(self):
268-
"""Test that cache_size=0 uses unbounded dict (same as no caching)."""
269-
dag_bag = DBDagBag(cache_size=0, cache_ttl=60)
269+
@pytest.mark.parametrize("cache_size", [0, None])
270+
def test_ttl_only_without_size_cap(self, cache_size):
271+
"""Test that a positive cache_ttl with no size cap gives a TTL cache with maxsize=inf."""
272+
dag_bag = DBDagBag(cache_size=cache_size, cache_ttl=60)
273+
assert dag_bag._use_cache is True
274+
assert isinstance(dag_bag._dags, TTLCache)
275+
assert dag_bag._dags.maxsize == math.inf
276+
277+
@pytest.mark.parametrize("cache_ttl", [None, 0])
278+
def test_zero_cache_size_uses_unbounded_dict(self, cache_ttl):
279+
"""Test that cache_size=0 without a TTL uses an unbounded dict (same as no caching)."""
280+
dag_bag = DBDagBag(cache_size=0, cache_ttl=cache_ttl)
270281
assert dag_bag._use_cache is False
271282
assert isinstance(dag_bag._dags, dict)
272283

@@ -310,6 +321,21 @@ def test_ttl_cache_expiry(self):
310321
with time_machine.travel("2025-01-01 00:00:02", tick=False):
311322
assert dag_bag._dags.get("test_version_id") is None
312323

324+
def test_ttl_only_evicts_by_ttl_not_size(self):
325+
"""An unbounded (maxsize=inf) TTL cache keeps every entry until it expires by age."""
326+
dag_bag = DBDagBag(cache_size=0, cache_ttl=1)
327+
assert dag_bag._dags.maxsize == math.inf
328+
dag_bag._dags = TTLCache(maxsize=math.inf, ttl=1, timer=time.time)
329+
330+
with time_machine.travel("2025-01-01 00:00:00", tick=False):
331+
for i in range(500):
332+
dag_bag._dags[f"version_{i}"] = MagicMock()
333+
assert len(dag_bag._dags) == 500
334+
335+
with time_machine.travel("2025-01-01 00:00:02", tick=False):
336+
assert dag_bag._dags.get("version_0") is None
337+
assert len(dag_bag._dags) == 0
338+
313339
def test_lru_eviction(self):
314340
"""Test that LRU eviction works when cache is full."""
315341
dag_bag = DBDagBag(cache_size=2)

0 commit comments

Comments
 (0)