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 = [ postgresql = [
"sqlalchemy[postgresql_psycopg2binary]", "sqlalchemy[postgresql_psycopg2binary]>=2.0.38,<3.0.0",
"sqlalchemy[postgresql_psycopg]", "sqlalchemy[postgresql_psycopg]>=2.0.38,<3.0.0",
] ]
[project.scripts] [project.scripts]

View file

@ -25,6 +25,7 @@ from rich.panel import Panel
from rich.table import Table from rich.table import Table
from sqlmodel import select 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.initial_setup.setup import get_or_create_default_folder
from langflow.logging.logger import configure, logger from langflow.logging.logger import configure, logger
from langflow.main import setup_app from langflow.main import setup_app
@ -40,6 +41,54 @@ console = Console()
app = typer.Typer(no_args_is_help=True) 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): def get_number_of_workers(workers=None):
if workers == -1 or workers is None: if workers == -1 or workers is None:
workers = (cpu_count() * 2) + 1 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") logger.debug("Set OBJC_DISABLE_INITIALIZE_FORK_SAFETY to YES to avoid error")
def handle_sigterm(signum, frame): # noqa: ARG001 def wait_for_server_ready(host, port, protocol) -> None:
"""Handle SIGTERM signal gracefully.""" """Wait for the server to become ready by polling the health endpoint."""
logger.info("Received SIGTERM signal. Performing graceful shutdown...") status_code = 0
# Raise SystemExit to trigger graceful shutdown while status_code != httpx.codes.OK:
sys.exit(0) 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() @app.command()
@ -103,7 +160,11 @@ def run(
help="Path to the .env file containing environment variables.", help="Path to the .env file containing environment variables.",
show_default=False, 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), log_file: Path | None = typer.Option(None, help="Path to the log file.", show_default=False),
cache: str | None = typer.Option( # noqa: ARG001 cache: str | None = typer.Option( # noqa: ARG001
None, 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), ssl_key_file_path: str | None = typer.Option(None, help="Defines the SSL key file path.", show_default=False),
) -> None: ) -> None:
"""Run Langflow.""" """Run Langflow."""
# Register SIGTERM handler
signal.signal(signal.SIGTERM, handle_sigterm)
if env_file: if env_file:
load_dotenv(env_file, override=True) 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) 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(): # Create progress indicator (show verbose timing if log level is DEBUG)
new_key = key.replace("LANGFLOW_", "") verbose = log_level == "debug"
if hasattr(settings_service.auth_settings, new_key): progress = create_langflow_progress(verbose=verbose)
setattr(settings_service.auth_settings, new_key, value)
frame = inspect.currentframe() # Step 0: Initializing Langflow
valid_args: list = [] with progress.step(0):
values: dict = {} logger.debug(f"Loading config from file: '{env_file}'" if env_file else "No env_file provided.")
if frame is not None: set_var_for_macos_issue()
arguments, _, _, values = inspect.getargvalues(frame) settings_service = get_settings_service()
valid_args = [arg for arg in arguments if values[arg] is not None]
for arg in valid_args: # Step 1: Checking Environment
if arg == "components_path": with progress.step(1):
settings_service.settings.update_settings(components_path=components_path) for key, value in os.environ.items():
elif hasattr(settings_service.settings, arg): new_key = key.replace("LANGFLOW_", "")
settings_service.set(arg, values[arg]) if hasattr(settings_service.auth_settings, new_key):
elif hasattr(settings_service.auth_settings, arg): setattr(settings_service.auth_settings, new_key, value)
settings_service.auth_settings.set(arg, values[arg])
logger.debug(f"Loading config from cli parameter '{arg}': '{values[arg]}'")
host = settings_service.settings.host frame = inspect.currentframe()
port = settings_service.settings.port valid_args: list = []
workers = settings_service.settings.workers values: dict = {}
worker_timeout = settings_service.settings.worker_timeout if frame is not None:
log_level = settings_service.settings.log_level arguments, _, _, values = inspect.getargvalues(frame)
frontend_path = settings_service.settings.frontend_path valid_args = [arg for arg in arguments if values[arg] is not None]
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
# create path object if frontend_path is provided for arg in valid_args:
static_files_dir: Path | None = Path(frontend_path) if frontend_path else None 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) # Get final values from settings
# check if port is being used host = settings_service.settings.host
if is_port_in_use(port, host): port = settings_service.settings.port
port = get_free_port(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 = { # create path object if frontend_path is provided
"bind": f"{host}:{port}", static_files_dir: Path | None = Path(frontend_path) if frontend_path else None
"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"
# Define an env variable to know if we are just testing the server # Step 2: Starting Core Services
if "pytest" in sys.modules: with progress.step(2):
return app = setup_app(static_files_dir=static_files_dir, backend_only=backend_only)
process: Process | None = None
try: # Step 3: Connecting Database (this happens inside setup_app via dependencies)
if platform.system() == "Windows": with progress.step(3):
# Run using uvicorn on MacOS and Windows # check if port is being used
# Windows doesn't support gunicorn if is_port_in_use(port, host):
# MacOS requires an env variable to be set to use gunicorn port = get_free_port(port)
run_on_windows(host, port, log_level, options, app, protocol)
else: protocol = "https" if ssl_cert_file_path and ssl_key_file_path else "http"
# Run using gunicorn on Linux
process = run_on_mac_or_linux(host, port, log_level, options, app, protocol) # 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: if open_browser and not backend_only:
browser_host = get_best_access_host(host, port) click.launch(f"{protocol}://{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
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: try:
status_code = httpx.get( process_manager.webapp_process.join()
f"{protocol}://{host}:{port}/health", verify=host not in ("localhost", "127.0.0.1") except KeyboardInterrupt:
).status_code # SIGINT should be handled by the signal handler, but leaving here for safety
except HTTPError: logger.warning("KeyboardInterrupt caught in main thread")
time.sleep(1) finally:
except Exception: # noqa: BLE001 process_manager.shutdown()
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)
def is_port_in_use(port, host="localhost"): 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" 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 = ( info_text = (
":star2: GitHub: Star for updates → https://github.com/langflow-ai/langflow\n" f"{github_icon} GitHub: Star for updates {arrow} https://github.com/langflow-ai/langflow\n"
":speech_balloon: Discord: Join for support → https://discord.com/invite/EqksyE2EX9" f"{discord_icon} Discord: Join for support {arrow} https://discord.com/invite/EqksyE2EX9"
) )
telemetry_text = ( 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_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}" 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))) 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() @app.command()
def superuser( def superuser(
username: str = typer.Option(..., prompt=True, help="Username for the 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 {} return {}
logger.info(f"Building custom components from {components_paths}") logger.info(f"Building custom components from {components_paths}")
custom_components_from_file: dict = {} custom_components_from_file: dict = {}
processed_paths = set() processed_paths = set()
for path in components_paths: 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) custom_component_dict = build_custom_component_list_from_path(path_str)
if custom_component_dict: if custom_component_dict:
category = next(iter(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 = merge_nested_dicts_with_renaming(
custom_components_from_file, custom_component_dict custom_components_from_file, custom_component_dict
) )
@ -560,7 +561,7 @@ async def abuild_custom_components(components_paths: list[str]):
if not components_paths: if not components_paths:
return {} return {}
logger.info(f"Building custom components from {components_paths}") logger.debug(f"Building custom components from {components_paths}")
custom_components_from_file: dict = {} custom_components_from_file: dict = {}
processed_paths = set() processed_paths = set()
for path in components_paths: 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) custom_component_dict = await abuild_custom_component_list_from_path(path_str)
if custom_component_dict: if custom_component_dict:
category = next(iter(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 = merge_nested_dicts_with_renaming(
custom_components_from_file, custom_component_dict 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) 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_variable_service().initialize_user_variables(super_user.id, async_session)
_ = await get_or_create_default_folder(async_session, super_user.id) _ = 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: 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(): async def sync_flows_from_fs():
flow_mtimes = {} flow_mtimes = {}
fs_flows_polling_interval = get_settings_service().settings.fs_flows_polling_interval / 1000 fs_flows_polling_interval = get_settings_service().settings.fs_flows_polling_interval / 1000
while True: try:
try: while True:
async with session_scope() as session: try:
stmt = select(Flow).where(col(Flow.fs_path).is_not(None)) async with session_scope() as session:
flows = (await session.exec(stmt)).all() stmt = select(Flow).where(col(Flow.fs_path).is_not(None))
for flow in flows: flows = (await session.exec(stmt)).all()
mtime = flow_mtimes.setdefault(flow.id, 0) for flow in flows:
path = anyio.Path(flow.fs_path) mtime = flow_mtimes.setdefault(flow.id, 0)
try: path = anyio.Path(flow.fs_path)
if await path.exists(): try:
new_mtime = (await path.stat()).st_mtime if await path.exists():
if new_mtime > mtime: new_mtime = (await path.stat()).st_mtime
update_data = orjson.loads(await path.read_text(encoding="utf-8")) if new_mtime > mtime:
try: update_data = orjson.loads(await path.read_text(encoding="utf-8"))
for field_name in ("name", "description", "data", "locked"): try:
if new_value := update_data.get(field_name): for field_name in ("name", "description", "data", "locked"):
setattr(flow, field_name, new_value) if new_value := update_data.get(field_name):
if folder_id := update_data.get("folder_id"): setattr(flow, field_name, new_value)
flow.folder_id = UUID(folder_id) if folder_id := update_data.get("folder_id"):
await session.commit() flow.folder_id = UUID(folder_id)
await session.refresh(flow) await session.commit()
except Exception: # noqa: BLE001 await session.refresh(flow)
logger.exception(f"Couldn't update flow {flow.id} in database from path {path}") except Exception: # noqa: BLE001
flow_mtimes[flow.id] = new_mtime logger.exception(f"Couldn't update flow {flow.id} in database from path {path}")
except Exception: # noqa: BLE001 flow_mtimes[flow.id] = new_mtime
logger.exception(f"Error while handling flow file {path}") except Exception: # noqa: BLE001
except Exception: # noqa: BLE001 logger.exception(f"Error while handling flow file {path}")
logger.exception("Error while syncing flows from database") except asyncio.CancelledError:
await asyncio.sleep(fs_flows_polling_interval) 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 anyio
import httpx import httpx
import sqlalchemy
from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
@ -124,6 +125,7 @@ def get_lifespan(*, fix_migration=False, version=None):
temp_dirs: list[TemporaryDirectory] = [] temp_dirs: list[TemporaryDirectory] = []
sync_flows_from_fs_task = None sync_flows_from_fs_task = None
try: try:
start_time = asyncio.get_event_loop().time() 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") logger.debug(f"Total initialization time: {total_time:.2f}s")
yield yield
except asyncio.CancelledError:
logger.debug("Lifespan received cancellation signal")
except Exception as exc: except Exception as exc:
if "langflow migration --fix" not in str(exc): if "langflow migration --fix" not in str(exc):
logger.exception(exc) logger.exception(exc)
raise raise
finally: finally:
# Clean shutdown # Clean shutdown with progress indicator
logger.info("Cleaning up resources...") # Create shutdown progress (show verbose timing if log level is DEBUG)
if sync_flows_from_fs_task: from langflow.cli.progress import create_langflow_shutdown_progress
sync_flows_from_fs_task.cancel()
await asyncio.wait([sync_flows_from_fs_task])
await teardown_services()
await asyncio.sleep(0.1) # let logger flush async logs log_level = os.getenv("LANGFLOW_LOG_LEVEL", "info").lower()
await logger.complete() 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] try:
await asyncio.gather(*temp_dir_cleanups) # Step 0: Stopping Server
# Final message with shutdown_progress.step(0):
logger.debug("Langflow shutdown complete") 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 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: def setup_app(static_files_dir: Path | None = None, *, backend_only: bool = False) -> FastAPI:
"""Setup the FastAPI app.""" """Setup the FastAPI app."""
# get the directory of the current file # 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: if not static_files_dir:
static_files_dir = get_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." msg = f"Static files directory {static_files_dir} does not exist."
raise RuntimeError(msg) raise RuntimeError(msg)
app = create_app() app = create_app()
if not backend_only and static_files_dir is not None: if not backend_only and static_files_dir is not None:
setup_static_files(app, static_files_dir) setup_static_files(app, static_files_dir)
return app return app

View file

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

View file

@ -310,7 +310,6 @@ class DatabaseService(Service):
command.ensure_version(alembic_cfg) command.ensure_version(alembic_cfg)
# alembic_cfg.attributes["connection"].commit() # alembic_cfg.attributes["connection"].commit()
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Alembic initialized")
def _run_migrations(self, should_initialize_alembic, fix) -> None: def _run_migrations(self, should_initialize_alembic, fix) -> None:
# First we need to check if alembic has been initialized # First we need to check if alembic has been initialized
@ -336,9 +335,7 @@ class DatabaseService(Service):
logger.exception(msg) logger.exception(msg)
raise RuntimeError(msg) from exc raise RuntimeError(msg) from exc
else: else:
logger.info("Alembic initialized") logger.debug("Alembic initialized")
logger.info(f"Running DB migrations in {self.script_location}")
try: try:
buffer.write(f"{datetime.now(tz=timezone.utc).astimezone().isoformat()}: Checking migrations\n") 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: async def teardown(self) -> None:
"""Teardown all the services.""" """Teardown all the services."""
for service in self.services.values(): for service in list(self.services.values()):
if service is None: if service is None:
continue continue
logger.debug(f"Teardown service {service.name}") 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") logger.debug(f"Appending {langflow_component_path} to components_path")
if not value: if not value:
value = [] value = [BASE_COMPONENTS_PATH]
logger.debug("Setting empty 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}") logger.debug(f"Components path: {value}")
return value return value

View file

@ -131,17 +131,12 @@ async def teardown_superuser(settings_service, session: AsyncSession) -> None:
async def teardown_services() -> None: async def teardown_services() -> None:
"""Teardown all the services.""" """Teardown all the services."""
try: async with get_db_service().with_session() as session:
async with get_db_service().with_session() as session: await teardown_superuser(get_settings_service(), 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
await service_manager.teardown() from langflow.services.manager import service_manager
except Exception as exc: # noqa: BLE001
logger.exception(exc) await service_manager.teardown()
def initialize_settings_service() -> None: 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: async def initialize_user_variables(self, user_id: UUID | str, session: AsyncSession) -> None:
if not self.settings_service.settings.store_environment_variables: if not self.settings_service.settings.store_environment_variables:
logger.info("Skipping environment variable storage.") logger.debug("Skipping environment variable storage.")
return return
logger.info("Storing environment variables in the database.")
for var_name in self.settings_service.settings.variables_to_get_from_environment: 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(): if var_name in os.environ and os.environ[var_name].strip():
value = os.environ[var_name].strip() value = os.environ[var_name].strip()
@ -50,7 +49,7 @@ class DatabaseVariableService(VariableService, Service):
type_=CREDENTIAL_TYPE, type_=CREDENTIAL_TYPE,
session=session, 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 except Exception as e: # noqa: BLE001
logger.exception(f"Error processing {var_name} variable: {e!s}") logger.exception(f"Error processing {var_name} variable: {e!s}")

View file

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

9520
uv.lock generated

File diff suppressed because it is too large Load diff