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 bcf1dbf41a0 Report Dag cache metrics under each component's own 
namespace (#71815)
bcf1dbf41a0 is described below

commit bcf1dbf41a0593e44398c04b3d2aee7eeabe6ed5
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Fri Aug 21 15:06:47 2026 +0800

    Report Dag cache metrics under each component's own namespace (#71815)
    
    * Report Dag cache metrics under each component's own namespace
    
    Every DBDagBag emitted its cache counters under api_server.dag_bag.*, so 
once the
    scheduler gained a cache its traffic was silently counted against the API
    server's series. An operator reading cache_hit or cache_size could not tell 
the
    two components apart, and the API server's numbers became wrong rather than
    merely incomplete.
    
    Each caller now supplies the namespace it reports under, and a cache built
    without one fails at construction instead of emitting a partially-formed 
metric
    name mid-request.
    
    The registry check matched dynamic metric names only by the static prefix 
ahead
    of their first variable, which cannot express a name assembled from a caller
    supplied prefix. It now matches on all static parts wherever the variable 
sits.
    
    * Scope Dag cache configuration to cached database Dag bags
    
    DBDagBag is used by callers that do not configure cache eviction or 
metrics. Keeping those concerns behind a dedicated subtype prevents optional 
constructor combinations and makes metric ownership explicit.
    
    * Preserve no-eviction Dag cache configuration
    
    * Avoid redundant Dag cache checks
---
 .../src/airflow/api_fastapi/common/dagbag.py       |  10 +-
 .../src/airflow/jobs/scheduler_job_runner.py       |   9 +-
 airflow-core/src/airflow/models/dagbag.py          | 127 ++++++++++++---------
 .../tests/unit/api_fastapi/common/test_dagbag.py   |  26 +++--
 airflow-core/tests/unit/jobs/test_scheduler_job.py |   4 +
 airflow-core/tests/unit/models/test_dagbag.py      | 120 ++++++++++++-------
 .../prek/check_metrics_synced_with_the_registry.py |  67 ++++++++---
 .../test_check_metrics_synced_with_the_registry.py |  79 +++++++++++--
 .../observability/metrics/metrics_template.yaml    |  24 ++++
 9 files changed, 329 insertions(+), 137 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/dagbag.py 
b/airflow-core/src/airflow/api_fastapi/common/dagbag.py
index 85ce253fcb8..b96e6f65b06 100644
--- a/airflow-core/src/airflow/api_fastapi/common/dagbag.py
+++ b/airflow-core/src/airflow/api_fastapi/common/dagbag.py
@@ -22,7 +22,7 @@ from fastapi import Depends, HTTPException, Request, status
 from sqlalchemy.orm import Session
 
 from airflow.configuration import conf
-from airflow.models.dagbag import DBDagBag
+from airflow.models.dagbag import CachedDBDagBag, DBDagBag
 from airflow.models.serialized_dag import SerializedDagModel
 
 if TYPE_CHECKING:
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
     from airflow.serialization.definitions.dag import SerializedDAG
 
 
-def create_dag_bag() -> DBDagBag:
+def create_dag_bag() -> CachedDBDagBag:
     """Create DagBag with configurable LRU+TTL caching for API server usage."""
     cache_size = conf.getint("api", "dag_cache_size", fallback=64)
     cache_ttl = conf.getint("api", "dag_cache_ttl", fallback=3600)
@@ -40,7 +40,11 @@ def create_dag_bag() -> DBDagBag:
     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)
+    return CachedDBDagBag(
+        cache_size=cache_size,
+        cache_ttl=cache_ttl,
+        stats_prefix="api_server.dag_bag",
+    )
 
 
 def dag_bag_from_app(request: Request) -> DBDagBag:
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index d744de41816..6b08dab3f02 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -100,7 +100,7 @@ from airflow.models.connection_test import (
 )
 from airflow.models.dag import DagModel
 from airflow.models.dag_version import DagVersion, _resolve_version_data
-from airflow.models.dagbag import DBDagBag
+from airflow.models.dagbag import CachedDBDagBag, DBDagBag
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagrun import DagRun
 from airflow.models.dagwarning import DagWarning, DagWarningType
@@ -383,7 +383,12 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         if log:
             self._log = log
 
-        self.scheduler_dag_bag = DBDagBag(load_op_links=False, 
cache_size=SCHEDULER_DAG_CACHE_SIZE)
+        self.scheduler_dag_bag = CachedDBDagBag(
+            load_op_links=False,
+            cache_size=SCHEDULER_DAG_CACHE_SIZE,
+            cache_ttl=0,
+            stats_prefix="scheduler.dag_bag",
+        )
 
         # Set of (dag_id, asset_name, asset_uri) tuples for trigger policies 
that
         # are permanently unreachable for the rollup window's cardinality — the
diff --git a/airflow-core/src/airflow/models/dagbag.py 
b/airflow-core/src/airflow/models/dagbag.py
index dfa336f2fe0..92662d067a8 100644
--- a/airflow-core/src/airflow/models/dagbag.py
+++ b/airflow-core/src/airflow/models/dagbag.py
@@ -63,55 +63,34 @@ class DBDagBag:
     """
     Internal class for retrieving dags from the database.
 
-    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.
+    Deserialized Dags are retained in an unbounded dictionary. Use 
:class:`CachedDBDagBag` when
+    the caller needs configurable eviction, thread safety, and cache metrics.
 
     :meta private:
     """
 
-    def __init__(
-        self,
-        load_op_links: bool = True,
-        cache_size: int | None = None,
-        cache_ttl: int | None = None,
-    ) -> None:
+    def __init__(self, load_op_links: bool = True) -> None:
         """
         Initialize DBDagBag.
 
         :param load_op_links: Should the extra operator link be loaded when 
de-serializing the DAG?
-        :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")
+        self._lock: RLock | nullcontext = nullcontext()
+
+    def _on_cache_hit(self) -> None:
+        """Handle a Dag cache hit."""
+
+    def _on_cache_miss(self) -> None:
+        """Handle a Dag cache miss."""
 
-        # 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). A 
plain dict needs no
-        # lock, so it uses nullcontext.
-        self._lock: RLock | nullcontext = RLock() if self._use_cache else 
nullcontext()
+    def _on_cache_clear(self) -> None:
+        """Handle the Dag cache being cleared."""
+
+    def _on_cache_size(self, *, rate: float = 1.0) -> None:
+        """Handle a change in the Dag cache size."""
 
     def _read_dag(self, serdag: SerializedDagModel) -> SerializedDAG | None:
         """Read and cache a SerializedDAG (with its ``dag_hash`` for staleness 
detection)."""
@@ -121,9 +100,7 @@ class DBDagBag:
             return None
         with self._lock:
             self._dags[serdag.dag_version_id] = _CacheEntry(dag, 
serdag.dag_hash, time.monotonic())
-            cache_size = len(self._dags)
-        if self._use_cache:
-            stats.gauge("api_server.dag_bag.cache_size", cache_size, rate=0.1)
+        self._on_cache_size(rate=0.1)
         return dag
 
     @staticmethod
@@ -145,8 +122,7 @@ class DBDagBag:
             # [core] min_serialized_dag_update_interval, so an entry validated 
within that window
             # cannot have gone stale yet -- serve it without touching the DB.
             if now - cached.last_validated < self._revalidation_interval:
-                if self._use_cache:
-                    stats.incr("api_server.dag_bag.cache_hit")
+                self._on_cache_hit()
                 return cached.dag
             # Past the window: a version may have been updated in place (same 
dag_version_id, new
             # content + new dag_hash) by SerializedDagModel.write_dag, so 
confirm the cached copy
@@ -160,8 +136,7 @@ class DBDagBag:
                     current = self._dags.get(version_id)
                     if current is not None and current.dag_hash == 
cached.dag_hash:
                         self._dags[version_id] = 
current._replace(last_validated=now)
-                if self._use_cache:
-                    stats.incr("api_server.dag_bag.cache_hit")
+                self._on_cache_hit()
                 return cached.dag
             # Stale (updated in place) or the version no longer exists: drop 
and reload below.
             with self._lock:
@@ -178,12 +153,11 @@ class DBDagBag:
         # served without an extra hash check, consistent with the policy 
above. 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 (cached := self._dags.get(version_id)) is not None:
-                    stats.incr("api_server.dag_bag.cache_hit")
-                    return cached.dag
-            stats.incr("api_server.dag_bag.cache_miss")
+        with self._lock:
+            if (cached := self._dags.get(version_id)) is not None:
+                self._on_cache_hit()
+                return cached.dag
+        self._on_cache_miss()
         return self._read_dag(serdag)
 
     def get_dag(self, version_id: UUID | str, session: Session) -> 
SerializedDAG | None:
@@ -214,9 +188,8 @@ class DBDagBag:
             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)
+        self._on_cache_clear()
+        self._on_cache_size()
         return count
 
     @staticmethod
@@ -258,6 +231,56 @@ class DBDagBag:
         return self._read_dag(serdag)
 
 
+class CachedDBDagBag(DBDagBag):
+    """Retrieve Dags through a configurable, thread-safe cache that emits 
component metrics."""
+
+    def __init__(
+        self,
+        load_op_links: bool = True,
+        *,
+        cache_size: int,
+        cache_ttl: int,
+        stats_prefix: str,
+    ) -> None:
+        """
+        Initialize CachedDBDagBag.
+
+        :param load_op_links: Should the extra operator link be loaded when 
de-serializing the DAG?
+        :param cache_size: Maximum cached entries. Zero means no size limit.
+        :param cache_ttl: Seconds until a cached entry expires. Zero disables 
TTL.
+        :param stats_prefix: Metric namespace for this component's cache.
+        :raises ValueError: If the metrics namespace is empty.
+        """
+        if not stats_prefix:
+            raise ValueError("CachedDBDagBag requires a stats_prefix")
+
+        super().__init__(load_op_links=load_op_links)
+
+        if cache_ttl > 0:
+            self._dags = TTLCache(maxsize=cache_size or math.inf, 
ttl=cache_ttl)
+        elif cache_size > 0:
+            self._dags = LRUCache(maxsize=cache_size)
+
+        # Configured caches are shared across component threads. cachetools 
caches need this for
+        # linked-list mutations, and the unbounded dict needs it for the 
double-checked load path.
+        self._lock = RLock()
+        self._stats_prefix = stats_prefix
+
+    def _on_cache_hit(self) -> None:
+        stats.incr(f"{self._stats_prefix}.cache_hit")
+
+    def _on_cache_miss(self) -> None:
+        stats.incr(f"{self._stats_prefix}.cache_miss")
+
+    def _on_cache_clear(self) -> None:
+        stats.incr(f"{self._stats_prefix}.cache_clear")
+
+    def _on_cache_size(self, *, rate: float = 1.0) -> None:
+        with self._lock:
+            size = len(self._dags)
+        stats.gauge(f"{self._stats_prefix}.cache_size", size, rate=rate)
+
+
 def generate_md5_hash(context):
     bundle_name = context.get_current_parameters()["bundle_name"]
     relative_fileloc = context.get_current_parameters()["relative_fileloc"]
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 d56e96a24a3..1d11b7bc5a3 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_dagbag.py
@@ -25,6 +25,7 @@ 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.models.dagbag import CachedDBDagBag
 from airflow.sdk import BaseOperator
 
 from tests_common.test_utils.config import conf_vars
@@ -53,13 +54,13 @@ class TestDagBagSingleton:
         """Patch DagBag once before app is created, and reset counter."""
         self.dagbag_call_counter["count"] = 0
 
-        from airflow.models.dagbag import DBDagBag as RealDagBag
+        from airflow.models.dagbag import CachedDBDagBag as RealDagBag
 
         def factory(*args, **kwargs):
             self.dagbag_call_counter["count"] += 1
             return RealDagBag(*args, **kwargs)
 
-        with mock.patch("airflow.api_fastapi.common.dagbag.DBDagBag", 
side_effect=factory):
+        with mock.patch("airflow.api_fastapi.common.dagbag.CachedDBDagBag", 
side_effect=factory):
             purge_cached_app()
             yield
 
@@ -93,20 +94,27 @@ class TestCreateDagBag:
     """Tests for create_dag_bag() function."""
 
     @pytest.mark.parametrize(
-        ("cache_size", "cache_ttl", "expected_dags_type", "expected_maxsize"),
+        ("cache_size", "cache_ttl", "expected_bag_type", "expected_dags_type", 
"expected_maxsize"),
         [
-            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"),
+            pytest.param("64", "3600", CachedDBDagBag, TTLCache, 64, 
id="default_ttl_cache"),
+            pytest.param("0", "3600", CachedDBDagBag, TTLCache, math.inf, 
id="size_zero_ttl_only"),
+            pytest.param("64", "0", CachedDBDagBag, LRUCache, 64, 
id="ttl_zero_lru_only"),
+            pytest.param("0", "0", CachedDBDagBag, dict, None, 
id="both_zero_no_eviction"),
         ],
     )
-    def test_create_dag_bag_cache_modes(self, cache_size, cache_ttl, 
expected_dags_type, expected_maxsize):
+    def test_create_dag_bag_cache_modes(
+        self,
+        cache_size,
+        cache_ttl,
+        expected_bag_type,
+        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 type(dag_bag) is expected_bag_type
         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
 
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 88fcb0555ce..9f14dee0b78 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -84,6 +84,7 @@ from airflow.models.connection_test import (
 )
 from airflow.models.dag import DagModel, get_last_dagrun, 
infer_automated_data_interval
 from airflow.models.dag_version import DagVersion
+from airflow.models.dagbag import CachedDBDagBag
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagrun import DagRun
 from airflow.models.dagwarning import DagWarning
@@ -420,8 +421,11 @@ class TestSchedulerJob:
 
         job_runner = SchedulerJobRunner(Job())
 
+        assert isinstance(job_runner.scheduler_dag_bag, CachedDBDagBag)
         assert isinstance(job_runner.scheduler_dag_bag._dags, LRUCache)
         assert job_runner.scheduler_dag_bag._dags.maxsize == 
SCHEDULER_DAG_CACHE_SIZE
+        # Reported separately from the API server's cache, not folded into it.
+        assert job_runner.scheduler_dag_bag._stats_prefix == 
"scheduler.dag_bag"
 
     @pytest.mark.parametrize(
         "heartrate",
diff --git a/airflow-core/tests/unit/models/test_dagbag.py 
b/airflow-core/tests/unit/models/test_dagbag.py
index 8dbc35ea385..d4c5e66314b 100644
--- a/airflow-core/tests/unit/models/test_dagbag.py
+++ b/airflow-core/tests/unit/models/test_dagbag.py
@@ -27,7 +27,7 @@ from cachetools import LRUCache, TTLCache
 
 from airflow.models.dag import DagModel
 from airflow.models.dag_version import DagVersion
-from airflow.models.dagbag import DBDagBag, _CacheEntry
+from airflow.models.dagbag import CachedDBDagBag, DBDagBag, _CacheEntry
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.providers.standard.operators.empty import EmptyOperator
@@ -39,6 +39,26 @@ from tests_common.test_utils import db
 
 pytestmark = pytest.mark.db_test
 
+STATS_PATH = "airflow.models.dagbag.stats"
+
+CACHE_METRIC_SUFFIXES = ("cache_hit", "cache_miss", "cache_clear", 
"cache_size")
+
+# Every namespace a component can report under. CachedDBDagBag builds names 
from the prefix each
+# component passes in, so the shared plumbing is exercised once per component.
+METRIC_PREFIXES = ["api_server.dag_bag", "scheduler.dag_bag"]
+
+STUB_PREFIX = "test.dag_bag"
+
+
+def _stub_dag_bag(*, cache_size: int, cache_ttl: int = 0) -> CachedDBDagBag:
+    """Build a configured cache with a test-only metric prefix."""
+    return CachedDBDagBag(
+        cache_size=cache_size,
+        cache_ttl=cache_ttl,
+        stats_prefix=STUB_PREFIX,
+    )
+
+
 # This file previously contained tests for DagBag functionality, but those 
tests
 # have been moved to airflow-core/tests/unit/dag_processing/test_dagbag.py to 
match
 # the source code reorganization where DagBag moved from models to 
dag_processing.
@@ -245,48 +265,26 @@ class TestDBDagBag:
 
 
 class TestDBDagBagCache:
-    """Tests for DBDagBag optional caching behavior."""
+    """Tests for plain and configured DBDagBag caching behavior."""
 
     @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, 0, 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"),
+            pytest.param(0, 0, dict, None, id="no_eviction"),
         ],
     )
     def test_cache_selection(self, cache_size, cache_ttl, expected_type, 
expected_maxsize):
-        dag_bag = DBDagBag(cache_size=cache_size, cache_ttl=cache_ttl)
+        dag_bag = _stub_dag_bag(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."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
 
         mock_dag = MagicMock()
         dag_bag._dags["version_1"] = mock_dag
@@ -297,6 +295,46 @@ class TestDBDagBagCache:
         assert count == 2
         assert len(dag_bag._dags) == 0
 
+    @pytest.mark.parametrize("prefix", METRIC_PREFIXES)
+    def test_stats_prefix_expands_to_registered_metrics(self, prefix):
+        """Every name a component can emit must exist in the metrics registry.
+
+        The registry prek check sees only the ``{_stats_prefix}.<suffix>`` 
template, so it can
+        verify the suffixes but not the prefix each component supplies. This 
pins the expanded
+        names so a renamed or misspelled prefix cannot ship unregistered.
+        """
+        from airflow._shared.observability.metrics.metrics_registry import 
MetricsRegistry
+
+        registry = MetricsRegistry()
+        missing = [
+            name for suffix in CACHE_METRIC_SUFFIXES if registry.get(name := 
f"{prefix}.{suffix}") is None
+        ]
+        assert not missing
+
+    def test_api_server_reports_under_its_own_namespace(self):
+        from airflow.api_fastapi.common.dagbag import create_dag_bag
+
+        assert create_dag_bag()._stats_prefix == "api_server.dag_bag"
+
+    def test_cached_bag_requires_non_empty_stats_prefix(self):
+        """A cache with no namespace to report under must fail at wiring time, 
not mid-request."""
+        with pytest.raises(ValueError, match="requires a stats_prefix"):
+            CachedDBDagBag(cache_size=10, cache_ttl=60, stats_prefix="")
+
+    def test_plain_bag_emits_no_metrics(self):
+        """The unbounded base implementation does not report component cache 
metrics."""
+        dag_bag = DBDagBag()
+        mock_serdag = MagicMock()
+        mock_serdag.dag_version_id = "test_version_1"
+        mock_serdag.dag = MagicMock()
+
+        with patch(STATS_PATH) as mock_stats:
+            dag_bag._read_dag(mock_serdag)
+            dag_bag.clear_cache()
+
+        mock_stats.incr.assert_not_called()
+        mock_stats.gauge.assert_not_called()
+
     def test_clear_cache_without_caching(self):
         """Test clear_cache() without caching enabled."""
         dag_bag = DBDagBag()
@@ -313,7 +351,7 @@ class TestDBDagBagCache:
         """Test that cached DAGs expire after TTL."""
         # TTLCache defaults to time.monotonic which time_machine cannot 
control.
         # Use time.time as the timer so time_machine can advance it.
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=1)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=1)
         dag_bag._dags = TTLCache(maxsize=10, ttl=1, timer=time.time)
 
         with time_machine.travel("2025-01-01 00:00:00", tick=False):
@@ -326,7 +364,7 @@ class TestDBDagBagCache:
 
     def test_lru_eviction(self):
         """Test that LRU eviction works when cache is full."""
-        dag_bag = DBDagBag(cache_size=2)
+        dag_bag = _stub_dag_bag(cache_size=2)
 
         dag_bag._dags["version_1"] = MagicMock()
         dag_bag._dags["version_2"] = MagicMock()
@@ -339,7 +377,7 @@ class TestDBDagBagCache:
 
     def test_thread_safety_with_caching(self):
         """Test concurrent access doesn't cause race conditions with caching 
enabled."""
-        dag_bag = DBDagBag(cache_size=100, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=100, cache_ttl=60)
         errors = []
         mock_session = MagicMock()
 
@@ -369,7 +407,7 @@ class TestDBDagBagCache:
 
     def test_read_dag_stores_in_bounded_cache(self):
         """Test that _read_dag stores DAG in bounded cache when cache_size > 
0."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
 
         mock_sdm = MagicMock()
         mock_sdm.dag = MagicMock()
@@ -395,7 +433,7 @@ class TestDBDagBagCache:
 
     def test_iter_all_latest_version_dags_does_not_cache(self):
         """Test that iter_all_latest_version_dags does not cache to prevent 
thrashing."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
 
         mock_session = MagicMock()
         mock_sdm = MagicMock()
@@ -411,7 +449,7 @@ class TestDBDagBagCache:
     @patch("airflow.models.dagbag.stats")
     def test_cache_hit_metric_emitted(self, mock_stats):
         """Test that cache hit metric is emitted when caching is enabled."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
         mock_session = MagicMock()
         # last_validated=0.0 forces revalidation; the hash matches, so it 
counts as a hit.
         dag_bag._dags["test_version"] = _CacheEntry(MagicMock(), "hash1", 0.0)
@@ -419,12 +457,12 @@ class TestDBDagBagCache:
 
         dag_bag._get_dag("test_version", mock_session)
 
-        mock_stats.incr.assert_called_with("api_server.dag_bag.cache_hit")
+        mock_stats.incr.assert_called_with(f"{STUB_PREFIX}.cache_hit")
 
     @patch("airflow.models.dagbag.stats")
     def test_cache_miss_metric_emitted(self, mock_stats):
         """Test that cache miss metric is emitted when DAG is found in DB but 
not in cache."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
         mock_session = MagicMock()
 
         # Set up a DB result so _get_dag reaches the miss metric path
@@ -437,22 +475,22 @@ class TestDBDagBagCache:
 
         dag_bag._get_dag("uncached_version", mock_session)
 
-        mock_stats.incr.assert_any_call("api_server.dag_bag.cache_miss")
+        mock_stats.incr.assert_any_call(f"{STUB_PREFIX}.cache_miss")
 
     @patch("airflow.models.dagbag.stats")
     def test_cache_clear_metric_emitted(self, mock_stats):
         """Test that cache clear metric is emitted when caching is enabled."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
         dag_bag._dags["test_version"] = MagicMock()
 
         dag_bag.clear_cache()
 
-        mock_stats.incr.assert_called_with("api_server.dag_bag.cache_clear")
+        mock_stats.incr.assert_called_with(f"{STUB_PREFIX}.cache_clear")
 
     @patch("airflow.models.dagbag.stats")
     def test_cache_size_gauge_emitted(self, mock_stats):
         """Test that cache size gauge is emitted when a DAG is cached."""
-        dag_bag = DBDagBag(cache_size=10, cache_ttl=60)
+        dag_bag = _stub_dag_bag(cache_size=10, cache_ttl=60)
         mock_serdag = MagicMock()
         mock_serdag.dag_version_id = "test_version_1"
         mock_serdag.dag = MagicMock()
@@ -460,4 +498,4 @@ class TestDBDagBagCache:
 
         dag_bag._read_dag(mock_serdag)
 
-        mock_stats.gauge.assert_called_with("api_server.dag_bag.cache_size", 
1, rate=0.1)
+        mock_stats.gauge.assert_called_with(f"{STUB_PREFIX}.cache_size", 1, 
rate=0.1)
diff --git a/scripts/ci/prek/check_metrics_synced_with_the_registry.py 
b/scripts/ci/prek/check_metrics_synced_with_the_registry.py
index 140e6ab9e91..9a1a20d8658 100644
--- a/scripts/ci/prek/check_metrics_synced_with_the_registry.py
+++ b/scripts/ci/prek/check_metrics_synced_with_the_registry.py
@@ -94,19 +94,50 @@ def normalize_metric_name(registry_metric_name: str) -> str:
         "{job_name}_start"             →  "*_start"
         "pool.open_slots"              →  "pool.open_slots"
     """
-    return re.sub(r"\{[^}]+\}", "*", registry_metric_name)
+    return _VARIABLE_RE.sub("*", registry_metric_name)
 
 
-# Sentinel returned when a dynamic metric name is partially matched based on a 
common prefix.
+# Sentinel returned when a dynamic metric name is structurally matched against 
registry entries.
 # For dynamic metric names that include variables, the check can't find an 
exact match with a registry
-# entry or its type. So, a partially matched prefix is good enough and type 
checking is skipped.
-_PREFIX_MATCHED = "__prefix_matched__"
+# entry or its type. So, a structural match is good enough and type checking 
is skipped.
+_PATTERN_MATCHED = "__pattern_matched__"
 
+# A ``{variable}`` stands for one or more dot-separated segments, so one 
pattern covers both a
+# single-segment substitution (``{state}`` -> ``running``) and a multi-segment 
one
+# (``{stats_prefix}`` -> ``api_server.dag_bag``).
+_VARIABLE_SEGMENTS = r"[^.]+(?:\.[^.]+)*"
 
-def find_prefix_matched_registry_entries(metric_name: str, metrics_registry: 
dict[str, dict]) -> list[str]:
-    """Return the registry entry names whose name matches the static prefix of 
a dynamic metric name."""
-    base = metric_name.split("{")[0].rstrip(".")
-    return [name for name in metrics_registry if name == base or 
name.startswith(base + ".")]
+# The ``{variable}`` placeholder itself, shared by name normalization and 
pattern compilation.
+_VARIABLE_RE = re.compile(r"\{[^}]+\}")
+
+
+def compile_dynamic_metric_pattern(metric_name: str) -> re.Pattern[str]:
+    """Compile a ``{variable}``-containing metric name into a regex over its 
static parts.
+
+    Matching on the whole shape rather than only the prefix before the first 
variable means a
+    variable may sit anywhere in the name, including at the start or between 
static parts::
+
+        "ti.{state}"                     matches "ti.running"
+        "{stats_prefix}.cache_hit"       matches "api_server.dag_bag.cache_hit"
+        "{prefix}.foo.{state}.duration"  matches "a.b.foo.success.duration"
+    """
+    literals = _VARIABLE_RE.split(metric_name)
+    return re.compile(_VARIABLE_SEGMENTS.join(re.escape(literal) for literal 
in literals))
+
+
+def find_pattern_matched_registry_entries(metric_name: str, metrics_registry: 
dict[str, dict]) -> list[str]:
+    """Return the registry entry names a dynamic metric name structurally 
matches."""
+    literals = _VARIABLE_RE.split(metric_name)
+    if len(literals) == 1:
+        # Static name: the exact and normalized lookups already had their 
chance.
+        return []
+    if not any(literals):
+        # Nothing but variables, e.g. ``{name}`` or ``{prefix}{suffix}``. The 
pattern would be a
+        # bare "any segments" regex matching every entry, which marks the 
whole registry used and
+        # silently disables the unused-entry check. Match nothing so the name 
is reported missing.
+        return []
+    pattern = compile_dynamic_metric_pattern(metric_name)
+    return [name for name in metrics_registry if pattern.fullmatch(name)]
 
 
 def find_registry_match(metric_name: str, metrics_registry: dict[str, dict]) 
-> str | None:
@@ -126,13 +157,11 @@ def find_registry_match(metric_name: str, 
metrics_registry: dict[str, dict]) ->
             return registry_metric_name
 
     # Dynamic metric name.
-    if "{" in metric_name and 
find_prefix_matched_registry_entries(metric_name, metrics_registry):
-        # Metric prefix matches the prefix of a dynamic registry entry.
-        # If the static part before the first variable, matches an exact 
registry entry name,
-        # or a dotted-prefix of one, then the name is considered covered and
-        # _PREFIX_MATCHED is returned. The type check must be skipped because
-        # the resulting metric name with all variables expanded, cannot be 
determined.
-        return _PREFIX_MATCHED
+    if find_pattern_matched_registry_entries(metric_name, metrics_registry):
+        # The name's static parts line up with at least one registry entry, so 
it is considered
+        # covered and _PATTERN_MATCHED is returned. The type check must be 
skipped because the
+        # resulting metric name with all variables expanded cannot be 
determined.
+        return _PATTERN_MATCHED
 
     # All checks for matching failed.
     return None
@@ -381,8 +410,8 @@ def compute_unused_registry_entries(
         registry_metric_name = find_registry_match(metric_name, 
metrics_registry)
         if registry_metric_name is None:
             continue
-        if registry_metric_name is _PREFIX_MATCHED:
-            
used_entries.update(find_prefix_matched_registry_entries(metric_name, 
metrics_registry))
+        if registry_metric_name is _PATTERN_MATCHED:
+            
used_entries.update(find_pattern_matched_registry_entries(metric_name, 
metrics_registry))
         else:
             used_entries.add(registry_metric_name)
     return sorted(set(metrics_registry) - used_entries)
@@ -439,9 +468,9 @@ def main() -> None:
     metrics_with_type_mismatch: dict[str, list[tuple[MetricCall, str, str]]] = 
{}
     for name, calls in code_metrics.items():
         registry_metric_name = find_registry_match(name, metrics_registry)
-        if registry_metric_name is None or registry_metric_name is 
_PREFIX_MATCHED:
+        if registry_metric_name is None or registry_metric_name is 
_PATTERN_MATCHED:
             # If None, then it's reported as missing, no need for type check.
-            # If _PREFIX_MATCHED, then the exact entry can't be determined. 
Skip the type check.
+            # If _PATTERN_MATCHED, then the exact entry can't be determined. 
Skip the type check.
             continue
         registry_type = metrics_registry[registry_metric_name].get("type", 
"").lower()
         mismatched = [
diff --git 
a/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py 
b/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
index e9b5e2ebe29..0c817343778 100644
--- a/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
+++ b/scripts/tests/ci/prek/test_check_metrics_synced_with_the_registry.py
@@ -24,13 +24,13 @@ from unittest import mock
 import pytest
 from ci.prek import check_metrics_synced_with_the_registry
 from ci.prek.check_metrics_synced_with_the_registry import (
-    _PREFIX_MATCHED,
+    _PATTERN_MATCHED,
     _except_handler_catches_expected_error,
     _is_stats_module_path,
     compute_unused_registry_entries,
     extract_metric_name_from_ast_node,
     extract_metric_names_from_ast_node,
-    find_prefix_matched_registry_entries,
+    find_pattern_matched_registry_entries,
     find_registry_match,
     find_stale_indirectly_emitted_metrics,
     get_stats_obj_name,
@@ -117,9 +117,9 @@ def test_normalize_metric_name(metric_name, 
expected_result):
         # In this case, the legacy name of 'task.duration', is 
'dag.{dag_id}.{task_id}.duration'.
         # Once normalized, both will be 'dag.*.*.duration' and there should be 
a match.
         pytest.param("dag.{x}.{y}.duration", "task.duration", 
id="legacy_name_match_different_structure"),
-        pytest.param("ti.{state}", _PREFIX_MATCHED, 
id="prefix_match_returns_sentinel"),
-        pytest.param("dagrun.duration.{state}", _PREFIX_MATCHED, 
id="prefix_match_dotted_base"),
-        pytest.param("non.existent.{var}", None, 
id="dynamic_metric_no_prefix_match_returns_none"),
+        pytest.param("ti.{state}", _PATTERN_MATCHED, 
id="pattern_match_returns_sentinel"),
+        pytest.param("dagrun.duration.{state}", _PATTERN_MATCHED, 
id="pattern_match_dotted_static_part"),
+        pytest.param("non.existent.{var}", None, 
id="dynamic_metric_no_pattern_match_returns_none"),
         pytest.param("non.existent", None, 
id="static_metric_not_in_registry_returns_none"),
     ],
 )
@@ -222,14 +222,71 @@ def test_extract_metric_names_from_ast_node(code: str, 
expected_result):
         pytest.param(
             "ti.{state}",
             ["ti.scheduled", "ti.queued", "ti.start.{dag_id}.{task_id}"],
-            id="base_prefix_matches_multiple_entries",
+            id="pattern_matches_multiple_entries",
         ),
-        pytest.param("dagrun.duration.{state}", ["dagrun.duration.success"], 
id="dotted_base_prefix"),
-        pytest.param("non.existent.{var}", [], 
id="no_prefix_match_returns_empty_list"),
+        pytest.param("dagrun.duration.{state}", ["dagrun.duration.success"], 
id="dotted_static_part"),
+        pytest.param("non.existent.{var}", [], 
id="no_pattern_match_returns_empty_list"),
     ],
 )
-def test_find_prefix_matched_registry_entries(metric_name, expected_result):
-    assert find_prefix_matched_registry_entries(metric_name, METRICS_REGISTRY) 
== expected_result
+def test_find_pattern_matched_registry_entries(metric_name, expected_result):
+    assert find_pattern_matched_registry_entries(metric_name, 
METRICS_REGISTRY) == expected_result
+
+
+# A registry whose entries share a suffix but differ in how many segments 
precede it, which is the
+# shape produced by a metric name built from a per-component prefix.
+PREFIXED_METRICS_REGISTRY = {
+    "api_server.dag_bag.cache_hit": {"name": "api_server.dag_bag.cache_hit", 
"type": "counter"},
+    "scheduler.dag_bag.cache_hit": {"name": "scheduler.dag_bag.cache_hit", 
"type": "counter"},
+    "pool.open_slots": {"name": "pool.open_slots", "type": "gauge"},
+    "a.b.foo.success.duration": {"name": "a.b.foo.success.duration", "type": 
"timer"},
+}
+
+
[email protected](
+    "metric_name, expected_result",
+    [
+        pytest.param(
+            "{stats_prefix}.cache_hit",
+            ["api_server.dag_bag.cache_hit", "scheduler.dag_bag.cache_hit"],
+            id="leading_variable_spans_multiple_segments",
+        ),
+        pytest.param(
+            "{prefix}.foo.{state}.duration",
+            ["a.b.foo.success.duration"],
+            id="variables_around_a_static_middle",
+        ),
+        pytest.param("{stats_prefix}.cache_miss", [], 
id="unregistered_suffix_matches_nothing"),
+        pytest.param("{prefix}.open_slots", ["pool.open_slots"], 
id="single_segment_prefix"),
+    ],
+)
+def 
test_find_pattern_matched_registry_entries_with_variable_prefix(metric_name, 
expected_result):
+    """A variable anywhere in the name resolves, which static-prefix matching 
could not do."""
+    assert find_pattern_matched_registry_entries(metric_name, 
PREFIXED_METRICS_REGISTRY) == expected_result
+
+
+def test_pattern_match_does_not_cross_static_parts():
+    """The static parts must line up, so a name is not matched just because it 
shares a suffix."""
+    assert find_pattern_matched_registry_entries("{prefix}.bar.duration", 
PREFIXED_METRICS_REGISTRY) == []
+
+
[email protected](
+    "metric_name",
+    [
+        pytest.param("{variable}", id="single_variable"),
+        pytest.param("{prefix}{suffix}", id="adjacent_variables"),
+    ],
+)
+def test_all_variable_name_matches_nothing(metric_name):
+    """A name with no static part must not match, or it marks the whole 
registry used.
+
+    Its pattern would be a bare "any segments" regex, so every entry would 
fullmatch and
+    ``compute_unused_registry_entries`` would go permanently empty -- the 
check failing open.
+    """
+    assert find_pattern_matched_registry_entries(metric_name, 
PREFIXED_METRICS_REGISTRY) == []
+    assert find_registry_match(metric_name, PREFIXED_METRICS_REGISTRY) is None
+    assert compute_unused_registry_entries({metric_name}, 
PREFIXED_METRICS_REGISTRY) == sorted(
+        PREFIXED_METRICS_REGISTRY
+    )
 
 
 # 'executor.open_slots' is in INDIRECTLY_EMITTED_METRICS, so it is never 
reported as unused.
@@ -263,7 +320,7 @@ def test_find_prefix_matched_registry_entries(metric_name, 
expected_result):
         pytest.param(
             {"ti.{state}"},
             ["dagrun.duration.success", "pool.open_slots", 
"scheduler.heartbeat", "task.duration"],
-            id="prefix_match_marks_all_prefix_entries_used",
+            id="pattern_match_marks_all_matched_entries_used",
         ),
         pytest.param(
             {"pool.open_slots.{my_pool}"},
diff --git 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 392b55d7c07..edac39bb48c 100644
--- 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++ 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -361,6 +361,24 @@ metrics:
     legacy_name: "-"
     name_variables: []
 
+  - name: "scheduler.dag_bag.cache_hit"
+    description: "Number of cache hits when retrieving SerializedDAG from 
DBDagBag in the scheduler"
+    type: "counter"
+    legacy_name: "-"
+    name_variables: []
+
+  - name: "scheduler.dag_bag.cache_miss"
+    description: "Number of cache misses when retrieving SerializedDAG from 
DBDagBag in the scheduler"
+    type: "counter"
+    legacy_name: "-"
+    name_variables: []
+
+  - name: "scheduler.dag_bag.cache_clear"
+    description: "Number of times the DBDagBag cache was cleared in the 
scheduler"
+    type: "counter"
+    legacy_name: "-"
+    name_variables: []
+
   - name: "connection_test.success"
     description: "Number of worker-dispatched connection tests that completed 
successfully."
     type: "counter"
@@ -395,6 +413,12 @@ metrics:
     legacy_name: "-"
     name_variables: []
 
+  - name: "scheduler.dag_bag.cache_size"
+    description: "Current number of SerializedDAG objects cached in the 
scheduler's DBDagBag"
+    type: "gauge"
+    legacy_name: "-"
+    name_variables: []
+
   - name: "connection_test.active"
     description: "Number of connection tests currently in flight (``queued`` + 
``running``), sampled by the
     scheduler each tick."

Reply via email to