Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/src/content/docs/configuration/invokeai-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,37 @@ Available strategies:

Changing this setting only affects newly-created images. Existing images remain in their current locations unless you run [Image Storage Maintenance](/features/image-storage-maintenance/).

#### Response Compression

API responses (JSON, HTML, JS, CSS, SVG) are gzipped before being sent. Already-compressed responses — PNG, WebP, JPEG, MP4 — are passed through untouched, since compressing them costs CPU and returns a body no smaller than the original.

Compression runs on the server's event loop, which means it blocks *everything else* for its duration: no other request is served and no progress event is delivered while it runs. The `gzip_compresslevel` setting controls that trade-off:

```yaml
gzip_compresslevel: 9 # default value
```

| Value | Behavior |
| ------ | ---------------------------------------------------------------------------------------------- |
| `0` | No compression. Responses are sent as-is and the compression middleware is not installed. |
| `1` | Fastest compression, slightly larger output. |
| `2`–`8` | Progressively slower, marginally smaller. |
| `9` | Smallest output, by far the slowest. This is the default. |

If the UI feels sluggish while a large library is being browsed, lowering this is one of the cheapest wins available. Measured on the image-name list of a 200,000-image library (8.48 MB of JSON):

| Level | Time | Output |
| ----- | ------: | --------: |
| `1` | 16.4 ms | 6.1% of input |
| `6` | 36.1 ms | 5.9% of input |
| `9` | 90.2 ms | 5.7% of input |

Level 9 spends 5.5× the event-loop time to save 0.4 percentage points of bandwidth. On a locally-served install the bandwidth is free and the stall is not, so `gzip_compresslevel: 1` is usually the better setting there — the default stays at `9` only so that upgrading does not silently change how anyone's install behaves.

:::tip[Behind a reverse proxy]
If you serve InvokeAI through nginx, Caddy, or similar, set `gzip_compresslevel: 0` and let the proxy compress instead. The proxy does that work in its own process rather than on InvokeAI's event loop, and it avoids compressing the same bytes twice.
:::

#### Logging

Several different log handler destinations are available, and multiple destinations are supported by providing a list:
Expand Down
90 changes: 90 additions & 0 deletions docs/src/content/docs/contributing/blocking-work-in-api-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Blocking Work in API Routes
---

Almost every service in the backend is synchronous — the database layer, the model
manager, the file stores. The API layer in front of them is asynchronous. Getting the
boundary between the two wrong does not produce a slow endpoint; it produces a server
that stops answering entirely.

## The rule

**A route handler that only calls synchronous services must be declared `def`, not
`async def`.**

```python
# Correct — Starlette runs this in a worker thread.
@gallery_router.get("/items/names")
def get_gallery_item_names(current_user: CurrentUserOrDefault) -> GalleryItemNamesResult:
return ApiDependencies.invoker.services.gallery.list_item_names(...)
```

```python
# Wrong — the database query runs on the event loop.
@gallery_router.get("/items/names")
async def get_gallery_item_names(current_user: CurrentUserOrDefault) -> GalleryItemNamesResult:
return ApiDependencies.invoker.services.gallery.list_item_names(...)
```

The same rule applies to **dependencies**, not just handlers. A dependency declared
`async def` that performs a synchronous database lookup blocks the loop on every request
that uses it.

## Why it matters

The server runs as a single process with a single event loop. Anything executed directly
on that loop has the whole process to itself until it returns. Blocking work on the loop
therefore does not just delay its own response — for its entire duration the process
serves **no** other HTTP request and delivers **no** socket.io event. Users do not
experience this as one slow endpoint; they experience it as the application freezing,
typically mid-generation, because progress events stop arriving too.

The cost scales with the user's library, not with the developer's. A gallery query that
returns in milliseconds against a test database can take minutes against a multi-gigabyte
one — for example a metadata search, which has to read every row's metadata blob.

Declaring the handler `def` makes FastAPI dispatch it to a worker thread instead, leaving
the loop free to serve everything else.

## When `async def` is right

Use `async def` when the body actually awaits something — streaming a response, awaiting
another async API, or coordinating tasks. If such a handler *also* performs blocking work,
that work must be wrapped explicitly:

```python
from starlette.concurrency import run_in_threadpool

user = await run_in_threadpool(ApiDependencies.invoker.services.users.get, user_id)
```

`async def` with no `await` in the body is always a mistake: it gains nothing and costs
the loop.

## What this does not fix

Moving work to the threadpool does not make it faster, and it does not make it parallel.
The SQLite layer uses a single connection behind a process-wide lock, so database work
remains serialized regardless of which thread requests it. The benefit is confined to —
and this is the point — keeping everything *else* responsive while it runs.

## Testing it

Two tests cover this, and they do different jobs.

`tests/app/routers/test_no_blocking_async_routes.py` **enforces the rule**: it parses every
router module and fails if any route handler is `async def` without awaiting anything. This
is the one that catches a new route — a per-route test cannot, because the route does not
exist when the test is written.

`tests/app/routers/test_event_loop_blocking.py` **proves the effect** for a few
representative routes. It stubs a service method to block synchronously, issues a request
against the route under test, and asserts that an unrelated trivial route still answers
while that request is in flight.

Note what the second one measures: not the slow request's own duration, which the fix does
not change, but the latency of other requests during it. A benchmark of the slow endpoint
alone will show no improvement and is the wrong instrument here.

If you call a route handler directly from a test, call it like the plain function it now is
— no `await`, no `asyncio.run`.
11 changes: 11 additions & 0 deletions docs/src/generated/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@
"type": "<class 'str'>",
"validation": {}
},
{
"category": "WEB",
"default": 9,
"description": "GZip compression level for API responses. 0 disables response compression entirely, 1 is fastest, 9 (the default) is smallest. Compression runs on the event loop and blocks the whole server while it works, and level 9 costs about 5.5x the time of level 1 for 0.4 percentage points of extra compression, so lowering this makes the app noticeably more responsive on large libraries. Set to 0 when a reverse proxy already compresses responses.",
"env_var": "INVOKEAI_GZIP_COMPRESSLEVEL",
"literal_values": [],
"name": "gzip_compresslevel",
"required": false,
"type": "<class 'int'>",
"validation": {}
},
{
"category": "MISC FEATURES",
"default": false,
Expand Down
10 changes: 5 additions & 5 deletions invokeai/app/api/auth_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def _validate_token(token: str, invalid_detail: str) -> TokenData:
return token_data


async def get_current_user(
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
) -> TokenData:
"""Get current authenticated user from Bearer token.
Expand Down Expand Up @@ -76,7 +76,7 @@ async def get_current_user(
return token_data


async def get_current_user_or_default(
def get_current_user_or_default(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
) -> TokenData:
"""Get current authenticated user from Bearer token, or return a default system user if not authenticated.
Expand Down Expand Up @@ -128,7 +128,7 @@ async def get_current_user_or_default(
return token_data


async def get_current_media_user_or_default(
def get_current_media_user_or_default(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
media_token: Annotated[str | None, Cookie(alias=MEDIA_TOKEN_COOKIE)] = None,
) -> TokenData:
Expand All @@ -141,7 +141,7 @@ async def get_current_media_user_or_default(
return _validate_token(token, "Invalid or expired token")


async def require_admin(
def require_admin(
current_user: Annotated[TokenData, Depends(get_current_user)],
) -> TokenData:
"""Require admin role for the current user.
Expand All @@ -160,7 +160,7 @@ async def require_admin(
return current_user


async def require_admin_or_default(
def require_admin_or_default(
current_user: Annotated[TokenData, Depends(get_current_user_or_default)],
) -> TokenData:
"""Require admin role for the current user, or return default system admin in single-user mode.
Expand Down
32 changes: 16 additions & 16 deletions invokeai/app/api/routers/app_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ class AppVersion(BaseModel):


@app_router.get("/version", operation_id="app_version", status_code=200, response_model=AppVersion)
async def get_version() -> AppVersion:
def get_version() -> AppVersion:
return AppVersion(version=__version__)


@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=dict[str, str])
async def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]:
def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]:
deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()}
try:
cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue]
Expand All @@ -72,7 +72,7 @@ async def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]:


@app_router.get("/patchmatch_status", operation_id="get_patchmatch_status", status_code=200, response_model=bool)
async def get_patchmatch_status(current_user: CurrentUserOrDefault) -> bool:
def get_patchmatch_status(current_user: CurrentUserOrDefault) -> bool:
return PatchMatch.patchmatch_available()


Expand Down Expand Up @@ -212,7 +212,7 @@ def _redact_config_secrets(config: InvokeAIAppConfig) -> InvokeAIAppConfig:
status_code=200,
response_model=list[GenerationDeviceOption],
)
async def get_generation_device_options(current_user: CurrentUserOrDefault) -> list[GenerationDeviceOption]:
def get_generation_device_options(current_user: CurrentUserOrDefault) -> list[GenerationDeviceOption]:
"""List the devices available for generation, for use with the `generation_devices` setting."""
options: list[GenerationDeviceOption] = []
if torch.cuda.is_available():
Expand All @@ -233,7 +233,7 @@ async def get_generation_device_options(current_user: CurrentUserOrDefault) -> l
@app_router.get(
"/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields
)
async def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppConfigWithSetFields:
def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppConfigWithSetFields:
config = get_config()
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=_redact_config_secrets(config))

Expand All @@ -244,7 +244,7 @@ async def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppCo
status_code=200,
response_model=InvokeAIAppConfigWithSetFields,
)
async def update_runtime_config(
def update_runtime_config(
_: AdminUserOrDefault,
changes: UpdateAppGenerationSettingsRequest = Body(description="Writable runtime configuration changes"),
) -> InvokeAIAppConfigWithSetFields:
Expand Down Expand Up @@ -277,7 +277,7 @@ async def update_runtime_config(
status_code=200,
response_model=list[ExternalProviderStatusModel],
)
async def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> list[ExternalProviderStatusModel]:
def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> list[ExternalProviderStatusModel]:
statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses()
return [status_to_model(status) for status in statuses.values()]

Expand All @@ -288,7 +288,7 @@ async def get_external_provider_statuses(current_user: CurrentUserOrDefault) ->
status_code=200,
response_model=list[ExternalProviderConfigModel],
)
async def get_external_provider_configs(current_admin: AdminUserOrDefault) -> list[ExternalProviderConfigModel]:
def get_external_provider_configs(current_admin: AdminUserOrDefault) -> list[ExternalProviderConfigModel]:
config = get_config()
return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS]

Expand All @@ -299,7 +299,7 @@ async def get_external_provider_configs(current_admin: AdminUserOrDefault) -> li
status_code=200,
response_model=ExternalProviderConfigModel,
)
async def set_external_provider_config(
def set_external_provider_config(
_: AdminUserOrDefault,
provider_id: str = Path(description="The external provider identifier"),
update: ExternalProviderConfigUpdate = Body(description="External provider configuration settings"),
Expand Down Expand Up @@ -330,7 +330,7 @@ async def set_external_provider_config(
status_code=200,
response_model=ExternalProviderConfigModel,
)
async def reset_external_provider_config(
def reset_external_provider_config(
_: AdminUserOrDefault,
provider_id: str = Path(description="The external provider identifier"),
) -> ExternalProviderConfigModel:
Expand Down Expand Up @@ -439,7 +439,7 @@ def _remove_external_models_for_provider(provider_id: str) -> None:
responses={200: {"description": "The operation was successful"}},
response_model=LogLevel,
)
async def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel:
def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel:
"""Returns the log level"""
return LogLevel(ApiDependencies.invoker.services.logger.level)

Expand All @@ -450,7 +450,7 @@ async def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel:
responses={200: {"description": "The operation was successful"}},
response_model=LogLevel,
)
async def set_log_level(
def set_log_level(
current_admin: AdminUserOrDefault,
level: LogLevel = Body(description="New log verbosity level"),
) -> LogLevel:
Expand All @@ -464,7 +464,7 @@ async def set_log_level(
operation_id="clear_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None:
def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.clear()

Expand All @@ -474,7 +474,7 @@ async def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None:
operation_id="enable_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.enable()

Expand All @@ -484,7 +484,7 @@ async def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
operation_id="disable_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.disable()

Expand All @@ -494,6 +494,6 @@ async def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
operation_id="get_invocation_cache_status",
responses={200: {"model": InvocationCacheStatus}},
)
async def get_invocation_cache_status(current_admin: AdminUserOrDefault) -> InvocationCacheStatus:
def get_invocation_cache_status(current_admin: AdminUserOrDefault) -> InvocationCacheStatus:
"""Clears the invocation cache"""
return ApiDependencies.invoker.services.invocation_cache.get_status()
Loading
Loading