Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 17.7k
Add configurable LRU+TTL caching for API server DAG retrieval#60804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
9ed6486d3dc17799faf78f8b339803ed0f8dcb2ab5d31c6efFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add configurable LRU+TTL caching for API server DAG retrieval via ``dag_cache_size`` and ``dag_cache_ttl`` config options in the ``[api]`` section. This bounds memory growth from accumulated SerializedDAG objects in long-running API server processes. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -16,21 +16,42 @@ | ||
| # under the License. | ||
| from __future__ import annotations | ||
| import logging | ||
| from typing import TYPE_CHECKING, Annotated | ||
| from fastapi import Depends, HTTPException, Request, status | ||
| from sqlalchemy.orm import Session | ||
| from airflow.configuration import conf | ||
| from airflow.models.dagbag import DBDagBag | ||
| 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 to retrieve DAGs from the database.""" | ||
| return 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) | ||
| 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 | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| cache_ttl: int | None = cache_ttl_config if cache_ttl_config > 0 else None | ||
| return DBDagBag(cache_size=cache_size, cache_ttl=cache_ttl) | ||
| def dag_bag_from_app(request: Request) -> DBDagBag: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -18,12 +18,17 @@ | ||
| from __future__ import annotations | ||
| import hashlib | ||
| from collections.abc import MutableMapping | ||
| from contextlib import nullcontext | ||
| from threading import RLock | ||
| from typing import TYPE_CHECKING, Any | ||
| from uuid import UUID | ||
| from cachetools import LRUCache, TTLCache | ||
| from sqlalchemy import String, select | ||
| from sqlalchemy.orm import Mapped, joinedload, mapped_column | ||
| from airflow._shared.observability.metrics.stats import Stats | ||
| from airflow.models.base import Base, StringID | ||
| from airflow.models.dag_version import DagVersion | ||
| @@ -39,50 +44,117 @@ | ||
| class DBDagBag: | ||
| """ | ||
| Internal class for retrieving and caching dags in the scheduler. | ||
| 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. | ||
| :meta private: | ||
| """ | ||
| def __init__(self, load_op_links: bool = True) -> None: | ||
| self._dags: dict[UUID, SerializedDagModel] = {} # dag_version_id to dag | ||
| self.load_op_links = load_op_links | ||
| def __init__( | ||
| self, | ||
| load_op_links: bool = True, | ||
| cache_size: int | None = None, | ||
| cache_ttl: int | None = None, | ||
| ) -> None: | ||
| """ | ||
| Initialize DBDagBag. | ||
| def _read_dag(self, serialized_dag_model: SerializedDagModel) -> SerializedDAG | None: | ||
| serialized_dag_model.load_op_links = self.load_op_links | ||
| if dag := serialized_dag_model.dag: | ||
| self._dags[serialized_dag_model.dag_version_id] = serialized_dag_model | ||
| :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). | ||
| """ | ||
| self.load_op_links = load_op_links | ||
| self._dags: MutableMapping[UUID | str, SerializedDAG] = {} | ||
| self._use_cache = False | ||
| # Initialize bounded cache if cache_size is provided and > 0 | ||
| if cache_size and cache_size > 0: | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if cache_ttl and cache_ttl > 0: | ||
| self._dags = TTLCache(maxsize=cache_size, ttl=cache_ttl) | ||
| else: | ||
| self._dags = LRUCache(maxsize=cache_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. | ||
| self._lock: RLock | nullcontext = RLock() if self._use_cache else nullcontext() | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| def _read_dag(self, serdag: SerializedDagModel) -> SerializedDAG | None: | ||
| """Read and optionally cache a SerializedDAG from a SerializedDagModel.""" | ||
| serdag.load_op_links = self.load_op_links | ||
| dag = serdag.dag | ||
| if not dag: | ||
| return None | ||
| with self._lock: | ||
| self._dags[serdag.dag_version_id] = dag | ||
| cache_size = len(self._dags) | ||
| if self._use_cache: | ||
| Stats.gauge("api_server.dag_bag.cache_size", cache_size, rate=0.1) | ||
| return dag | ||
| def get_serialized_dag_model(self, version_id: UUID, session: Session) -> SerializedDagModel | None: | ||
| def _get_dag(self, version_id: UUID | str, session: Session) -> SerializedDAG | None: | ||
| # Check cache first | ||
| with self._lock: | ||
| dag = self._dags.get(version_id) | ||
| if dag: | ||
| if self._use_cache: | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| Stats.incr("api_server.dag_bag.cache_hit") | ||
| return dag | ||
| dag_version = session.get(DagVersion, version_id, options=[joinedload(DagVersion.serialized_dag)]) | ||
| if not dag_version: | ||
| return None | ||
| if not (serdag := dag_version.serialized_dag): | ||
| return None | ||
| # Double-checked locking: another thread may have cached it while we queried DB. | ||
| # Only emit the miss metric after confirming no other thread cached it, to avoid | ||
| # counting a single lookup as both a miss and a hit. | ||
| if self._use_cache: | ||
| with self._lock: | ||
| if dag := self._dags.get(version_id): | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| Stats.incr("api_server.dag_bag.cache_hit") | ||
| return dag | ||
| Stats.incr("api_server.dag_bag.cache_miss") | ||
| return self._read_dag(serdag) | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| def get_dag(self, version_id: UUID | str, session: Session) -> SerializedDAG | None: | ||
| """Get a dag by its version id, using cache if enabled.""" | ||
| return self._get_dag(version_id=version_id, session=session) | ||
| def get_serialized_dag_model(self, version_id: UUID | str, session: Session) -> SerializedDagModel | None: | ||
| """ | ||
| Return the SerializedDagModel for a given dag version id. | ||
| This will first consult the in-memory cache keyed by the dag version id. If the | ||
| model is not cached, the database is queried for a corresponding :class:`DagVersion` | ||
| and its associated :class:`SerializedDagModel`. | ||
| Always queries the database. The triggerer needs the full model | ||
| for ``serialized_dag_model.data``, which cannot be stored in the | ||
| LRU/TTL cache (it stores deserialized SerializedDAG objects). | ||
| """ | ||
| dag_version = session.get(DagVersion, version_id, options=[joinedload(DagVersion.serialized_dag)]) | ||
| if not dag_version or not (serdag := dag_version.serialized_dag): | ||
| return None | ||
| serdag.load_op_links = self.load_op_links | ||
| return serdag | ||
| :param version_id: The UUID of the dag version to look up. | ||
| :param session: SQLAlchemy session used to query the database. | ||
| :return: The serialized DAG model if found either in the cache or the database; ``None`` | ||
| is returned when no :class:`DagVersion` exists for the given ``version_id`` or | ||
| when that :class:`DagVersion` does not have an associated :class:`SerializedDagModel`. | ||
| :rtype: SerializedDagModel | None | ||
| def clear_cache(self) -> int: | ||
| """ | ||
| Clear all cached DAGs and serialized DAG models. | ||
| Note: If a serialized dag model is found in the database it will be stored in the | ||
| internal cache (``self._dags``) before being returned. | ||
| :return: Number of entries cleared from the DAG cache. | ||
| """ | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if not (serialized_dag_model := self._dags.get(version_id)): | ||
| dag_version = session.get(DagVersion, version_id, options=[joinedload(DagVersion.serialized_dag)]) | ||
| if not dag_version or not (serialized_dag_model := dag_version.serialized_dag): | ||
| return None | ||
| self._read_dag(serialized_dag_model) | ||
| return serialized_dag_model | ||
| def get_dag(self, version_id: UUID, session: Session) -> SerializedDAG | None: | ||
| if serialized_dag_model := self.get_serialized_dag_model(version_id=version_id, session=session): | ||
| return serialized_dag_model.dag | ||
| return None | ||
| with self._lock: | ||
| count = len(self._dags) | ||
| self._dags.clear() | ||
| if self._use_cache: | ||
| Stats.incr("api_server.dag_bag.cache_clear") | ||
| Stats.gauge("api_server.dag_bag.cache_size", 0) | ||
| return count | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @staticmethod | ||
| def _version_from_dag_run(dag_run: DagRun, *, session: Session) -> UUID | None: | ||
| @@ -94,24 +166,30 @@ def _version_from_dag_run(dag_run: DagRun, *, session: Session) -> UUID | None: | ||
| def get_dag_for_run(self, dag_run: DagRun, session: Session) -> SerializedDAG | None: | ||
| if version_id := self._version_from_dag_run(dag_run=dag_run, session=session): | ||
| return self.get_dag(version_id=version_id, session=session) | ||
| return self._get_dag(version_id=version_id, session=session) | ||
| return None | ||
| def iter_all_latest_version_dags(self, *, session: Session) -> Generator[SerializedDAG, None, None]: | ||
| """Walk through all latest version dags available in the database.""" | ||
| """ | ||
| Walk through all latest version dags available in the database. | ||
| Note: This method does NOT cache the DAGs to avoid cache thrashing when | ||
| iterating over many DAGs. Each DAG is deserialized fresh from the database. | ||
| """ | ||
| from airflow.models.serialized_dag import SerializedDagModel | ||
| for serialized_dag_model in session.scalars(select(SerializedDagModel)): | ||
| if dag := self._read_dag(serialized_dag_model): | ||
| for sdm in session.scalars(select(SerializedDagModel)): | ||
| sdm.load_op_links = self.load_op_links | ||
| if dag := sdm.dag: | ||
| yield dag | ||
| def get_latest_version_of_dag(self, dag_id: str, *, session: Session) -> SerializedDAG | None: | ||
| """Get the latest version of a dag by its id.""" | ||
| from airflow.models.serialized_dag import SerializedDagModel | ||
| if not (serialized_dag_model := SerializedDagModel.get(dag_id, session=session)): | ||
| if not (serdag := SerializedDagModel.get(dag_id, session=session)): | ||
| return None | ||
| return self._read_dag(serialized_dag_model) | ||
| return self._read_dag(serdag) | ||
| def generate_md5_hash(context): | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -19,6 +19,7 @@ | ||
| from unittest import mock | ||
| import pytest | ||
| from cachetools import LRUCache, TTLCache | ||
| from airflow.api_fastapi.app import purge_cached_app | ||
| from airflow.sdk import BaseOperator | ||
| @@ -82,3 +83,30 @@ def test_dagbag_used_as_singleton_in_dependency(self, session, dag_maker, test_c | ||
| assert resp2.status_code == 200 | ||
| assert self.dagbag_call_counter["count"] == 1 | ||
| class TestCreateDagBag: | ||
| """Tests for create_dag_bag() function.""" | ||
kaxil marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @pytest.mark.parametrize( | ||
| ["cache_size", "cache_ttl", "expected_use_cache", "expected_dags_type"], | ||
| [ | ||
| 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"), | ||
| ], | ||
| ) | ||
| @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 | ||
| assert isinstance(dag_bag._dags, expected_dags_type) | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.