') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Add configurable LRU+TTL caching for API server DAG retrieval by kaxil · Pull Request #60804 · apache/airflow · GitHub
Skip to content

Add configurable LRU+TTL caching for API server DAG retrieval - #60804

Merged
kaxil merged 7 commits into
apache:mainfrom
astronomer:add-api-server-dag-cache
Apr 17, 2026
Merged

Add configurable LRU+TTL caching for API server DAG retrieval#60804
kaxil merged 7 commits into
apache:mainfrom
astronomer:add-api-server-dag-cache

Conversation

@kaxil

@kaxilkaxil commented Jan 20, 2026

Copy link
Copy Markdown
Member

Summary

Fixes memory growth in long-running API servers by adding bounded LRU+TTL caching to DBDagBag. Previously, the internal dict cache never expired and never evicted, causing memory to grow indefinitely as DAG versions accumulated (~500 MB/day with 100+ DAGs updating daily).

Two new [api] config options control caching:

ConfigDefaultDescription
dag_cache_size64Max cached DAG versions (0 = unbounded dict, no eviction)
dag_cache_ttl3600TTL in seconds (0 = LRU only, no time-based expiry)

Design decisions

API server only. The scheduler continues using a plain unbounded dict with zero lock overhead (nullcontext instead of RLock). The bounded cache + lock is only created when cache_size > 0.

Cache thrashing prevention.iter_all_latest_version_dags() (used by the DAG listing endpoint) bypasses the cache entirely. Without this, every DAG listing request would flush the hot working set and replace it with a full scan of all DAGs.

Double-checked locking. When multiple threads miss on the same version_id concurrently, only the first thread queries the DB. The rest find it cached after acquiring the lock. Metrics are emitted correctly: a single lookup never counts as both a hit and a miss.

Separate model cache.get_serialized_dag_model() maintains its own dict cache. The triggerer needs the full SerializedDagModel (for .data), not the deserialized SerializedDAG stored in the LRU/TTL cache.

Cache keying. The cache is keyed by DAG version ID. Lookups by dag_id (e.g., viewing a DAG's details) always query the DB for the latest version, but the deserialized result is cached for subsequent version-specific lookups (e.g., task instance views for a specific DAG run).

Staleness. After a DAG is updated, the API server may serve the previous version until the cached entry expires (controlled by dag_cache_ttl). This is documented in the config description.

Why cachetools.cachetools is a small, pure-Python library (~1K LOC) already present as a transitive dependency via google-auth. It provides battle-tested LRUCache and TTLCache implementations. Pinned at >=6.0.0 to match the FAB provider.

Why RLock.cachetools caches are NOT thread-safe -- .get() mutates internal doubly-linked lists (LRU reordering) and TTL access triggers cleanup. Without synchronization, concurrent access can corrupt the data structure.

Metrics

MetricTypeDescription
api_server.dag_bag.cache_hitCounterCache hits (including double-checked locking hits)
api_server.dag_bag.cache_missCounterConfirmed misses (after double-check)
api_server.dag_bag.cache_clearCounterCache clears
api_server.dag_bag.cache_sizeGaugeCurrent cache size (sampled at 10%)

Backward compatibility

  • Default behavior unchanged for scheduler and triggerer (unbounded dict, no lock)
  • API server gets caching by default (dag_cache_size=64, dag_cache_ttl=3600)
  • Use dag_cache_size=0 to restore pre-change behavior (unbounded dict)
  • No breaking changes to public APIs; get_serialized_dag_model() and get_dag() signatures preserved

Related

@kaxilkaxil added the full tests needed We need to run full set of tests for this PR to merge label Jan 20, 2026
@kaxil
kaxilforce-pushed the add-api-server-dag-cache branch from 795bfb1 to 8a1f7fdCompareJanuary 20, 2026 18:05
@kaxil
kaxil marked this pull request as ready for review January 20, 2026 18:05
@kaxilkaxil added this to the Airflow 3.2.0 milestone Jan 20, 2026

@jason810496jason810496 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement! LGTM overall.

With 100+ DAGs updating daily, memory grows ~500 MB/day, eventually causing OOM.

It’s surprising that the API server without cache eviction can cause system instability.

Comment threadairflow-core/src/airflow/models/dagbag.py
Comment threadairflow-core/src/airflow/models/dagbag.py
Comment threadairflow-core/src/airflow/models/dagbag.py Outdated
Comment threadairflow-core/tests/unit/api_fastapi/common/test_dagbag.py
Comment threadairflow-core/tests/unit/models/test_dagbag.py Outdated
@potiuk

potiuk commented Jan 21, 2026

Copy link
Copy Markdown
Member

It’s surprising that the API server without cache eviction can cause system instability.

One question. In gunicorn in Airflow 2 we had a way simpler solution. Simply the uvicorn servers have restarted every few (tens?) of minutes or every N requests - effectively cleaning the cache and also getting rid of some other side effects (and for example reloading UI plugins). Since api-server (except the cache) is essentially stateless, that did not have almost any negative side effects - except some load caused on the startup time and database refreshing happening then, but that's not much different than the caching implemented here provides.

Additionally that approach was far more "resilient" to any kinds of accumulation-type bugs, yes it was hiding them as well, but the overall stability and resilience to any kind of mistakes made with memory usage, or side-effects of imports or global state sharung was eventually high-up.

This approach is named "software rejuvenation" https://ieeexplore.ieee.org/document/466961 - there are some studies and recommendations to use it as it is effectively way more resilient and in complex systems it allows to handle much wide range of issues.

Maybe we should explore that as well (or instead) - I am not sure if fast-api/starlette has similar concept, but in case of all kinds of stateless webserves, the technique of restarting them gracefully while load-balancing requests has a long proven history.

Should we possibly do it instead of caching LRU/TTL ? That seems way more robust if this is easy and supported by Fast API

@kaxil

Copy link
Copy Markdown
MemberAuthor

One question. In gunicorn in Airflow 2 we had a way simpler solution. Simply the uvicorn servers have restarted every few (tens?) of minutes or every N requests - effectively cleaning the cache and also getting rid of some other side effects (and for example reloading UI plugins). Since api-server (except the cache) is essentially stateless, that did not have almost any negative side effects - except some load caused on the startup time and database refreshing happening then, but that's not much different than the caching implemented here provides.

Good idea, worth trying that out too. Marking this as draft to playaround with it

@kaxil

Copy link
Copy Markdown
MemberAuthor

@potiuk Alternate approach is here in #60919 which uses pure uvicorn signals to increment/decrement workers.

Limitations:

  • workers=1 needs workaround (briefly scales to 2 during refresh)
  • uvicorn's SIGTTOU kills newest worker (LIFO) unlike gunicorn which kills oldest (FIFO), so we send SIGTERM directly to old PIDs instead

The LIFO thing is worth noting since it's a non-obvious difference between uvicorn and gunicorn that anyone else looking at this would run into.

@kaxil

Copy link
Copy Markdown
MemberAuthor

Another alternative using gunicorn is in #60940

@kaxil

kaxil commented Jan 22, 2026

Copy link
Copy Markdown
MemberAuthor

Worth now comparing them side-by-side -- and will let other review all 3 of them. Will check back next week.

Based on trying it out, I don't like #60919 for gotcha's mentioned in that PR (and in the comment above).

#60940 is what I prefer as there are no such limitatons and we get benefits of gunicorn and uvicorn (gunicorn for worker lifeycle management, preload etc and uvicorn for async perf). We could pair it with Helm chart changes to cycle it after X duration and/or this PR.

@kaxil
kaxilforce-pushed the add-api-server-dag-cache branch from 8a1f7fd to a6fd815CompareJanuary 28, 2026 19:26
@shahar1shahar1 mentioned this pull request Feb 12, 2026
2 tasks
Comment threadairflow-core/newsfragments/60804.feature.rst

@dheerajturagadheerajturaga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Thanks for this!

@dheerajturaga

Copy link
Copy Markdown
Member

One reason why I ended up here was some false dag version increments due to dag meta data changes. Also some dags used "datetime.now()" as start date rather than a fixed startdate.

@dheerajturaga We should fix those underlying issue too. Could you create GH issue with what metadata is causing false increase? and for datetime.now --> if it is for start date -- that should never be used like that

I did do a deep dive into this. All of it was from datetime.now in start date.
Many of our dags just need a simple cron and start date in that scenario is largely meaningless so users just put something in there to get by

@pierrejeambrunpierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few questions/suggestion but looking good overall.

I'm a big confused by the use_cache logic. I would expect no caching if use_cache is not enabled, but it looks like it's controlling logging but some paths seem to still be leveraging cache even if use_cache=False

Comment threadairflow-core/src/airflow/config_templates/config.yml Outdated
Comment threadairflow-core/src/airflow/config_templates/config.yml Outdated
Comment threadairflow-core/docs/faq.rst Outdated
Comment threaduv.lock
Comment threadairflow-core/src/airflow/models/dagbag.py Outdated

@pierrejeambrunpierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! LGTM

kaxil added 6 commits April 17, 2026 02:31
The API server's DBDagBag uses an unbounded dict to cache SerializedDAG
objects (5-50 MB each). This cache never expires and never evicts,
causing memory to grow indefinitely as DAG versions accumulate. With
100+ DAGs updating daily, memory grows ~500 MB/day.
This adds optional LRU+TTL caching controlled by two new [api] config
options: dag_cache_size (default 64) and dag_cache_ttl (default 3600s).
Key design decisions:
- API server only: the scheduler continues using a plain unbounded dict
with no lock overhead (nullcontext instead of RLock).
- Cache thrashing prevention: iter_all_latest_version_dags() bypasses
the cache entirely so DAG listing endpoints don't evict the hot
working set.
- Double-checked locking: concurrent cache misses on the same version_id
only query the DB once; the second thread finds it cached.
- Separate model cache: get_serialized_dag_model() keeps its own dict
cache for the triggerer, which needs the full SerializedDagModel (for
.data), not just the deserialized SerializedDAG.
- Thread-safe via RLock when bounded caching is enabled. cachetools
LRUCache/TTLCache are not thread-safe (LRU reordering and TTL cleanup
mutate internal linked lists).
Configuration:
[api]
dag_cache_size = 64 # 0 to disable
dag_cache_ttl = 3600 # seconds, 0 for LRU-only
The cache is keyed by DAG version ID. Lookups by dag_id (e.g., viewing
a DAG's details) always query the DB for the latest version, but the
deserialized result is cached for subsequent version-specific lookups.
After a DAG update, the API server may serve the previous version until
the cached entry expires (controlled by dag_cache_ttl).
- Add dag_cache_size and dag_cache_ttl to web-stack.rst config options
- Mention bounded DAG caching in Kubernetes uvicorn section
- Add newsfragment for PR apache#60804
An unbounded dict in a PR that fixes unbounded memory growth is a
contradiction. The triggerer (the only caller of get_serialized_dag_model)
creates a fresh DBDagBag per batch anyway, so the within-batch
deduplication benefit is marginal. Always query the DB instead.
@kaxil
kaxilforce-pushed the add-api-server-dag-cache branch from c49bd17 to dcb2ab5CompareApril 17, 2026 01:31
Comment threadairflow-core/tests/unit/api_fastapi/common/test_dagbag.py Outdated
Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
@kaxil
kaxil merged commit 26cbdcb into apache:mainApr 17, 2026
7 checks passed
@kaxil
kaxil deleted the add-api-server-dag-cache branch April 17, 2026 02:41
@vatsrahul1001

Copy link
Copy Markdown
Contributor

backport PR up for review #66862

vatsrahul1001 added a commit that referenced this pull request May 15, 2026
#66862)
Fixes memory growth in long-running API servers by adding bounded LRU+TTL caching to `DBDagBag`. Previously, the internal dict cache never expired and never evicted, causing memory to grow indefinitely as DAG versions accumulated (~500 MB/day with 100+ DAGs updating daily).
Two new `[api]` config options control caching:
| Config | Default | Description |
|--------|---------|-------------|
| `dag_cache_size` | `64` | Max cached DAG versions (0 = unbounded dict, no eviction) |
| `dag_cache_ttl` | `3600` | TTL in seconds (0 = LRU only, no time-based expiry) |
**API server only.** The scheduler continues using a plain unbounded dict with zero lock overhead (`nullcontext` instead of `RLock`). The bounded cache + lock is only created when `cache_size > 0`.
**Cache thrashing prevention.** `iter_all_latest_version_dags()` (used by the DAG listing endpoint) bypasses the cache entirely. Without this, every DAG listing request would flush the hot working set and replace it with a full scan of all DAGs.
**Double-checked locking.** When multiple threads miss on the same `version_id` concurrently, only the first thread queries the DB. The rest find it cached after acquiring the lock. Metrics are emitted correctly: a single lookup never counts as both a hit and a miss.
**Separate model cache.** `get_serialized_dag_model()` maintains its own dict cache. The triggerer needs the full `SerializedDagModel` (for `.data`), not the deserialized `SerializedDAG` stored in the LRU/TTL cache.
**Cache keying.** The cache is keyed by DAG version ID. Lookups by `dag_id` (e.g., viewing a DAG's details) always query the DB for the latest version, but the deserialized result is cached for subsequent version-specific lookups (e.g., task instance views for a specific DAG run).
**Staleness.** After a DAG is updated, the API server may serve the previous version until the cached entry expires (controlled by `dag_cache_ttl`). This is documented in the config description.
**Why `cachetools`.** `cachetools` is a small, pure-Python library (~1K LOC) already present as a transitive dependency via `google-auth`. It provides battle-tested `LRUCache` and `TTLCache` implementations. Pinned at `>=6.0.0` to match the FAB provider.
**Why `RLock`.** `cachetools` caches are NOT thread-safe -- `.get()` mutates internal doubly-linked lists (LRU reordering) and TTL access triggers cleanup. Without synchronization, concurrent access can corrupt the data structure.
| Metric | Type | Description |
|--------|------|-------------|
| `api_server.dag_bag.cache_hit` | Counter | Cache hits (including double-checked locking hits) |
| `api_server.dag_bag.cache_miss` | Counter | Confirmed misses (after double-check) |
| `api_server.dag_bag.cache_clear` | Counter | Cache clears |
| `api_server.dag_bag.cache_size` | Gauge | Current cache size (sampled at 10%) |
- Default behavior unchanged for scheduler and triggerer (unbounded dict, no lock)
- API server gets caching by default (`dag_cache_size=64`, `dag_cache_ttl=3600`)
- Use `dag_cache_size=0` to restore pre-change behavior (unbounded dict)
- No breaking changes to public APIs; `get_serialized_dag_model()` and `get_dag()` signatures preserved
- #64326 (closed) -- similar fix with OrderedDict-based LRU, no TTL
- #60940 (merged) -- gunicorn support with rolling worker restarts (complementary, handles memory growth from any source)
(cherry picked from commit 26cbdcb)
Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
vatsrahul1001 added a commit that referenced this pull request May 20, 2026
#66862)
Fixes memory growth in long-running API servers by adding bounded LRU+TTL caching to `DBDagBag`. Previously, the internal dict cache never expired and never evicted, causing memory to grow indefinitely as DAG versions accumulated (~500 MB/day with 100+ DAGs updating daily).
Two new `[api]` config options control caching:
| Config | Default | Description |
|--------|---------|-------------|
| `dag_cache_size` | `64` | Max cached DAG versions (0 = unbounded dict, no eviction) |
| `dag_cache_ttl` | `3600` | TTL in seconds (0 = LRU only, no time-based expiry) |
**API server only.** The scheduler continues using a plain unbounded dict with zero lock overhead (`nullcontext` instead of `RLock`). The bounded cache + lock is only created when `cache_size > 0`.
**Cache thrashing prevention.** `iter_all_latest_version_dags()` (used by the DAG listing endpoint) bypasses the cache entirely. Without this, every DAG listing request would flush the hot working set and replace it with a full scan of all DAGs.
**Double-checked locking.** When multiple threads miss on the same `version_id` concurrently, only the first thread queries the DB. The rest find it cached after acquiring the lock. Metrics are emitted correctly: a single lookup never counts as both a hit and a miss.
**Separate model cache.** `get_serialized_dag_model()` maintains its own dict cache. The triggerer needs the full `SerializedDagModel` (for `.data`), not the deserialized `SerializedDAG` stored in the LRU/TTL cache.
**Cache keying.** The cache is keyed by DAG version ID. Lookups by `dag_id` (e.g., viewing a DAG's details) always query the DB for the latest version, but the deserialized result is cached for subsequent version-specific lookups (e.g., task instance views for a specific DAG run).
**Staleness.** After a DAG is updated, the API server may serve the previous version until the cached entry expires (controlled by `dag_cache_ttl`). This is documented in the config description.
**Why `cachetools`.** `cachetools` is a small, pure-Python library (~1K LOC) already present as a transitive dependency via `google-auth`. It provides battle-tested `LRUCache` and `TTLCache` implementations. Pinned at `>=6.0.0` to match the FAB provider.
**Why `RLock`.** `cachetools` caches are NOT thread-safe -- `.get()` mutates internal doubly-linked lists (LRU reordering) and TTL access triggers cleanup. Without synchronization, concurrent access can corrupt the data structure.
| Metric | Type | Description |
|--------|------|-------------|
| `api_server.dag_bag.cache_hit` | Counter | Cache hits (including double-checked locking hits) |
| `api_server.dag_bag.cache_miss` | Counter | Confirmed misses (after double-check) |
| `api_server.dag_bag.cache_clear` | Counter | Cache clears |
| `api_server.dag_bag.cache_size` | Gauge | Current cache size (sampled at 10%) |
- Default behavior unchanged for scheduler and triggerer (unbounded dict, no lock)
- API server gets caching by default (`dag_cache_size=64`, `dag_cache_ttl=3600`)
- Use `dag_cache_size=0` to restore pre-change behavior (unbounded dict)
- No breaking changes to public APIs; `get_serialized_dag_model()` and `get_dag()` signatures preserved
- #64326 (closed) -- similar fix with OrderedDict-based LRU, no TTL
- #60940 (merged) -- gunicorn support with rolling worker restarts (complementary, handles memory growth from any source)
(cherry picked from commit 26cbdcb)
Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
vatsrahul1001 added a commit that referenced this pull request May 20, 2026
#66862)
Fixes memory growth in long-running API servers by adding bounded LRU+TTL caching to `DBDagBag`. Previously, the internal dict cache never expired and never evicted, causing memory to grow indefinitely as DAG versions accumulated (~500 MB/day with 100+ DAGs updating daily).
Two new `[api]` config options control caching:
| Config | Default | Description |
|--------|---------|-------------|
| `dag_cache_size` | `64` | Max cached DAG versions (0 = unbounded dict, no eviction) |
| `dag_cache_ttl` | `3600` | TTL in seconds (0 = LRU only, no time-based expiry) |
**API server only.** The scheduler continues using a plain unbounded dict with zero lock overhead (`nullcontext` instead of `RLock`). The bounded cache + lock is only created when `cache_size > 0`.
**Cache thrashing prevention.** `iter_all_latest_version_dags()` (used by the DAG listing endpoint) bypasses the cache entirely. Without this, every DAG listing request would flush the hot working set and replace it with a full scan of all DAGs.
**Double-checked locking.** When multiple threads miss on the same `version_id` concurrently, only the first thread queries the DB. The rest find it cached after acquiring the lock. Metrics are emitted correctly: a single lookup never counts as both a hit and a miss.
**Separate model cache.** `get_serialized_dag_model()` maintains its own dict cache. The triggerer needs the full `SerializedDagModel` (for `.data`), not the deserialized `SerializedDAG` stored in the LRU/TTL cache.
**Cache keying.** The cache is keyed by DAG version ID. Lookups by `dag_id` (e.g., viewing a DAG's details) always query the DB for the latest version, but the deserialized result is cached for subsequent version-specific lookups (e.g., task instance views for a specific DAG run).
**Staleness.** After a DAG is updated, the API server may serve the previous version until the cached entry expires (controlled by `dag_cache_ttl`). This is documented in the config description.
**Why `cachetools`.** `cachetools` is a small, pure-Python library (~1K LOC) already present as a transitive dependency via `google-auth`. It provides battle-tested `LRUCache` and `TTLCache` implementations. Pinned at `>=6.0.0` to match the FAB provider.
**Why `RLock`.** `cachetools` caches are NOT thread-safe -- `.get()` mutates internal doubly-linked lists (LRU reordering) and TTL access triggers cleanup. Without synchronization, concurrent access can corrupt the data structure.
| Metric | Type | Description |
|--------|------|-------------|
| `api_server.dag_bag.cache_hit` | Counter | Cache hits (including double-checked locking hits) |
| `api_server.dag_bag.cache_miss` | Counter | Confirmed misses (after double-check) |
| `api_server.dag_bag.cache_clear` | Counter | Cache clears |
| `api_server.dag_bag.cache_size` | Gauge | Current cache size (sampled at 10%) |
- Default behavior unchanged for scheduler and triggerer (unbounded dict, no lock)
- API server gets caching by default (`dag_cache_size=64`, `dag_cache_ttl=3600`)
- Use `dag_cache_size=0` to restore pre-change behavior (unbounded dict)
- No breaking changes to public APIs; `get_serialized_dag_model()` and `get_dag()` signatures preserved
- #64326 (closed) -- similar fix with OrderedDict-based LRU, no TTL
- #60940 (merged) -- gunicorn support with rolling worker restarts (complementary, handles memory growth from any source)
(cherry picked from commit 26cbdcb)
Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
vatsrahul1001 added a commit that referenced this pull request May 21, 2026
#66862)
Fixes memory growth in long-running API servers by adding bounded LRU+TTL caching to `DBDagBag`. Previously, the internal dict cache never expired and never evicted, causing memory to grow indefinitely as DAG versions accumulated (~500 MB/day with 100+ DAGs updating daily).
Two new `[api]` config options control caching:
| Config | Default | Description |
|--------|---------|-------------|
| `dag_cache_size` | `64` | Max cached DAG versions (0 = unbounded dict, no eviction) |
| `dag_cache_ttl` | `3600` | TTL in seconds (0 = LRU only, no time-based expiry) |
**API server only.** The scheduler continues using a plain unbounded dict with zero lock overhead (`nullcontext` instead of `RLock`). The bounded cache + lock is only created when `cache_size > 0`.
**Cache thrashing prevention.** `iter_all_latest_version_dags()` (used by the DAG listing endpoint) bypasses the cache entirely. Without this, every DAG listing request would flush the hot working set and replace it with a full scan of all DAGs.
**Double-checked locking.** When multiple threads miss on the same `version_id` concurrently, only the first thread queries the DB. The rest find it cached after acquiring the lock. Metrics are emitted correctly: a single lookup never counts as both a hit and a miss.
**Separate model cache.** `get_serialized_dag_model()` maintains its own dict cache. The triggerer needs the full `SerializedDagModel` (for `.data`), not the deserialized `SerializedDAG` stored in the LRU/TTL cache.
**Cache keying.** The cache is keyed by DAG version ID. Lookups by `dag_id` (e.g., viewing a DAG's details) always query the DB for the latest version, but the deserialized result is cached for subsequent version-specific lookups (e.g., task instance views for a specific DAG run).
**Staleness.** After a DAG is updated, the API server may serve the previous version until the cached entry expires (controlled by `dag_cache_ttl`). This is documented in the config description.
**Why `cachetools`.** `cachetools` is a small, pure-Python library (~1K LOC) already present as a transitive dependency via `google-auth`. It provides battle-tested `LRUCache` and `TTLCache` implementations. Pinned at `>=6.0.0` to match the FAB provider.
**Why `RLock`.** `cachetools` caches are NOT thread-safe -- `.get()` mutates internal doubly-linked lists (LRU reordering) and TTL access triggers cleanup. Without synchronization, concurrent access can corrupt the data structure.
| Metric | Type | Description |
|--------|------|-------------|
| `api_server.dag_bag.cache_hit` | Counter | Cache hits (including double-checked locking hits) |
| `api_server.dag_bag.cache_miss` | Counter | Confirmed misses (after double-check) |
| `api_server.dag_bag.cache_clear` | Counter | Cache clears |
| `api_server.dag_bag.cache_size` | Gauge | Current cache size (sampled at 10%) |
- Default behavior unchanged for scheduler and triggerer (unbounded dict, no lock)
- API server gets caching by default (`dag_cache_size=64`, `dag_cache_ttl=3600`)
- Use `dag_cache_size=0` to restore pre-change behavior (unbounded dict)
- No breaking changes to public APIs; `get_serialized_dag_model()` and `get_dag()` signatures preserved
- #64326 (closed) -- similar fix with OrderedDict-based LRU, no TTL
- #60940 (merged) -- gunicorn support with rolling worker restarts (complementary, handles memory growth from any source)
(cherry picked from commit 26cbdcb)
Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:APIAirflow's REST/HTTP APIarea:ConfigTemplatesarea:task-sdkfull tests neededWe need to run full set of tests for this PR to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@kaxil@potiuk@pierrejeambrun@shivaam@dheerajturaga@vatsrahul1001@jason810496@jscheffl