') + ')', '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 gunicorn support for API server with rolling worker restarts by kaxil · Pull Request #60940 · apache/airflow · GitHub
Skip to content

Add gunicorn support for API server with rolling worker restarts - #60940

Merged
kaxil merged 3 commits into
apache:mainfrom
astronomer:gunicorn-api-server
Feb 4, 2026
Merged

Add gunicorn support for API server with rolling worker restarts#60940
kaxil merged 3 commits into
apache:mainfrom
astronomer:gunicorn-api-server

Conversation

@kaxil

@kaxilkaxil commented Jan 22, 2026

Copy link
Copy Markdown
Member

Related #60804 & #60919

This PR adds an optional gunicorn server type for the API server, providing:

  • Memory sharing: Gunicorn uses preload + fork, so workers share memory via copy-on-write (unlike uvicorn's multiprocess mode where each worker loads everything independently)
  • Rolling worker restarts: Custom Arbiter performs zero-downtime worker recycling to prevent memory accumulation
  • Proper signal handling: SIGTTOU kills oldest worker (FIFO), enabling true rolling restarts

Memory Impact

With --preload, gunicorn loads the application once in the arbiter process, then forks workers. Workers share read-only memory pages via copy-on-write:

ConfigurationEstimated Memory Usage
Uvicorn 4 workers~600 MB (4 × 150 MB, each loads independently)
Gunicorn 4 workers~300-350 MB (shared base + ~50 MB per worker for unique pages)

Savings: ~40-50% memory reduction with 4 workers. Benefits scale with worker count.

Usage

# Enable gunicorn modeexport AIRFLOW__API__SERVER_TYPE=gunicorn
export AIRFLOW__API__WORKER_REFRESH_INTERVAL=43200 # 12 hours
airflow api-server

Configuration

New [api] configuration options:

  • server_type: uvicorn (default) or gunicorn
  • worker_refresh_interval: Seconds between worker refresh cycles (0 = disabled)
  • worker_refresh_batch_size: Workers to refresh per cycle (default: 1)

Architecture

┌─────────────────────────────────────────────────────────┐
│ airflow api-server (gunicorn via Python API) │
│ └── AirflowArbiter (custom Arbiter with monitoring) │
│ ├── worker 1 (UvicornWorker) │
│ ├── worker 2 (UvicornWorker) │
│ └── worker N (UvicornWorker) │
└─────────────────────────────────────────────────────────┘

Uses gunicorn's recommended extension pattern: custom AirflowArbiter subclass integrates worker monitoring directly into the arbiter loop via manage_workers(). No separate thread or subprocess needed.

Rolling Restart Flow

  1. Spawn batch_size new workers (spawn_worker())
  2. Wait for workers to reach target count
  3. Kill batch_size old workers (kill_worker() - kills oldest via FIFO)
  4. Repeat until all original workers replaced

Zero-downtime is guaranteed because new workers are spawned before old workers are killed.

Why Gunicorn Over Uvicorn Multiprocess?

AspectGunicornUvicorn
Memory sharingYes (preload + fork COW)No (independent workers)
Rolling restartsYes (SIGTTOU kills oldest - FIFO)No (SIGTTOU kills newest - LIFO)
Worker managementAlways has arbiter processNo arbiter with workers=1
macOS supportLimited (setproctitle issues)Full

The key difference: Uvicorn's SIGTTOU kills the newest worker (LIFO), while Gunicorn kills the oldest (FIFO). Rolling restarts require killing old workers, not new ones.

Why Gunicorn is Optional

Gunicorn is an optional extra (apache-airflow-core[gunicorn]) because:

  1. Windows incompatibility: Gunicorn is Unix-only
  2. Most users don't need it: Default uvicorn is sufficient for development and simple production setups
  3. Different trade-offs: Some users prefer uvicorn's simplicity

imageimage

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds optional gunicorn support to the API server, enabling zero-downtime worker recycling to prevent memory accumulation in long-running processes. The implementation uses a GunicornMonitor to perform rolling restarts while maintaining service availability.

Changes:

  • Added gunicorn as an optional server type alongside the default uvicorn
  • Implemented GunicornMonitor for zero-downtime worker recycling with configurable refresh intervals
  • Added configuration options for server type, worker refresh intervals, and batch sizes

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
pyproject.tomlAdded gunicorn extra to main package dependencies
airflow-core/pyproject.tomlAdded gunicorn as an optional dependency with version constraint
docs/spelling_wordlist.txtAdded "multiprocess" to spelling dictionary
airflow-core/src/airflow/config_templates/config.ymlAdded server_type, worker_refresh_interval, and worker_refresh_batch_size configuration options
airflow-core/src/airflow/settings.pyAdded GUNICORN_WORKER_READY_PREFIX constant for process title tracking
airflow-core/src/airflow/cli/commands/gunicorn_monitor.pyImplemented GunicornMonitor class for rolling worker restarts
airflow-core/src/airflow/cli/commands/api_server_command.pyAdded gunicorn command building and server type routing logic
airflow-core/src/airflow/api_fastapi/gunicorn_config.pyAdded gunicorn hooks for worker readiness tracking and cleanup
airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.pyComprehensive test coverage for GunicornMonitor functionality
airflow-core/newsfragments/60921.significant.rstRelease notes documenting the new feature
airflow-core/docs/extra-packages-ref.rstDocumentation for gunicorn extra package
airflow-core/docs/administration-and-deployment/web-stack.rstUser guide for server types and rolling worker restarts
.pre-commit-config.yamlAdded new files to pre-commit exclusions
Comments suppressed due to low confidence (1)

airflow-core/src/airflow/cli/commands/api_server_command.py:1

  • Using verify=False disables SSL certificate verification. While the comment notes this is for localhost health checks, consider only disabling verification when the scheme is https and host is localhost/127.0.0.1 to avoid accidentally disabling verification for remote health checks.
# Licensed to the Apache Software Foundation (ASF) under one

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadairflow-core/docs/extra-packages-ref.rst Outdated
Comment threadairflow-core/src/airflow/cli/commands/api_server_command.py
@kaxil
kaxilforce-pushed the gunicorn-api-server branch 3 times, most recently from 884e5d3 to 4b1ca24CompareJanuary 29, 2026 01:21
Comment threadairflow-core/src/airflow/api_fastapi/gunicorn_monitor.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.

Nice I think this is a great addition.

I think it would be cool to have both https://github.com/apache/airflow/pull/60804/changes, in case a particular dagbag grows too big in a shorter time than the lifecycle. (just to make sure it doesn't explode and put a limit there)

@kaxil
kaxilforce-pushed the gunicorn-api-server branch 3 times, most recently from ec264a8 to aa59022CompareJanuary 30, 2026 17:42
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
…onitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
@kaxil
kaxilforce-pushed the gunicorn-api-server branch from 4e0812b to 048b919CompareJanuary 30, 2026 18:39
@kaxilkaxil added this to the Airflow 3.2.0 milestone Jan 30, 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! LGTM overall and the changes make sense to me.

Comment threadairflow-core/newsfragments/60921.significant.rst
@kaxil
kaxil merged commit b906080 into apache:mainFeb 4, 2026
435 of 437 checks passed
@kaxil
kaxil deleted the gunicorn-api-server branch February 4, 2026 09:45
@github-actions

Copy link
Copy Markdown
Contributor

Backport failed to create: v3-1-test. View the failure log Run details

StatusBranchResult
v3-1-testCommit Link

You can attempt to backport this manually by running:

cherry_picker b906080 v3-1-test

This should apply the commit to the v3-1-test branch and leave the commit in conflict state marking
the files that need manual conflict resolution.

After you have resolved the conflicts, you can continue the backport process by running:

cherry_picker --continue

If you don't have cherry-picker installed, see the installation guide.

Alok-kumar-priyadarshi pushed a commit to Alok-kumar-priyadarshi/airflow that referenced this pull request Feb 5, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
jhgoebbert pushed a commit to jhgoebbert/airflow_Owen-CH-Leung that referenced this pull request Feb 8, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
Ratasa143 pushed a commit to Ratasa143/airflow that referenced this pull request Feb 15, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
@pierrejeambrunpierrejeambrun mentioned this pull request Feb 16, 2026
choo121600 pushed a commit to choo121600/airflow that referenced this pull request Feb 22, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
Subham-KRLX pushed a commit to Subham-KRLX/airflow that referenced this pull request Mar 4, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
Ankurdeewan pushed a commit to Ankurdeewan/airflow that referenced this pull request Mar 15, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
radhwene pushed a commit to radhwene/airflow that referenced this pull request Mar 21, 2026
…che#60940)
Add optional gunicorn server type for the API server that provides:
- Memory sharing via preload + fork copy-on-write
- Rolling worker restarts through GunicornMonitor
- Correct FIFO signal handling (SIGTTOU kills oldest worker)
New configuration options in [api] section:
- server_type: uvicorn (default) or gunicorn
- worker_refresh_interval: seconds between refresh cycles (0=disabled)
- worker_refresh_batch_size: workers to refresh per cycle
- master_timeout: gunicorn master timeout
- reload_on_plugin_change: reload on plugin file changes
Requires apache-airflow-core[gunicorn] extra for gunicorn mode.
* Run GunicornMonitor in main thread instead of daemon thread
Matches Airflow 2's webserver pattern: monitor runs in main thread,
so if it crashes, the whole process exits (fail-fast). No silent
degradation where gunicorn keeps running without worker recycling.
Also triggers monitor when reload_on_plugin_change is enabled,
even if worker_refresh_interval is 0.
* Refactor gunicorn support to use custom Arbiter instead of external monitor
This refactor changes the gunicorn worker monitoring architecture from an
external thread-based approach to using a custom Arbiter subclass, which
is gunicorn's recommended extension pattern.
Changes:
- New gunicorn_app.py with AirflowArbiter and AirflowGunicornApp
- AirflowArbiter integrates worker refresh into manage_workers() loop
- Removed gunicorn_monitor.py (no longer needed)
- Simplified api_server_command.py (no subprocess, direct gunicorn API)
- Updated tests for new architecture
Benefits:
- Simpler architecture (no separate thread or subprocess)
- Direct access to worker state via self.WORKERS
- Uses gunicorn's internal spawn_worker/kill_worker methods
- Follows gunicorn's documented extension pattern
@jscheffl

Copy link
Copy Markdown
Contributor

Just came across this - COOL!

kaxil added a commit that referenced this pull request Apr 17, 2026
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) |
## 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
| 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%) |
## 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
- #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)
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@kaxil@jscheffl@ashb@pierrejeambrun@jason810496