fix: adds logging to langflow run command (#8466)

* Clean up initialiation of uv run langflow run

* sigint fixes

* Log level fixes and gunicorn logs removal

* sqlalch contraints

* Add back banner for windows

* [autofix.ci] apply automated fixes

* handle sigint sigterms

* [autofix.ci] apply automated fixes

* handle multiple sigints

* more gracefully handle shutdown during init

* [autofix.ci] apply automated fixes

* swallow cancellation during log flush

* fix path import and cleanup

* [autofix.ci] apply automated fixes

* start with step logs

* 📝 (main.py): Comment out logger.info statement to prevent unnecessary logging of static files directory setup information.

* 💡 (langflow/__main__.py): Comment out logger.info message for initializing Langflow to reduce noise in logs and improve readability.

* uncomment logs, change to debug

* 📝 (langflow): remove redundant logger info messages in signal handlers and shutdown process
♻️ (langflow): refactor shutdown process to use a progress indicator for better visibility and control
♻️ (langflow): refactor initialization process to remove redundant logger info messages
📝 (langflow): add methods to print shutdown summary and farewell message after shutdown is complete
📝 (langflow): add method to create a progress indicator for shutdown steps
📝 (langflow): add predefined shutdown steps for Langflow shutdown process
📝 (langflow): add method to create a progress indicator with predefined Langflow initialization steps
📝 (langflow): add method to create a progress indicator with predefined Langflow shutdown steps
📝 (langflow): add predefined shutdown steps in reverse order of initialization
📝 (langflow): add predefined initialization steps for Langflow initialization process
📝 (langflow): add method to print a summary of all completed shutdown steps

* 📝 (api/v1/__init__.py): reorganize imports and include_router calls for better readability and maintainability
🔧 (api/v1/__init__.py): update __all__ list to explicitly include all routers for better module encapsulation
💡 (utils.py): add logging statement to indicate building custom components from specified paths

* Fix ordering of operations for windows builds

* use lowercase log levels for uvicorn

* converts log to lower if set from env var

* [autofix.ci] apply automated fixes

* use a subprocess for windows so progress step can complete

* [autofix.ci] apply automated fixes

* test subprocess for windows

* use subprocess for all architectures

* Clean up global vars and simplify shutdown

* [autofix.ci] apply automated fixes

* 📝 (progress.py): add platform check to use different characters and icons based on the operating system for better compatibility and user experience

* 📝 (langflow/__main__.py): use platform-specific characters for icons to prevent encoding issues and improve cross-platform compatibility

* test for windows

* remove print

* move windows blocking call outside of progres step

* [autofix.ci] apply automated fixes

* log cleanup and timeout of teardown

* remove seemingly unnecessary redirect

* [autofix.ci] apply automated fixes

* ruff

* Add init file to cli dir

* ruff:
:

* unused import

* mypy fixes

* mypy fixes

* [autofix.ci] apply automated fixes

* ruff

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* Try underscores so ci doesn't remove

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>
This commit is contained in:
Jordan Frazier 2025-06-23 10:57:27 -07:00 committed by GitHub
commit 131322cb9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 6191 additions and 4124 deletions

View file

@ -215,8 +215,8 @@ nv-ingest = [
]
postgresql = [
"sqlalchemy[postgresql_psycopg2binary]",
"sqlalchemy[postgresql_psycopg]",
"sqlalchemy[postgresql_psycopg2binary]>=2.0.38,<3.0.0",
"sqlalchemy[postgresql_psycopg]>=2.0.38,<3.0.0",
]
[project.scripts]

View file

@ -25,6 +25,7 @@ from rich.panel import Panel
from rich.table import Table
from sqlmodel import select
from langflow.cli.progress import create_langflow_progress
from langflow.initial_setup.setup import get_or_create_default_folder
from langflow.logging.logger import configure, logger
from langflow.main import setup_app
@ -40,6 +41,54 @@ console = Console()
app = typer.Typer(no_args_is_help=True)
class ProcessManager:
"""Manages the lifecycle of the backend process."""
def __init__(self):
self.webapp_process = None
self.shutdown_in_progress = False
# params are required for signal handlers, even if they are not used
def handle_sigterm(self, _signum: int, _frame) -> None:
"""Handle SIGTERM signal gracefully."""
if self.shutdown_in_progress:
return # Already shutting down, ignore
self.shutdown_in_progress = True
self.shutdown()
# params are required for signal handlers, even if they are not used
def handle_sigint(self, _signum: int, _frame) -> None:
"""Handle SIGINT signal gracefully."""
if self.shutdown_in_progress:
return # Already shutting down, ignore
self.shutdown_in_progress = True
self.shutdown()
def shutdown(self):
"""Gracefully shutdown the webapp process."""
if self.webapp_process and self.webapp_process.is_alive():
# Just terminate the process - the actual shutdown progress is handled
# by the FastAPI lifespan context in main.py
self.webapp_process.terminate()
# The long wait allows the process to finish setup, preventing it from
# getting in a state where background tasks continue to do work after termination
# is sent.
self.webapp_process.join(timeout=30)
if self.webapp_process.is_alive():
logger.warning("Process didn't terminate gracefully, killing it.")
self.webapp_process.kill()
self.webapp_process.join()
sys.exit(0)
# Create a single instance of ProcessManager
process_manager = ProcessManager()
# Update signal handlers to use the instance methods
signal.signal(signal.SIGTERM, process_manager.handle_sigterm)
signal.signal(signal.SIGINT, process_manager.handle_sigint)
def get_number_of_workers(workers=None):
if workers == -1 or workers is None:
workers = (cpu_count() * 2) + 1
@ -78,11 +127,19 @@ def set_var_for_macos_issue() -> None:
logger.debug("Set OBJC_DISABLE_INITIALIZE_FORK_SAFETY to YES to avoid error")
def handle_sigterm(signum, frame): # noqa: ARG001
"""Handle SIGTERM signal gracefully."""
logger.info("Received SIGTERM signal. Performing graceful shutdown...")
# Raise SystemExit to trigger graceful shutdown
sys.exit(0)
def wait_for_server_ready(host, port, protocol) -> None:
"""Wait for the server to become ready by polling the health endpoint."""
status_code = 0
while status_code != httpx.codes.OK:
try:
status_code = httpx.get(
f"{protocol}://{host}:{port}/health", verify=host not in ("127.0.0.1", "localhost")
).status_code
except HTTPError:
time.sleep(1)
except Exception: # noqa: BLE001
logger.opt(exception=True).debug("Error while waiting for the server to become ready.")
time.sleep(1)
@app.command()
@ -103,7 +160,11 @@ def run(
help="Path to the .env file containing environment variables.",
show_default=False,
),
log_level: str | None = typer.Option(None, help="Logging level.", show_default=False),
log_level: str | None = typer.Option(
None,
help="Logging level. One of: [debug, info, warning, error, critical]. Defaults to info.",
show_default=False,
),
log_file: Path | None = typer.Option(None, help="Path to the log file.", show_default=False),
cache: str | None = typer.Option( # noqa: ARG001
None,
@ -167,127 +228,148 @@ def run(
ssl_key_file_path: str | None = typer.Option(None, help="Defines the SSL key file path.", show_default=False),
) -> None:
"""Run Langflow."""
# Register SIGTERM handler
signal.signal(signal.SIGTERM, handle_sigterm)
if env_file:
load_dotenv(env_file, override=True)
# Set default log level if not provided
log_level_str = "info" if log_level is None else log_level.lower()
# Must set as env var for child process to pick up
env_log_level = os.environ.get("LANGFLOW_LOG_LEVEL")
if env_log_level is None:
os.environ["LANGFLOW_LOG_LEVEL"] = log_level_str
else:
os.environ["LANGFLOW_LOG_LEVEL"] = env_log_level.lower()
configure(log_level=log_level, log_file=log_file)
logger.debug(f"Loading config from file: '{env_file}'" if env_file else "No env_file provided.")
set_var_for_macos_issue()
settings_service = get_settings_service()
for key, value in os.environ.items():
new_key = key.replace("LANGFLOW_", "")
if hasattr(settings_service.auth_settings, new_key):
setattr(settings_service.auth_settings, new_key, value)
# Create progress indicator (show verbose timing if log level is DEBUG)
verbose = log_level == "debug"
progress = create_langflow_progress(verbose=verbose)
frame = inspect.currentframe()
valid_args: list = []
values: dict = {}
if frame is not None:
arguments, _, _, values = inspect.getargvalues(frame)
valid_args = [arg for arg in arguments if values[arg] is not None]
# Step 0: Initializing Langflow
with progress.step(0):
logger.debug(f"Loading config from file: '{env_file}'" if env_file else "No env_file provided.")
set_var_for_macos_issue()
settings_service = get_settings_service()
for arg in valid_args:
if arg == "components_path":
settings_service.settings.update_settings(components_path=components_path)
elif hasattr(settings_service.settings, arg):
settings_service.set(arg, values[arg])
elif hasattr(settings_service.auth_settings, arg):
settings_service.auth_settings.set(arg, values[arg])
logger.debug(f"Loading config from cli parameter '{arg}': '{values[arg]}'")
# Step 1: Checking Environment
with progress.step(1):
for key, value in os.environ.items():
new_key = key.replace("LANGFLOW_", "")
if hasattr(settings_service.auth_settings, new_key):
setattr(settings_service.auth_settings, new_key, value)
host = settings_service.settings.host
port = settings_service.settings.port
workers = settings_service.settings.workers
worker_timeout = settings_service.settings.worker_timeout
log_level = settings_service.settings.log_level
frontend_path = settings_service.settings.frontend_path
backend_only = settings_service.settings.backend_only
ssl_cert_file_path = settings_service.settings.ssl_cert_file if ssl_cert_file_path is None else ssl_cert_file_path
ssl_key_file_path = settings_service.settings.ssl_key_file if ssl_key_file_path is None else ssl_key_file_path
frame = inspect.currentframe()
valid_args: list = []
values: dict = {}
if frame is not None:
arguments, _, _, values = inspect.getargvalues(frame)
valid_args = [arg for arg in arguments if values[arg] is not None]
# create path object if frontend_path is provided
static_files_dir: Path | None = Path(frontend_path) if frontend_path else None
for arg in valid_args:
if arg == "components_path":
settings_service.settings.update_settings(components_path=components_path)
elif hasattr(settings_service.settings, arg):
settings_service.set(arg, values[arg])
elif hasattr(settings_service.auth_settings, arg):
settings_service.auth_settings.set(arg, values[arg])
logger.debug(f"Loading config from cli parameter '{arg}': '{values[arg]}'")
app = setup_app(static_files_dir=static_files_dir, backend_only=backend_only)
# check if port is being used
if is_port_in_use(port, host):
port = get_free_port(port)
# Get final values from settings
host = settings_service.settings.host
port = settings_service.settings.port
workers = settings_service.settings.workers
worker_timeout = settings_service.settings.worker_timeout
log_level = settings_service.settings.log_level
frontend_path = settings_service.settings.frontend_path
backend_only = settings_service.settings.backend_only
ssl_cert_file_path = (
settings_service.settings.ssl_cert_file if ssl_cert_file_path is None else ssl_cert_file_path
)
ssl_key_file_path = settings_service.settings.ssl_key_file if ssl_key_file_path is None else ssl_key_file_path
options = {
"bind": f"{host}:{port}",
"workers": get_number_of_workers(workers),
"timeout": worker_timeout,
"certfile": ssl_cert_file_path,
"keyfile": ssl_key_file_path,
}
protocol = "https" if options["keyfile"] and options["certfile"] else "http"
# create path object if frontend_path is provided
static_files_dir: Path | None = Path(frontend_path) if frontend_path else None
# Define an env variable to know if we are just testing the server
if "pytest" in sys.modules:
return
process: Process | None = None
try:
if platform.system() == "Windows":
# Run using uvicorn on MacOS and Windows
# Windows doesn't support gunicorn
# MacOS requires an env variable to be set to use gunicorn
run_on_windows(host, port, log_level, options, app, protocol)
else:
# Run using gunicorn on Linux
process = run_on_mac_or_linux(host, port, log_level, options, app, protocol)
# Step 2: Starting Core Services
with progress.step(2):
app = setup_app(static_files_dir=static_files_dir, backend_only=backend_only)
# Step 3: Connecting Database (this happens inside setup_app via dependencies)
with progress.step(3):
# check if port is being used
if is_port_in_use(port, host):
port = get_free_port(port)
protocol = "https" if ssl_cert_file_path and ssl_key_file_path else "http"
# Step 4: Loading Components (placeholder for components loading)
with progress.step(4):
pass # Components are loaded during app startup
# Step 5: Adding Starter Projects (placeholder for starter projects)
if get_settings_service().settings.create_starter_projects:
with progress.step(5):
pass # Starter projects are added during app startup
# Step 6: Launching Langflow
if platform.system() == "Windows":
with progress.step(6):
import uvicorn
# Print summary and banner before starting the server, since uvicorn is a blocking call.
# We _may_ be able to subprocess, but with window's spawn behavior, we'd have to move all
# non-picklable code to the subprocess.
progress.print_summary()
print_banner(host, port, protocol)
# Blocking call, so must be outside of the progress step
uvicorn.run(
app,
host=host,
port=port,
log_level=log_level,
reload=False,
workers=get_number_of_workers(workers),
loop="asyncio",
)
else:
with progress.step(6):
# Use Gunicorn with LangflowUvicornWorker for non-Windows systems
from langflow.server import LangflowApplication
options = {
"bind": f"{host}:{port}",
"workers": get_number_of_workers(workers),
"timeout": worker_timeout,
"certfile": ssl_cert_file_path,
"keyfile": ssl_key_file_path,
"log_level": log_level.lower(),
}
server = LangflowApplication(app, options)
# Start the webapp process
process_manager.webapp_process = Process(target=server.run)
process_manager.webapp_process.start()
wait_for_server_ready(host, port, protocol)
# Print summary and banner after server is ready
progress.print_summary()
print_banner(host, port, protocol)
# Handle browser opening
if open_browser and not backend_only:
browser_host = get_best_access_host(host, port)
click.launch(f"{protocol}://{browser_host}:{port}")
if process:
process.join()
except (KeyboardInterrupt, SystemExit) as e:
logger.info("Shutting down server...")
if process is not None:
process.terminate()
process.join(timeout=15) # Wait up to 15 seconds for process to terminate
if process.is_alive():
logger.warning("Process did not terminate gracefully, forcing...")
process.kill()
raise typer.Exit(0) from e
except Exception as e:
logger.exception(e)
if process is not None:
process.terminate()
raise typer.Exit(1) from e
click.launch(f"{protocol}://{host}:{port}")
def wait_for_server_ready(host, port, protocol) -> None:
"""Wait for the server to become ready by polling the health endpoint."""
status_code = 0
while status_code != httpx.codes.OK:
try:
status_code = httpx.get(
f"{protocol}://{host}:{port}/health", verify=host not in ("localhost", "127.0.0.1")
).status_code
except HTTPError:
time.sleep(1)
except Exception: # noqa: BLE001
logger.opt(exception=True).debug("Error while waiting for the server to become ready.")
time.sleep(1)
def run_on_mac_or_linux(host, port, log_level, options, app, protocol):
webapp_process = Process(target=run_langflow, args=(host, port, log_level, options, app))
webapp_process.start()
wait_for_server_ready(host, port, protocol)
print_banner(host, port, protocol)
return webapp_process
def run_on_windows(host, port, log_level, options, app, protocol) -> None:
"""Run the Langflow server on Windows."""
print_banner(host, port, protocol)
run_langflow(host, port, log_level, options, app)
process_manager.webapp_process.join()
except KeyboardInterrupt:
# SIGINT should be handled by the signal handler, but leaving here for safety
logger.warning("KeyboardInterrupt caught in main thread")
finally:
process_manager.shutdown()
def is_port_in_use(port, host="localhost"):
@ -466,9 +548,24 @@ def print_banner(host: str, port: int, protocol: str) -> None:
)
title = f"[bold]Welcome to {styled_package_name}[/bold]\n"
# Use Windows-safe characters to prevent encoding issues
import platform
if platform.system() == "Windows":
github_icon = "*"
discord_icon = "#"
arrow = "->"
status_icon = "[OK]"
else:
github_icon = ":star2:"
discord_icon = ":speech_balloon:"
arrow = ""
status_icon = "🟢"
info_text = (
":star2: GitHub: Star for updates → https://github.com/langflow-ai/langflow\n"
":speech_balloon: Discord: Join for support → https://discord.com/invite/EqksyE2EX9"
f"{github_icon} GitHub: Star for updates {arrow} https://github.com/langflow-ai/langflow\n"
f"{discord_icon} Discord: Join for support {arrow} https://discord.com/invite/EqksyE2EX9"
)
telemetry_text = (
(
@ -482,54 +579,13 @@ def print_banner(host: str, port: int, protocol: str) -> None:
)
)
access_host = get_best_access_host(host, port)
access_link = f"[bold]🟢 Open Langflow →[/bold] [link={protocol}://{access_host}:{port}]{protocol}://{access_host}:{port}[/link]"
access_link = f"[bold]{status_icon} Open Langflow {arrow}[/bold] [link={protocol}://{access_host}:{port}]{protocol}://{access_host}:{port}[/link]"
message = f"{title}\n{info_text}\n\n{telemetry_text}\n\n{access_link}"
console.print(Panel.fit(message, border_style="#7528FC", padding=(1, 2)))
def run_langflow(host, port, log_level, options, app) -> None:
"""Run Langflow server on localhost."""
if platform.system() == "Windows":
import uvicorn
uvicorn.run(
app,
host=host,
port=port,
log_level=log_level.lower(),
loop="asyncio",
ssl_keyfile=options["keyfile"],
ssl_certfile=options["certfile"],
)
else:
from langflow.server import LangflowApplication
server = LangflowApplication(app, options)
def graceful_shutdown(signum, frame): # noqa: ARG001
"""Gracefully shutdown the server when receiving SIGTERM."""
# Suppress click exceptions during shutdown
import click
click.echo = lambda *args, **kwargs: None # noqa: ARG005
logger.info("Gracefully shutting down server...")
# For Gunicorn workers, we raise SystemExit to trigger graceful shutdown
raise SystemExit(0)
# Register signal handlers
signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)
try:
server.run()
except (KeyboardInterrupt, SystemExit):
# Suppress the exception output
sys.exit(0)
@app.command()
def superuser(
username: str = typer.Option(..., prompt=True, help="Username for the superuser."),

View file

@ -0,0 +1,225 @@
import platform
import sys
import threading
import time
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
import click
MIN_DURATION_THRESHOLD = 0.1 # Minimum duration to show in seconds (100ms)
class ProgressIndicator:
"""A CLI progress indicator that shows user-friendly step-by-step progress.
Shows animated loading indicators ( ) for each step of the initialization process.
"""
def __init__(self, *, verbose: bool = False):
self.verbose = verbose
self.steps: list[dict[str, Any]] = []
self.current_step = 0
self.running = False
self._stop_animation = False
self._animation_thread: threading.Thread | None = None
# Use Windows-safe characters on Windows to prevent encoding issues
if platform.system() == "Windows":
self._animation_chars = ["-", "\\", "|", "/"] # ASCII spinner
self._success_icon = "+" # ASCII plus sign
self._failure_icon = "x" # ASCII x
self._farewell_emoji = ":)" # ASCII smiley
else:
self._animation_chars = ["", "", "", ""] # Unicode squares
self._success_icon = "" # Unicode checkmark
self._failure_icon = "" # Unicode cross
self._farewell_emoji = "👋" # Unicode wave
self._animation_index = 0
def add_step(self, title: str, description: str = "") -> None:
"""Add a step to the progress indicator."""
self.steps.append(
{
"title": title,
"description": description,
"status": "pending", # pending, running, completed, failed
"start_time": None,
"end_time": None,
}
)
def _animate_step(self, step_index: int) -> None:
"""Animate the current step with rotating square characters."""
if step_index >= len(self.steps):
return
step = self.steps[step_index]
while self.running and step["status"] == "running" and not self._stop_animation:
# Clear the current line and move cursor to beginning
sys.stdout.write("\r")
# Show the animated character
animation_char = self._animation_chars[self._animation_index]
# Print the step with animation
line = f"{animation_char} {step['title']}..."
sys.stdout.write(line)
sys.stdout.flush()
# Update animation
self._animation_index = (self._animation_index + 1) % len(self._animation_chars)
time.sleep(0.15) # Animation speed
def start_step(self, step_index: int) -> None:
"""Start a specific step and begin animation."""
if step_index >= len(self.steps):
return
self.current_step = step_index
step = self.steps[step_index]
step["status"] = "running"
step["start_time"] = time.time()
self.running = True
self._stop_animation = False
# Start animation in a separate thread
self._animation_thread = threading.Thread(target=self._animate_step, args=(step_index,))
self._animation_thread.daemon = True
self._animation_thread.start()
def complete_step(self, step_index: int, *, success: bool = True) -> None:
"""Complete a step and stop its animation."""
if step_index >= len(self.steps):
return
step = self.steps[step_index]
step["status"] = "completed" if success else "failed"
step["end_time"] = time.time()
# Stop animation
self._stop_animation = True
if self._animation_thread and self._animation_thread.is_alive():
self._animation_thread.join(timeout=0.5)
self.running = False
# Clear the current line and print final result
sys.stdout.write("\r")
if success:
icon = click.style(self._success_icon, fg="green", bold=True)
title = click.style(step["title"], fg="green")
else:
icon = click.style(self._failure_icon, fg="red", bold=True)
title = click.style(step["title"], fg="red")
duration = ""
if step["start_time"] and step["end_time"]:
elapsed = step["end_time"] - step["start_time"]
if self.verbose and elapsed > MIN_DURATION_THRESHOLD: # Only show duration if verbose and > 100ms
duration = click.style(f" ({elapsed:.2f}s)", fg="bright_black")
line = f"{icon} {title}{duration}"
click.echo(line)
def fail_step(self, step_index: int, error_msg: str = "") -> None:
"""Mark a step as failed."""
self.complete_step(step_index, success=False)
if error_msg and self.verbose:
click.echo(click.style(f" Error: {error_msg}", fg="red"))
@contextmanager
def step(self, step_index: int) -> Generator[None, None, None]:
"""Context manager for running a step with automatic completion."""
try:
self.start_step(step_index)
yield
self.complete_step(step_index, success=True)
except Exception as e:
error_msg = str(e) if self.verbose else ""
self.fail_step(step_index, error_msg)
raise
def print_summary(self) -> None:
"""Print a summary of all completed steps."""
if not self.verbose:
return
completed_steps = [s for s in self.steps if s["status"] in ["completed", "failed"]]
if not completed_steps:
return
total_time = sum(
(s["end_time"] - s["start_time"]) for s in completed_steps if s["start_time"] and s["end_time"]
)
click.echo()
click.echo(click.style(f"Total initialization time: {total_time:.2f}s", fg="bright_black"))
def print_shutdown_summary(self) -> None:
"""Print a summary of all completed shutdown steps."""
if not self.verbose:
return
completed_steps = [s for s in self.steps if s["status"] in ["completed", "failed"]]
if not completed_steps:
return
total_time = sum(
(s["end_time"] - s["start_time"]) for s in completed_steps if s["start_time"] and s["end_time"]
)
click.echo()
click.echo(click.style(f"Total shutdown time: {total_time:.2f}s", fg="bright_black"))
def print_farewell_message(self) -> None:
"""Print a nice farewell message after shutdown is complete."""
click.echo()
farewell = click.style(f"{self._farewell_emoji} See you next time!", fg="bright_blue", bold=True)
click.echo(farewell)
def create_langflow_progress(*, verbose: bool = False) -> ProgressIndicator:
"""Create a progress indicator with predefined Langflow initialization steps."""
progress = ProgressIndicator(verbose=verbose)
# Define the initialization steps matching the order in main.py
steps = [
("Initializing Langflow", "Setting up basic configuration"),
("Checking Environment", "Loading environment variables and settings"),
("Starting Core Services", "Initializing database and core services"),
("Connecting Database", "Setting up database connection and migrations"),
("Loading Components", "Caching component types and custom components"),
("Adding Starter Projects", "Creating or updating starter project templates"),
("Launching Langflow", "Starting server and final setup"),
]
for title, description in steps:
progress.add_step(title, description)
return progress
def create_langflow_shutdown_progress(*, verbose: bool = False) -> ProgressIndicator:
"""Create a progress indicator with predefined Langflow shutdown steps."""
progress = ProgressIndicator(verbose=verbose)
# Define the shutdown steps in reverse order of initialization
steps = [
("Stopping Server", "Gracefully stopping the web server"),
("Cancelling Background Tasks", "Stopping file synchronization and background jobs"),
("Cleaning Up Services", "Teardown database connections and services"),
("Clearing Temporary Files", "Removing temporary directories and cache"),
("Finalizing Shutdown", "Completing cleanup and logging"),
]
for title, description in steps:
progress.add_step(title, description)
return progress

View file

@ -536,6 +536,7 @@ def build_custom_components(components_paths: list[str]):
return {}
logger.info(f"Building custom components from {components_paths}")
custom_components_from_file: dict = {}
processed_paths = set()
for path in components_paths:
@ -546,7 +547,7 @@ def build_custom_components(components_paths: list[str]):
custom_component_dict = build_custom_component_list_from_path(path_str)
if custom_component_dict:
category = next(iter(custom_component_dict))
logger.info(f"Loading {len(custom_component_dict[category])} component(s) from category {category}")
logger.debug(f"Loading {len(custom_component_dict[category])} component(s) from category {category}")
custom_components_from_file = merge_nested_dicts_with_renaming(
custom_components_from_file, custom_component_dict
)
@ -560,7 +561,7 @@ async def abuild_custom_components(components_paths: list[str]):
if not components_paths:
return {}
logger.info(f"Building custom components from {components_paths}")
logger.debug(f"Building custom components from {components_paths}")
custom_components_from_file: dict = {}
processed_paths = set()
for path in components_paths:
@ -571,7 +572,7 @@ async def abuild_custom_components(components_paths: list[str]):
custom_component_dict = await abuild_custom_component_list_from_path(path_str)
if custom_component_dict:
category = next(iter(custom_component_dict))
logger.info(f"Loading {len(custom_component_dict[category])} component(s) from category {category}")
logger.debug(f"Loading {len(custom_component_dict[category])} component(s) from category {category}")
custom_components_from_file = merge_nested_dicts_with_renaming(
custom_components_from_file, custom_component_dict
)

View file

@ -1015,7 +1015,7 @@ async def initialize_super_user_if_needed() -> None:
super_user = await create_super_user(db=async_session, username=username, password=password)
await get_variable_service().initialize_user_variables(super_user.id, async_session)
_ = await get_or_create_default_folder(async_session, super_user.id)
logger.info("Super user initialized")
logger.debug("Super user initialized")
async def get_or_create_default_folder(session: AsyncSession, user_id: UUID) -> FolderRead:
@ -1058,32 +1058,45 @@ async def get_or_create_default_folder(session: AsyncSession, user_id: UUID) ->
async def sync_flows_from_fs():
flow_mtimes = {}
fs_flows_polling_interval = get_settings_service().settings.fs_flows_polling_interval / 1000
while True:
try:
async with session_scope() as session:
stmt = select(Flow).where(col(Flow.fs_path).is_not(None))
flows = (await session.exec(stmt)).all()
for flow in flows:
mtime = flow_mtimes.setdefault(flow.id, 0)
path = anyio.Path(flow.fs_path)
try:
if await path.exists():
new_mtime = (await path.stat()).st_mtime
if new_mtime > mtime:
update_data = orjson.loads(await path.read_text(encoding="utf-8"))
try:
for field_name in ("name", "description", "data", "locked"):
if new_value := update_data.get(field_name):
setattr(flow, field_name, new_value)
if folder_id := update_data.get("folder_id"):
flow.folder_id = UUID(folder_id)
await session.commit()
await session.refresh(flow)
except Exception: # noqa: BLE001
logger.exception(f"Couldn't update flow {flow.id} in database from path {path}")
flow_mtimes[flow.id] = new_mtime
except Exception: # noqa: BLE001
logger.exception(f"Error while handling flow file {path}")
except Exception: # noqa: BLE001
logger.exception("Error while syncing flows from database")
await asyncio.sleep(fs_flows_polling_interval)
try:
while True:
try:
async with session_scope() as session:
stmt = select(Flow).where(col(Flow.fs_path).is_not(None))
flows = (await session.exec(stmt)).all()
for flow in flows:
mtime = flow_mtimes.setdefault(flow.id, 0)
path = anyio.Path(flow.fs_path)
try:
if await path.exists():
new_mtime = (await path.stat()).st_mtime
if new_mtime > mtime:
update_data = orjson.loads(await path.read_text(encoding="utf-8"))
try:
for field_name in ("name", "description", "data", "locked"):
if new_value := update_data.get(field_name):
setattr(flow, field_name, new_value)
if folder_id := update_data.get("folder_id"):
flow.folder_id = UUID(folder_id)
await session.commit()
await session.refresh(flow)
except Exception: # noqa: BLE001
logger.exception(f"Couldn't update flow {flow.id} in database from path {path}")
flow_mtimes[flow.id] = new_mtime
except Exception: # noqa: BLE001
logger.exception(f"Error while handling flow file {path}")
except asyncio.CancelledError:
logger.debug("Flow sync cancelled")
break
except (sa.exc.OperationalError, ValueError) as e:
if "no active connection" in str(e) or "connection is closed" in str(e):
logger.debug("Database connection lost, assuming shutdown")
break # Exit gracefully, don't error
raise # Re-raise if it's a real connection problem
except Exception: # noqa: BLE001
logger.exception("Error while syncing flows from database")
break
await asyncio.sleep(fs_flows_polling_interval)
except asyncio.CancelledError:
logger.debug("Flow sync task cancelled")

View file

@ -11,6 +11,7 @@ from urllib.parse import urlencode
import anyio
import httpx
import sqlalchemy
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
@ -124,6 +125,7 @@ def get_lifespan(*, fix_migration=False, version=None):
temp_dirs: list[TemporaryDirectory] = []
sync_flows_from_fs_task = None
try:
start_time = asyncio.get_event_loop().time()
@ -180,25 +182,68 @@ def get_lifespan(*, fix_migration=False, version=None):
logger.debug(f"Total initialization time: {total_time:.2f}s")
yield
except asyncio.CancelledError:
logger.debug("Lifespan received cancellation signal")
except Exception as exc:
if "langflow migration --fix" not in str(exc):
logger.exception(exc)
raise
finally:
# Clean shutdown
logger.info("Cleaning up resources...")
if sync_flows_from_fs_task:
sync_flows_from_fs_task.cancel()
await asyncio.wait([sync_flows_from_fs_task])
await teardown_services()
# Clean shutdown with progress indicator
# Create shutdown progress (show verbose timing if log level is DEBUG)
from langflow.cli.progress import create_langflow_shutdown_progress
await asyncio.sleep(0.1) # let logger flush async logs
await logger.complete()
log_level = os.getenv("LANGFLOW_LOG_LEVEL", "info").lower()
shutdown_progress = create_langflow_shutdown_progress(verbose=log_level == "debug")
temp_dir_cleanups = [asyncio.to_thread(temp_dir.cleanup) for temp_dir in temp_dirs]
await asyncio.gather(*temp_dir_cleanups)
# Final message
logger.debug("Langflow shutdown complete")
try:
# Step 0: Stopping Server
with shutdown_progress.step(0):
logger.debug("Stopping server gracefully...")
# The actual server stopping is handled by the lifespan context
await asyncio.sleep(0.1) # Brief pause for visual effect
# Step 1: Cancelling Background Tasks
with shutdown_progress.step(1):
if sync_flows_from_fs_task:
sync_flows_from_fs_task.cancel()
await asyncio.wait([sync_flows_from_fs_task])
# Step 2: Cleaning Up Services
with shutdown_progress.step(2):
try:
await asyncio.wait_for(teardown_services(), timeout=10)
except asyncio.TimeoutError:
logger.warning("Teardown services timed out.")
# Step 3: Clearing Temporary Files
with shutdown_progress.step(3):
temp_dir_cleanups = [asyncio.to_thread(temp_dir.cleanup) for temp_dir in temp_dirs]
await asyncio.gather(*temp_dir_cleanups)
# Step 4: Finalizing Shutdown
with shutdown_progress.step(4):
logger.debug("Langflow shutdown complete")
# Show completion summary and farewell
shutdown_progress.print_shutdown_summary()
shutdown_progress.print_farewell_message()
except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.DBAPIError) as e:
# Case where the database connection is closed during shutdown
logger.warning(f"Database teardown failed due to closed connection: {e}")
except asyncio.CancelledError:
# Swallow this - it's normal during shutdown
logger.debug("Teardown cancelled during shutdown.")
except Exception as e: # noqa: BLE001
logger.exception(f"Unhandled error during cleanup: {e}")
try:
await asyncio.shield(asyncio.sleep(0.1)) # let logger flush async logs
await asyncio.shield(logger.complete())
except asyncio.CancelledError:
# Cancellation during logger flush is possible during shutdown, so we swallow it
pass
return lifespan
@ -369,7 +414,6 @@ def get_static_files_dir():
def setup_app(static_files_dir: Path | None = None, *, backend_only: bool = False) -> FastAPI:
"""Setup the FastAPI app."""
# get the directory of the current file
logger.info(f"Setting up app with static files directory {static_files_dir}")
if not static_files_dir:
static_files_dir = get_static_files_dir()
@ -377,6 +421,7 @@ def setup_app(static_files_dir: Path | None = None, *, backend_only: bool = Fals
msg = f"Static files directory {static_files_dir} does not exist."
raise RuntimeError(msg)
app = create_app()
if not backend_only and static_files_dir is not None:
setup_static_files(app, static_files_dir)
return app

View file

@ -25,7 +25,6 @@ class LangflowUvicornWorker(UvicornWorker):
def handle_exit(self, sig, frame):
if not self._has_exited:
print("👋 See you next time!") # noqa: T201
self._has_exited = True
super().handle_exit(sig, frame)
@ -46,6 +45,9 @@ class Logger(glogging.Logger):
def __init__(self, cfg) -> None:
super().__init__(cfg)
logging.getLogger("gunicorn.error").setLevel(logging.WARNING)
logging.getLogger("gunicorn.access").setLevel(logging.WARNING)
logging.getLogger("gunicorn.error").handlers = [InterceptHandler()]
logging.getLogger("gunicorn.access").handlers = [InterceptHandler()]

View file

@ -310,7 +310,6 @@ class DatabaseService(Service):
command.ensure_version(alembic_cfg)
# alembic_cfg.attributes["connection"].commit()
command.upgrade(alembic_cfg, "head")
logger.info("Alembic initialized")
def _run_migrations(self, should_initialize_alembic, fix) -> None:
# First we need to check if alembic has been initialized
@ -336,9 +335,7 @@ class DatabaseService(Service):
logger.exception(msg)
raise RuntimeError(msg) from exc
else:
logger.info("Alembic initialized")
logger.info(f"Running DB migrations in {self.script_location}")
logger.debug("Alembic initialized")
try:
buffer.write(f"{datetime.now(tz=timezone.utc).astimezone().isoformat()}: Checking migrations\n")

View file

@ -89,7 +89,7 @@ class ServiceManager:
async def teardown(self) -> None:
"""Teardown all the services."""
for service in self.services.values():
for service in list(self.services.values()):
if service is None:
continue
logger.debug(f"Teardown service {service.name}")

View file

@ -429,8 +429,14 @@ class Settings(BaseSettings):
logger.debug(f"Appending {langflow_component_path} to components_path")
if not value:
value = []
logger.debug("Setting empty components path")
value = [BASE_COMPONENTS_PATH]
logger.debug("Setting default components path to components_path")
else:
if isinstance(value, Path):
value = [str(value)]
elif isinstance(value, list):
value = [str(p) if isinstance(p, Path) else p for p in value]
logger.debug("Adding default components path to components_path")
logger.debug(f"Components path: {value}")
return value

View file

@ -131,17 +131,12 @@ async def teardown_superuser(settings_service, session: AsyncSession) -> None:
async def teardown_services() -> None:
"""Teardown all the services."""
try:
async with get_db_service().with_session() as session:
await teardown_superuser(get_settings_service(), session)
except Exception as exc: # noqa: BLE001
logger.exception(exc)
try:
from langflow.services.manager import service_manager
async with get_db_service().with_session() as session:
await teardown_superuser(get_settings_service(), session)
await service_manager.teardown()
except Exception as exc: # noqa: BLE001
logger.exception(exc)
from langflow.services.manager import service_manager
await service_manager.teardown()
def initialize_settings_service() -> None:

View file

@ -29,10 +29,9 @@ class DatabaseVariableService(VariableService, Service):
async def initialize_user_variables(self, user_id: UUID | str, session: AsyncSession) -> None:
if not self.settings_service.settings.store_environment_variables:
logger.info("Skipping environment variable storage.")
logger.debug("Skipping environment variable storage.")
return
logger.info("Storing environment variables in the database.")
for var_name in self.settings_service.settings.variables_to_get_from_environment:
if var_name in os.environ and os.environ[var_name].strip():
value = os.environ[var_name].strip()
@ -50,7 +49,7 @@ class DatabaseVariableService(VariableService, Service):
type_=CREDENTIAL_TYPE,
session=session,
)
logger.info(f"Processed {var_name} variable from environment.")
logger.debug(f"Processed {var_name} variable from environment.")
except Exception as e: # noqa: BLE001
logger.exception(f"Error processing {var_name} variable: {e!s}")

View file

@ -236,10 +236,10 @@ audio = [
]
postgresql = [
"sqlalchemy[postgresql_psycopg2binary]",
"sqlalchemy[postgresql_psycopg]",
"sqlalchemy[postgresql_psycopg2binary]>=2.0.38,<3.0.0",
"sqlalchemy[postgresql_psycopg]>=2.0.38,<3.0.0",
]
local = [
"llama-cpp-python>=0.2.0",
"sentence-transformers>=2.0.0",

9520
uv.lock generated

File diff suppressed because it is too large Load diff