Skip to content

Commit 1109c56

Browse files
jason810496Copilot
andcommitted
Add CLI hot-reload support via --dev flag (apache#57741)
* Add watchfiles dependency and hot-reload utility with --dev flag support Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Add tests for --dev flag and hot-reload functionality Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Fix watchfiles API usage with proper DefaultFilter Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Address code review feedback: fix help text, API usage, and improve error messages Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Final code review fixes: improve logging and test clarity Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Refactor hot_reload utils * Refactor hot-reload: move to cli module, remove pyproject change, add dag-processor support Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Fix serve-log teardown issue using psutil to terminate process tree Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Refactor hot_reload: extract _terminate_process_tree helper function Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Add type annotations and process_name parameter to hot_reload Co-authored-by: jason810496 <68415893+jason810496@users.noreply.github.com> * Refactor final nits - remove logger in triggerer command - ensure type annotation in hot-reload module - fix test hot-reload * Fix mypy error * Respect DEV_MODE env var * Fix nits --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent a104aae commit 1109c56

12 files changed

Lines changed: 393 additions & 2 deletions

airflow-core/src/airflow/cli/cli_config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ def string_lower_type(val):
669669
default=conf.get("api", "ssl_key"),
670670
help="Path to the key to use with the SSL certificate",
671671
)
672-
ARG_DEV = Arg(("-d", "--dev"), help="Start FastAPI in development mode", action="store_true")
672+
ARG_DEV = Arg(("-d", "--dev"), help="Start in development mode with hot-reload enabled", action="store_true")
673673

674674
# scheduler
675675
ARG_NUM_RUNS = Arg(
@@ -1923,6 +1923,7 @@ class GroupCommand(NamedTuple):
19231923
ARG_LOG_FILE,
19241924
ARG_SKIP_SERVE_LOGS,
19251925
ARG_VERBOSE,
1926+
ARG_DEV,
19261927
),
19271928
epilog=(
19281929
"Signals:\n"
@@ -1946,6 +1947,7 @@ class GroupCommand(NamedTuple):
19461947
ARG_CAPACITY,
19471948
ARG_VERBOSE,
19481949
ARG_SKIP_SERVE_LOGS,
1950+
ARG_DEV,
19491951
),
19501952
),
19511953
ActionCommand(
@@ -1961,6 +1963,7 @@ class GroupCommand(NamedTuple):
19611963
ARG_STDERR,
19621964
ARG_LOG_FILE,
19631965
ARG_VERBOSE,
1966+
ARG_DEV,
19641967
),
19651968
),
19661969
ActionCommand(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def api_server(args: Namespace):
139139

140140
get_signing_args()
141141

142-
if args.dev:
142+
if cli_utils.should_enable_hot_reload(args):
143143
print(f"Starting the API server on port {args.port} and host {args.host} in development mode.")
144144
log.warning("Running in dev mode, ignoring uvicorn args")
145145
from fastapi_cli.cli import _run

airflow-core/src/airflow/cli/commands/dag_processor_command.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ def dag_processor(args):
5252
"""Start Airflow Dag Processor Job."""
5353
job_runner = _create_dag_processor_job_runner(args)
5454

55+
if cli_utils.should_enable_hot_reload(args):
56+
from airflow.cli.hot_reload import run_with_reloader
57+
58+
run_with_reloader(
59+
lambda: run_job(job=job_runner.job, execute_callable=job_runner._execute),
60+
process_name="dag-processor",
61+
)
62+
return
63+
5564
run_command_with_daemon_option(
5665
args=args,
5766
process_name="dag-processor",

airflow-core/src/airflow/cli/commands/scheduler_command.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ def scheduler(args: Namespace):
5151
"""Start Airflow Scheduler."""
5252
print(settings.HEADER)
5353

54+
if cli_utils.should_enable_hot_reload(args):
55+
from airflow.cli.hot_reload import run_with_reloader
56+
57+
run_with_reloader(lambda: _run_scheduler_job(args), process_name="scheduler")
58+
return
59+
5460
run_command_with_daemon_option(
5561
args=args,
5662
process_name="scheduler",

airflow-core/src/airflow/cli/commands/triggerer_command.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ def triggerer(args):
6666
print(settings.HEADER)
6767
triggerer_heartrate = conf.getfloat("triggerer", "JOB_HEARTBEAT_SEC")
6868

69+
if cli_utils.should_enable_hot_reload(args):
70+
from airflow.cli.hot_reload import run_with_reloader
71+
72+
run_with_reloader(
73+
lambda: triggerer_run(args.skip_serve_logs, args.capacity, triggerer_heartrate),
74+
process_name="triggerer",
75+
)
76+
return
77+
6978
run_command_with_daemon_option(
7079
args=args,
7180
process_name="triggerer",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
"""Hot reload utilities for development mode."""
19+
20+
from __future__ import annotations
21+
22+
import os
23+
import signal
24+
import sys
25+
from collections.abc import Callable, Sequence
26+
from pathlib import Path
27+
from typing import TYPE_CHECKING
28+
29+
import structlog
30+
31+
if TYPE_CHECKING:
32+
import subprocess
33+
34+
log = structlog.getLogger(__name__)
35+
36+
37+
def run_with_reloader(
38+
callback: Callable,
39+
process_name: str = "process",
40+
) -> None:
41+
"""
42+
Run a callback function with automatic reloading on file changes.
43+
44+
This function monitors specified paths for changes and restarts the process
45+
when changes are detected. Useful for development mode hot-reloading.
46+
47+
:param callback: The function to run. This should be the main entry point
48+
of the command that needs hot-reload support.
49+
:param process_name: Name of the process being run (for logging purposes)
50+
"""
51+
# Default watch paths - watch the airflow source directory
52+
import airflow
53+
54+
airflow_root = Path(airflow.__file__).parent
55+
watch_paths = [airflow_root]
56+
57+
log.info("Starting %s in development mode with hot-reload enabled", process_name)
58+
log.info("Watching paths: %s", watch_paths)
59+
60+
# Check if we're the main process or a reloaded child
61+
reloader_pid = os.environ.get("AIRFLOW_DEV_RELOADER_PID")
62+
if reloader_pid is None:
63+
# We're the main process - set up the reloader
64+
os.environ["AIRFLOW_DEV_RELOADER_PID"] = str(os.getpid())
65+
_run_reloader(watch_paths)
66+
else:
67+
# We're a child process - just run the callback
68+
callback()
69+
70+
71+
def _terminate_process_tree(
72+
process: subprocess.Popen[bytes],
73+
timeout: int = 5,
74+
force_kill_remaining: bool = True,
75+
) -> None:
76+
"""
77+
Terminate a process and all its children recursively.
78+
79+
Uses psutil to ensure all child processes are properly terminated,
80+
which is important for cleaning up subprocesses like serve-log servers.
81+
82+
:param process: The subprocess.Popen process to terminate
83+
:param timeout: Timeout in seconds to wait for graceful termination
84+
:param force_kill_remaining: If True, force kill processes that don't terminate gracefully
85+
"""
86+
import subprocess
87+
88+
import psutil
89+
90+
try:
91+
parent = psutil.Process(process.pid)
92+
# Get all child processes recursively
93+
children = parent.children(recursive=True)
94+
95+
# Terminate all children first
96+
for child in children:
97+
try:
98+
child.terminate()
99+
except (psutil.NoSuchProcess, psutil.AccessDenied):
100+
pass
101+
102+
# Terminate the parent
103+
parent.terminate()
104+
105+
# Wait for all processes to terminate
106+
gone, alive = psutil.wait_procs(children + [parent], timeout=timeout)
107+
108+
# Force kill any remaining processes if requested
109+
if force_kill_remaining:
110+
for proc in alive:
111+
try:
112+
log.warning("Force killing process %s", proc.pid)
113+
proc.kill()
114+
except (psutil.NoSuchProcess, psutil.AccessDenied):
115+
pass
116+
117+
except (psutil.NoSuchProcess, psutil.AccessDenied):
118+
# Process already terminated
119+
pass
120+
except Exception as e:
121+
log.warning("Error terminating process tree: %s", e)
122+
# Fallback to simple termination
123+
try:
124+
process.terminate()
125+
process.wait(timeout=timeout)
126+
except subprocess.TimeoutExpired:
127+
if force_kill_remaining:
128+
log.warning("Process did not terminate gracefully, killing...")
129+
process.kill()
130+
process.wait()
131+
132+
133+
def _run_reloader(watch_paths: Sequence[str | Path]) -> None:
134+
"""
135+
Watch for changes and restart the process.
136+
137+
Watches the provided paths and restarts the process by re-executing the
138+
Python interpreter with the same arguments.
139+
140+
:param watch_paths: List of paths to watch for changes.
141+
"""
142+
import subprocess
143+
144+
from watchfiles import watch
145+
146+
process = None
147+
should_exit = False
148+
149+
def start_process():
150+
"""Start or restart the subprocess."""
151+
nonlocal process
152+
if process is not None:
153+
log.info("Stopping process and all its children...")
154+
_terminate_process_tree(process, timeout=5, force_kill_remaining=True)
155+
156+
log.info("Starting process...")
157+
# Restart the process by re-executing Python with the same arguments
158+
# Note: sys.argv is safe here as it comes from the original CLI invocation
159+
# and is only used in development mode for hot-reloading the same process
160+
process = subprocess.Popen([sys.executable] + sys.argv)
161+
return process
162+
163+
def signal_handler(signum, frame):
164+
"""Handle termination signals."""
165+
nonlocal should_exit, process
166+
should_exit = True
167+
log.info("Received signal %s, shutting down...", signum)
168+
if process:
169+
_terminate_process_tree(process, timeout=5, force_kill_remaining=False)
170+
sys.exit(0)
171+
172+
# Set up signal handlers
173+
signal.signal(signal.SIGINT, signal_handler)
174+
signal.signal(signal.SIGTERM, signal_handler)
175+
176+
# Start the initial process
177+
process = start_process()
178+
179+
log.info("Hot-reload enabled. Watching for file changes...")
180+
log.info("Press Ctrl+C to stop")
181+
182+
try:
183+
for changes in watch(*watch_paths):
184+
if should_exit:
185+
break
186+
187+
log.info("Detected changes: %s", changes)
188+
log.info("Reloading...")
189+
190+
# Restart the process
191+
process = start_process()
192+
193+
except KeyboardInterrupt:
194+
log.info("Shutting down...")
195+
if process:
196+
process.terminate()
197+
process.wait()

airflow-core/src/airflow/utils/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,10 @@ def validate_dag_bundle_arg(bundle_names: list[str]) -> None:
472472
unknown_bundles: set[str] = set(bundle_names) - known_bundles
473473
if unknown_bundles:
474474
raise SystemExit(f"Bundles not found: {', '.join(unknown_bundles)}")
475+
476+
477+
def should_enable_hot_reload(args) -> bool:
478+
"""Check whether hot-reload should be enabled based on --dev flag or DEV_MODE env var."""
479+
if getattr(args, "dev", False):
480+
return True
481+
return os.getenv("DEV_MODE", "false").lower() == "true"

airflow-core/tests/unit/cli/commands/test_dag_processor_command.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,14 @@ def test_bundle_names_passed(self, mock_runner, configure_testing_dag_bundle):
5656
with configure_testing_dag_bundle(os.devnull):
5757
dag_processor_command.dag_processor(args)
5858
assert mock_runner.call_args.kwargs["processor"].bundle_names_to_parse == ["testing"]
59+
60+
@mock.patch("airflow.cli.hot_reload.run_with_reloader")
61+
def test_dag_processor_with_dev_flag(self, mock_reloader):
62+
"""Ensure that dag-processor with --dev flag uses hot-reload"""
63+
args = self.parser.parse_args(["dag-processor", "--dev"])
64+
dag_processor_command.dag_processor(args)
65+
66+
# Verify that run_with_reloader was called
67+
mock_reloader.assert_called_once()
68+
# The callback function should be callable
69+
assert callable(mock_reloader.call_args[0][0])

airflow-core/tests/unit/cli/commands/test_scheduler_command.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,13 @@ def test_run_job_exception_handling(self, mock_run_job, mock_process, mock_sched
163163
)
164164
mock_process.assert_called_once_with(target=serve_logs)
165165
mock_process().terminate.assert_called_once_with()
166+
167+
@mock.patch("airflow.cli.hot_reload.run_with_reloader")
168+
def test_scheduler_with_dev_flag(self, mock_reloader):
169+
args = self.parser.parse_args(["scheduler", "--dev"])
170+
scheduler_command.scheduler(args)
171+
172+
# Verify that run_with_reloader was called
173+
mock_reloader.assert_called_once()
174+
# The callback function should be callable
175+
assert callable(mock_reloader.call_args[0][0])

airflow-core/tests/unit/cli/commands/test_triggerer_command.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,14 @@ def test_trigger_run_serve_logs(self, mock_process, mock_run_job, mock_trigger_j
6363
job=mock_trigger_job_runner.return_value.job,
6464
execute_callable=mock_trigger_job_runner.return_value._execute,
6565
)
66+
67+
@mock.patch("airflow.cli.hot_reload.run_with_reloader")
68+
def test_triggerer_with_dev_flag(self, mock_reloader):
69+
"""Ensure that triggerer with --dev flag uses hot-reload"""
70+
args = self.parser.parse_args(["triggerer", "--dev"])
71+
triggerer_command.triggerer(args)
72+
73+
# Verify that run_with_reloader was called
74+
mock_reloader.assert_called_once()
75+
# The callback function should be callable
76+
assert callable(mock_reloader.call_args[0][0])

0 commit comments

Comments
 (0)