diff --git a/src/lfx/README.md b/src/lfx/README.md index 96dfaad6ff..6182886345 100644 --- a/src/lfx/README.md +++ b/src/lfx/README.md @@ -263,6 +263,7 @@ To view the LFX server's API docs and schema, see the `/docs` endpoint at `http: | `--flow-dir` | Directory for filesystem-backed flow storage shared across workers (e.g. `/tmp/lfx-flows` for single-pod, or a PVC mount for cross-pod). Defaults to in-memory only when omitted. | | `--max-requests` | Recycle each worker after this many requests (gunicorn, Unix-only, `--workers > 1`); `1` gives per-request recycling. Default: unset (workers are never recycled). | | `--limit-concurrency` | Max in-flight requests per worker (`--workers > 1`); excess get HTTP 503. Default: unset (unlimited). | +| `--timeout` | Worker timeout in seconds (gunicorn, Unix, `--workers > 1`): a worker that doesn't finish a request in this many seconds is killed and restarted. Raise it for long flows, especially with `--sync-workers`. Default: `120`. No effect on Windows. | | `--no-env-fallback` / `--env-fallback` | Disable the `os.environ` fallback for credential variables; variables not supplied via `global_vars` per request resolve to `None` instead of reading the process environment. Default: `--env-fallback` (fallback enabled). | | `--reset-environ` / `--no-reset-environ` | Snapshot `os.environ` before each flow run and restore it afterward, so a flow's environment mutations (or request-scoped credentials) cannot leak into the next request served by the same warm worker. Default: `--no-reset-environ` (off). | | `--sync-workers` / `--no-sync-workers` | Multi-worker only (`--workers > 1`, Unix). Use gunicorn's blocking `sync` worker so the kernel routes each request to an idle worker instead of queueing it behind an in-flight request on a busy async worker. Requires the `a2wsgi` package (`pip install a2wsgi`). Default: `--no-sync-workers` (async worker). | diff --git a/src/lfx/src/lfx/cli/_running_commands.py b/src/lfx/src/lfx/cli/_running_commands.py index 0412aafa1d..4a336a6df0 100644 --- a/src/lfx/src/lfx/cli/_running_commands.py +++ b/src/lfx/src/lfx/cli/_running_commands.py @@ -157,6 +157,16 @@ def register(app: typer.Typer) -> None: "strict cross-request isolation. Default (unset) means unlimited concurrency." ), ), + timeout: int = typer.Option( + 120, + "--timeout", + help=( + "Worker timeout in seconds (gunicorn, Unix, --workers > 1): a worker that does not " + "complete a request within this many seconds is killed and restarted. Raise it for " + "long-running flows, especially with --sync-workers (a blocking sync worker cannot " + "heartbeat mid-request). Default: 120. No effect on Windows (uvicorn fallback)." + ), + ), *, stdin: bool = typer.Option( False, @@ -222,4 +232,5 @@ def register(app: typer.Typer) -> None: limit_concurrency=limit_concurrency, reset_environ=reset_environ, sync_workers=sync_workers, + timeout=timeout, ) diff --git a/src/lfx/src/lfx/cli/commands.py b/src/lfx/src/lfx/cli/commands.py index 04dcaa20a5..a9188e926a 100644 --- a/src/lfx/src/lfx/cli/commands.py +++ b/src/lfx/src/lfx/cli/commands.py @@ -201,6 +201,16 @@ def serve_command( "strict cross-request isolation. Default (unset) means unlimited concurrency." ), ), + timeout: int = typer.Option( + 120, + "--timeout", + help=( + "Worker timeout in seconds (gunicorn, Unix, --workers > 1): a worker that does not " + "complete a request within this many seconds is killed and restarted. Raise it for " + "long-running flows, especially with --sync-workers (a blocking sync worker cannot " + "heartbeat mid-request). Default: 120. No effect on Windows (uvicorn fallback)." + ), + ), *, stdin: bool = typer.Option( False, # noqa: FBT003 @@ -404,6 +414,7 @@ def serve_command( limit_concurrency=limit_concurrency, reset_environ=reset_environ, sync_workers=sync_workers, + timeout=timeout, ) else: from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV @@ -442,6 +453,7 @@ def _launch_workers( limit_concurrency: int | None, reset_environ: bool = False, sync_workers: bool = False, + timeout: int = 120, ) -> None: """Launch ``workers`` worker processes for ``lfx serve --workers N``. @@ -469,7 +481,9 @@ def _launch_workers( around every flow run (see ``guarded_execute``). ``sync_workers`` (``--sync-workers``, Unix only) swaps the async worker for gunicorn's blocking ``sync`` worker wrapped by an a2wsgi ASGI->WSGI bridge, so the kernel routes each - request to an idle worker. Both default off. + request to an idle worker. Both default off. ``timeout`` (``--timeout``, default + 120s) sets gunicorn's worker timeout — raise it for long flows, especially under + ``--sync-workers``. """ from lfx.cli.serve_app import ( _SERVE_FLOW_DIR_ENV, @@ -569,9 +583,10 @@ def _launch_workers( "max_requests": max_requests if max_requests is not None else 0, "max_requests_jitter": 0, "loglevel": log_level, - # Sized for long synchronous flows; gunicorn's default 30s timeout - # would kill long LLM flows. Expose --timeout later if needed. - "timeout": 120, + # Worker timeout (--timeout, default 120). gunicorn's own default is 30s, + # which would kill long LLM flows — especially under --sync-workers, where a + # blocking worker cannot heartbeat mid-request. + "timeout": timeout, }, ).run() finally: diff --git a/src/lfx/tests/unit/cli/test_serve_process_model.py b/src/lfx/tests/unit/cli/test_serve_process_model.py index 60c2474f32..3f37b329c7 100644 --- a/src/lfx/tests/unit/cli/test_serve_process_model.py +++ b/src/lfx/tests/unit/cli/test_serve_process_model.py @@ -557,3 +557,15 @@ def test_launch_workers_reset_environ_off_by_default(monkeypatch): captured = _capture_gunicorn_launch(monkeypatch) assert captured["env"].get(_SERVE_RESET_ENVIRON_ENV) == "0" + + +def test_launch_workers_timeout_defaults_to_120(monkeypatch): + """Without --timeout, gunicorn's worker timeout is 120s (long-flow safe default).""" + captured = _capture_gunicorn_launch(monkeypatch) + assert captured["options"]["timeout"] == 120 + + +def test_launch_workers_timeout_propagates(monkeypatch): + """--timeout N is forwarded to gunicorn's worker timeout.""" + captured = _capture_gunicorn_launch(monkeypatch, timeout=600) + assert captured["options"]["timeout"] == 600