diff --git a/docs/src/content/docs/contributing/blocking-work-in-api-routes.md b/docs/src/content/docs/contributing/blocking-work-in-api-routes.md new file mode 100644 index 00000000000..97ccdbe0004 --- /dev/null +++ b/docs/src/content/docs/contributing/blocking-work-in-api-routes.md @@ -0,0 +1,104 @@ +--- +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. + +It also does not make the responsiveness unbounded. Starlette dispatches `def` handlers +through anyio's thread limiter, which holds **40 tokens by default**. Forty concurrent +blocking requests occupy every worker, and the forty-first waits for a free one — as does +anything else that needs a thread, including the synchronous auth dependency that runs +before a handler is even reached. So the stall does not vanish past that point, it moves: +from "one slow request freezes the server" to "the server keeps up until forty of them are +in flight at once". Note that `test_event_loop_blocking.py` probes `/api/v1/app/version`, +which has no auth dependency and no database access, so it would not show this. + +Getting past that bound is not a matter of raising the token count — the single-connection +lock below it is the real ceiling. It is the reason a route that can block for minutes +(model conversion, a git clone) is worth serializing explicitly rather than letting an +arbitrary number of them pile into the pool. + +## 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`. diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py index 1ba768a94cd..42b27d8ad0f 100644 --- a/invokeai/app/api/auth_dependencies.py +++ b/invokeai/app/api/auth_dependencies.py @@ -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. @@ -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. @@ -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: @@ -146,6 +146,11 @@ async def require_admin( ) -> TokenData: """Require admin role for the current user. + Stays `async def`, unlike the dependencies it builds on: this only reads a field off the + already-resolved token data. Declaring it `def` would buy a threadpool round-trip per admin + request and nothing else. The `users.get` that can block lives in `get_current_user`, which + is synchronous for that reason. + Args: current_user: The current authenticated user's token data @@ -165,6 +170,8 @@ async def require_admin_or_default( ) -> TokenData: """Require admin role for the current user, or return default system admin in single-user mode. + `async def` for the same reason as `require_admin`: it does no blocking work of its own. + This dependency is useful for admin-only endpoints that should work in both single-user and multiuser modes. When multiuser mode is disabled (default), this always returns a system user with admin privileges. diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 1546291670b..44e365df908 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -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] @@ -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() @@ -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(): @@ -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)) @@ -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: @@ -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()] @@ -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] @@ -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"), @@ -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: @@ -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) @@ -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: @@ -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() @@ -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() @@ -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() @@ -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() diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index f6a767c7c8d..ec6f3146c7a 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -129,7 +129,7 @@ class SetupStatusResponse(BaseModel): @auth_router.get("/status", response_model=SetupStatusResponse) -async def get_setup_status() -> SetupStatusResponse: +def get_setup_status() -> SetupStatusResponse: """Check if initial administrator setup is required. Returns: @@ -163,7 +163,7 @@ async def get_setup_status() -> SetupStatusResponse: @auth_router.post("/login", response_model=LoginResponse) -async def login( +def login( login_request: Annotated[LoginRequest, Body(description="Login credentials")], request: Request, response: Response, @@ -223,7 +223,7 @@ async def login( @auth_router.post("/logout", response_model=LogoutResponse) -async def logout( +def logout( current_user: CurrentUser, request: Request, response: Response, @@ -250,7 +250,7 @@ async def logout( @auth_router.post("/media-cookie", response_model=MediaCookieResponse) -async def refresh_media_cookie( +def refresh_media_cookie( request: Request, response: Response, _current_user: CurrentUserOrDefault, @@ -297,7 +297,7 @@ async def refresh_media_cookie( @auth_router.get("/me", response_model=UserDTO) -async def get_current_user_info( +def get_current_user_info( current_user: CurrentUser, ) -> UserDTO: """Get current authenticated user's information. @@ -321,7 +321,7 @@ async def get_current_user_info( @auth_router.post("/setup", response_model=SetupResponse) -async def setup_admin( +def setup_admin( request: Annotated[SetupRequest, Body(description="Admin account details")], ) -> SetupResponse: """Set up initial administrator account. @@ -423,7 +423,7 @@ class GeneratePasswordResponse(BaseModel): @auth_router.get("/generate-password", response_model=GeneratePasswordResponse) -async def generate_password( +def generate_password( current_user: CurrentUser, ) -> GeneratePasswordResponse: """Generate a strong random password. @@ -444,7 +444,7 @@ async def generate_password( @auth_router.get("/users", response_model=list[UserDTO]) -async def list_users( +def list_users( current_user: AdminUser, ) -> list[UserDTO]: """List all users. Requires admin privileges. @@ -460,7 +460,7 @@ async def list_users( @auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED) -async def create_user( +def create_user( request: Annotated[AdminUserCreateRequest, Body(description="New user details")], current_user: AdminUser, ) -> UserDTO: @@ -490,7 +490,7 @@ async def create_user( @auth_router.get("/users/{user_id}", response_model=UserDTO) -async def get_user( +def get_user( user_id: Annotated[str, Path(description="User ID")], current_user: AdminUser, ) -> UserDTO: @@ -513,7 +513,7 @@ async def get_user( @auth_router.patch("/users/{user_id}", response_model=UserDTO) -async def update_user( +def update_user( user_id: Annotated[str, Path(description="User ID")], request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")], current_user: AdminUser, @@ -546,7 +546,7 @@ async def update_user( @auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( +def delete_user( user_id: Annotated[str, Path(description="User ID")], current_user: AdminUser, ) -> None: @@ -581,7 +581,7 @@ async def delete_user( @auth_router.patch("/me", response_model=UserDTO) -async def update_current_user( +def update_current_user( request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")], current_user: CurrentUser, ) -> UserDTO: diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index ea0273f02d6..00c5c3a9bec 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -58,7 +58,7 @@ def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefau status_code=201, response_model=AddImagesToBoardResult, ) -async def add_image_to_board( +def add_image_to_board( current_user: CurrentUserOrDefault, board_id: str = Body(description="The id of the board to add to"), image_name: str = Body(description="The name of the image to add"), @@ -93,7 +93,7 @@ async def add_image_to_board( status_code=201, response_model=RemoveImagesFromBoardResult, ) -async def remove_image_from_board( +def remove_image_from_board( current_user: CurrentUserOrDefault, image_name: str = Body(description="The name of the image to remove", embed=True), ) -> RemoveImagesFromBoardResult: @@ -129,7 +129,7 @@ async def remove_image_from_board( status_code=201, response_model=AddImagesToBoardResult, ) -async def add_images_to_board( +def add_images_to_board( current_user: CurrentUserOrDefault, board_id: str = Body(description="The id of the board to add to"), image_names: list[str] = Body(description="The names of the images to add", embed=True), @@ -183,7 +183,7 @@ async def add_images_to_board( status_code=201, response_model=RemoveImagesFromBoardResult, ) -async def remove_images_from_board( +def remove_images_from_board( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The names of the images to remove", embed=True), ) -> RemoveImagesFromBoardResult: diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index c6adeab850e..895f73b73c8 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -49,7 +49,7 @@ class DeleteBoardResult(BaseModel): status_code=201, response_model=BoardDTO, ) -async def create_board( +def create_board( current_user: CurrentUserOrDefault, board_name: str = Query(description="The name of the board to create", max_length=300), ) -> BoardDTO: @@ -62,7 +62,7 @@ async def create_board( @boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO) -async def get_board( +def get_board( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of board to get"), ) -> BoardDTO: @@ -97,7 +97,7 @@ async def get_board( status_code=201, response_model=BoardDTO, ) -async def update_board( +def update_board( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of board to update"), changes: BoardChanges = Body(description="The changes to apply to the board"), @@ -209,7 +209,7 @@ def delete_board( operation_id="list_boards", response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]], ) -async def list_boards( +def list_boards( current_user: CurrentUserOrDefault, order_by: BoardRecordOrderBy = Query(default=BoardRecordOrderBy.CreatedAt, description="The attribute to order by"), direction: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The direction to order by"), @@ -239,7 +239,7 @@ async def list_boards( operation_id="list_all_board_image_names", response_model=list[str], ) -async def list_all_board_image_names( +def list_all_board_image_names( current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of the board or 'none' for uncategorized images"), categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."), diff --git a/invokeai/app/api/routers/client_state.py b/invokeai/app/api/routers/client_state.py index cd92263f97c..07790c7182a 100644 --- a/invokeai/app/api/routers/client_state.py +++ b/invokeai/app/api/routers/client_state.py @@ -13,7 +13,7 @@ operation_id="get_client_state_by_key", response_model=str | None, ) -async def get_client_state_by_key( +def get_client_state_by_key( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to get"), @@ -31,7 +31,7 @@ async def get_client_state_by_key( operation_id="set_client_state", response_model=str, ) -async def set_client_state( +def set_client_state( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to set"), @@ -50,7 +50,7 @@ async def set_client_state( operation_id="get_client_state_keys_by_prefix", response_model=list[str], ) -async def get_client_state_keys_by_prefix( +def get_client_state_keys_by_prefix( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), prefix: str = Query(..., description="Prefix to filter keys by"), @@ -70,7 +70,7 @@ async def get_client_state_keys_by_prefix( operation_id="delete_client_state_by_key", responses={204: {"description": "Client state key deleted"}}, ) -async def delete_client_state_by_key( +def delete_client_state_by_key( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to delete"), @@ -88,7 +88,7 @@ async def delete_client_state_by_key( operation_id="delete_client_state", responses={204: {"description": "Client state deleted"}}, ) -async def delete_client_state( +def delete_client_state( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), ) -> None: diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py index 35f6107d56a..77794ef1efc 100644 --- a/invokeai/app/api/routers/custom_nodes.py +++ b/invokeai/app/api/routers/custom_nodes.py @@ -5,6 +5,7 @@ import shutil import subprocess import sys +import threading import traceback from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path @@ -31,6 +32,17 @@ PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json" PACK_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +# Install, uninstall and reload all mutate the same three pieces of global state: the custom-nodes +# directory, `sys.modules`, and the invocation registry. As `async def` bodies containing no +# `await` they could not interleave with each other - the event loop had no point at which to +# switch. Running in the threadpool they can, and the interleavings are destructive: a failed +# install's cleanup `rmtree`s the directory a concurrent install just cloned into, and an uninstall +# can delete a pack out from under an install. This lock restores the exclusion explicitly. +# +# It is held across the git clone (up to its 120s timeout), which delays other *pack* operations +# only. Before the routes were made synchronous the same clone blocked the entire event loop. +_PACK_MUTATION_LOCK = threading.Lock() + class NodePackInfo(BaseModel): """Information about an installed node pack.""" @@ -154,7 +166,7 @@ def _get_installed_packs() -> list[NodePackInfo]: operation_id="list_custom_node_packs", response_model=NodePackListResponse, ) -async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse: +def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse: """Lists all installed custom node packs. Admin-only: the response includes absolute filesystem paths, and non-admins have no @@ -169,7 +181,7 @@ async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackL operation_id="install_custom_node_pack", response_model=InstallNodePackResponse, ) -async def install_custom_node_pack( +def install_custom_node_pack( current_admin: AdminUserOrDefault, request: InstallNodePackRequest = Body(description="The source URL to install from."), ) -> InstallNodePackResponse: @@ -191,6 +203,17 @@ async def install_custom_node_pack( target_dir = custom_nodes_path / pack_name + with _PACK_MUTATION_LOCK: + return _install_pack(source, pack_name, target_dir, owner_user_id=current_admin.user_id) + + +def _install_pack(source: str, pack_name: str, target_dir: Path, owner_user_id: str) -> InstallNodePackResponse: + """Clones and loads a pack. Callers must hold `_PACK_MUTATION_LOCK`. + + The exists-check and the cleanup `rmtree` in the failure paths below are only safe as a pair + while no other pack operation can run: without the lock two installs of the same pack both + pass the check, and the one whose clone fails deletes the other's freshly cloned directory. + """ if target_dir.exists(): return InstallNodePackResponse( name=pack_name, @@ -241,7 +264,7 @@ async def install_custom_node_pack( _load_node_pack(pack_name, target_dir) # Import any workflows found in the pack, owned by the installing admin and shared with all users - imported_workflow_ids = _import_workflows_from_pack(target_dir, pack_name, owner_user_id=current_admin.user_id) + imported_workflow_ids = _import_workflows_from_pack(target_dir, pack_name, owner_user_id=owner_user_id) _write_pack_manifest(target_dir, imported_workflow_ids) workflows_imported = len(imported_workflow_ids) workflow_msg = f" Imported {workflows_imported} workflow(s)." if workflows_imported > 0 else "" @@ -285,7 +308,7 @@ async def install_custom_node_pack( operation_id="uninstall_custom_node_pack", response_model=UninstallNodePackResponse, ) -async def uninstall_custom_node_pack( +def uninstall_custom_node_pack( current_admin: AdminUserOrDefault, pack_name: str, ) -> UninstallNodePackResponse: @@ -302,6 +325,12 @@ async def uninstall_custom_node_pack( custom_nodes_path = _get_custom_nodes_path() target_dir = custom_nodes_path / pack_name + with _PACK_MUTATION_LOCK: + return _uninstall_pack(pack_name, target_dir) + + +def _uninstall_pack(pack_name: str, target_dir: Path) -> UninstallNodePackResponse: + """Removes a pack and its imported workflows. Callers must hold `_PACK_MUTATION_LOCK`.""" if not target_dir.exists(): return UninstallNodePackResponse( name=pack_name, @@ -357,7 +386,7 @@ async def uninstall_custom_node_pack( "/reload", operation_id="reload_custom_nodes", ) -async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]: +def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]: """Triggers a reload of all custom nodes. This re-scans the nodes directory and loads any new node packs. @@ -371,12 +400,15 @@ async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, st from invokeai.app.invocations.load_custom_nodes import load_custom_nodes - load_custom_nodes(custom_nodes_path, logger) + # Imports pack modules and registers their invocations, so it must not run alongside an + # install or uninstall doing the same to the same directory. + with _PACK_MUTATION_LOCK: + load_custom_nodes(custom_nodes_path, logger) - # Invalidate the OpenAPI schema cache so the frontend gets updated node definitions - from invokeai.app.api_app import app + # Invalidate the OpenAPI schema cache so the frontend gets updated node definitions + from invokeai.app.api_app import app - app.openapi_schema = None + app.openapi_schema = None return {"status": "Custom nodes reloaded successfully."} diff --git a/invokeai/app/api/routers/download_queue.py b/invokeai/app/api/routers/download_queue.py index 305eaf9273e..8253f726701 100644 --- a/invokeai/app/api/routers/download_queue.py +++ b/invokeai/app/api/routers/download_queue.py @@ -45,7 +45,7 @@ def _validate_dest(dest: str) -> str: "/", operation_id="list_downloads", ) -async def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob]: +def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob]: """Get a list of active and inactive jobs.""" queue = ApiDependencies.invoker.services.download_queue return queue.list_jobs() @@ -59,7 +59,7 @@ async def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob 400: {"description": "Bad request"}, }, ) -async def prune_downloads(current_user: AdminUserOrDefault) -> Response: +def prune_downloads(current_user: AdminUserOrDefault) -> Response: """Prune completed and errored jobs.""" queue = ApiDependencies.invoker.services.download_queue queue.prune_jobs() @@ -70,7 +70,7 @@ async def prune_downloads(current_user: AdminUserOrDefault) -> Response: "/i/", operation_id="download", ) -async def download( +def download( current_user: CurrentUserOrDefault, source: AnyHttpUrl = Body(description="download source"), dest: str = Body(description="download destination"), @@ -91,7 +91,7 @@ async def download( 404: {"description": "The requested download JobID could not be found"}, }, ) -async def get_download_job( +def get_download_job( current_user: CurrentUserOrDefault, id: int = Path(description="ID of the download job to fetch."), ) -> DownloadJob: @@ -111,7 +111,7 @@ async def get_download_job( 404: {"description": "The requested download JobID could not be found"}, }, ) -async def cancel_download_job( +def cancel_download_job( current_user: CurrentUserOrDefault, id: int = Path(description="ID of the download job to cancel."), ) -> Response: @@ -132,7 +132,7 @@ async def cancel_download_job( 204: {"description": "Download jobs have been cancelled"}, }, ) -async def cancel_all_download_jobs(current_user: AdminUserOrDefault) -> Response: +def cancel_all_download_jobs(current_user: AdminUserOrDefault) -> Response: """Cancel all download jobs.""" ApiDependencies.invoker.services.download_queue.cancel_all_jobs() return Response(status_code=204) diff --git a/invokeai/app/api/routers/gallery.py b/invokeai/app/api/routers/gallery.py index a70822c5af1..1b63d31c43b 100644 --- a/invokeai/app/api/routers/gallery.py +++ b/invokeai/app/api/routers/gallery.py @@ -6,7 +6,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.images import _assert_board_read_access -from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNamesResult +from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNames, GalleryItemNamesResult from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection @@ -19,7 +19,7 @@ operation_id="list_gallery_items", response_model=OffsetPaginatedResults[GalleryItem], ) -async def list_gallery_items( +def list_gallery_items( current_user: CurrentUserOrDefault, origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), categories: Optional[list[ImageCategory]] = Query( @@ -58,12 +58,63 @@ async def list_gallery_items( ) +@gallery_router.get( + "/item_names", + operation_id="list_gallery_item_names", + response_model=GalleryItemNames, +) +def list_gallery_item_names( + current_user: CurrentUserOrDefault, + origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), + categories: Optional[list[ImageCategory]] = Query( + default=None, + description="The categories to include. Shared between images and videos.", + ), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate items."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find items without a board.", + ), + created_date: Optional[str] = Query( + default=None, + description="Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards.", + ), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred_first: bool = Query(default=True, description="Whether to sort by starred items first"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> GalleryItemNames: + """Returns the ordered flat list of item names — used to drive virtualized gallery selection. + + Names are polymorphic: image and video names are interleaved by `created_at`. A name ending + in `.mp4` is a video. + """ + if board_id is not None and board_id != "none": + _assert_board_read_access(board_id, current_user) + + try: + return ApiDependencies.invoker.services.gallery.get_item_names( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, + created_date=created_date, + ) + except Exception: + raise HTTPException(status_code=500, detail="Failed to get gallery item names") + + @gallery_router.get( "/items/names", operation_id="get_gallery_item_names", response_model=GalleryItemNamesResult, + deprecated=True, ) -async def get_gallery_item_names( +def get_gallery_item_names( current_user: CurrentUserOrDefault, origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), categories: Optional[list[ImageCategory]] = Query( @@ -79,7 +130,12 @@ async def get_gallery_item_names( starred_first: bool = Query(default=True, description="Whether to sort by starred items first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> GalleryItemNamesResult: - """Returns an ordered (kind, name) list — used to drive virtualized gallery selection.""" + """Returns an ordered (kind, name) list — used to drive virtualized gallery selection. + + Deprecated: use `GET /v1/gallery/item_names`, which returns the same order as a flat name + list. The `kind` discriminator here costs a model per row — ~800ms on a 200k-item library — + for a value callers already derive from the file extension. + """ if board_id is not None and board_id != "none": _assert_board_read_access(board_id, current_user) diff --git a/invokeai/app/api/routers/image_moves.py b/invokeai/app/api/routers/image_moves.py index 0fe328ea9ea..ecbebc2074a 100644 --- a/invokeai/app/api/routers/image_moves.py +++ b/invokeai/app/api/routers/image_moves.py @@ -65,7 +65,7 @@ def _status_to_response(service_status: ImageMoveBackgroundStatus | dict) -> Ima response_model=ImageMoveStatusResponse, status_code=status.HTTP_202_ACCEPTED, ) -async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: try: return _status_to_response(_get_image_move_service().start_background_move_all()) except (ImageMoveJobAlreadyRunning, ImageMoveQueueActive) as e: @@ -78,7 +78,7 @@ async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: response_model=ImageMoveStatusResponse, status_code=status.HTTP_202_ACCEPTED, ) -async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse: try: return _status_to_response(_get_image_move_service().start_background_recovery()) except ImageMoveJobAlreadyRunning as e: @@ -90,5 +90,5 @@ async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusRes operation_id="get_image_move_status", response_model=ImageMoveStatusResponse, ) -async def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse: +def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse: return _status_to_response(_get_image_move_service().get_background_status()) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index b9e06befb9c..eb0c7074294 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -187,7 +187,7 @@ class ImageUploadEntry(BaseModel): @images_router.post("/", operation_id="create_image_upload_entry") -async def create_image_upload_entry( +def create_image_upload_entry( _: CurrentUserOrDefault, width: int = Body(description="The width of the image"), height: int = Body(description="The height of the image"), @@ -199,7 +199,7 @@ async def create_image_upload_entry( @images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult) -async def delete_image( +def delete_image( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: @@ -227,7 +227,7 @@ async def delete_image( @images_router.delete("/intermediates", operation_id="clear_intermediates") -async def clear_intermediates( +def clear_intermediates( current_user: CurrentUserOrDefault, ) -> int: """Clears all intermediates. Requires admin.""" @@ -243,7 +243,7 @@ async def clear_intermediates( @images_router.get("/intermediates", operation_id="get_intermediates_count") -async def get_intermediates_count( +def get_intermediates_count( current_user: CurrentUserOrDefault, ) -> int: """Gets the count of intermediate images. Non-admin users only see their own intermediates.""" @@ -260,7 +260,7 @@ async def get_intermediates_count( operation_id="update_image", response_model=ImageDTO, ) -async def update_image( +def update_image( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image to update"), image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), @@ -280,7 +280,7 @@ async def update_image( operation_id="get_image_dto", response_model=ImageDTO, ) -async def get_image_dto( +def get_image_dto( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image to get"), ) -> ImageDTO: @@ -298,7 +298,7 @@ async def get_image_dto( operation_id="get_image_metadata", response_model=Optional[MetadataField], ) -async def get_image_metadata( +def get_image_metadata( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image to get"), ) -> Optional[MetadataField]: @@ -319,7 +319,7 @@ class WorkflowAndGraphResponse(BaseModel): @images_router.get( "/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse ) -async def get_image_workflow( +def get_image_workflow( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image whose workflow to get"), ) -> WorkflowAndGraphResponse: @@ -358,7 +358,7 @@ async def get_image_workflow( 404: {"description": "Image not found"}, }, ) -async def get_image_full( +def get_image_full( current_user: CurrentMediaUserOrDefault, image_name: str = Path(description="The name of full-resolution image file to get"), ) -> Response: @@ -394,7 +394,7 @@ async def get_image_full( 404: {"description": "Image not found"}, }, ) -async def get_image_thumbnail( +def get_image_thumbnail( current_user: CurrentMediaUserOrDefault, image_name: str = Path(description="The name of thumbnail image file to get"), ) -> Response: @@ -422,7 +422,7 @@ async def get_image_thumbnail( operation_id="get_image_urls", response_model=ImageUrlsDTO, ) -async def get_image_urls( +def get_image_urls( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of the image whose URL to get"), ) -> ImageUrlsDTO: @@ -446,7 +446,7 @@ async def get_image_urls( operation_id="list_image_dtos", response_model=OffsetPaginatedResults[ImageDTO], ) -async def list_image_dtos( +def list_image_dtos( current_user: CurrentUserOrDefault, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), @@ -486,7 +486,7 @@ async def list_image_dtos( @images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult) -async def delete_images_from_list( +def delete_images_from_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to delete", embed=True), ) -> DeleteImagesResult: @@ -534,7 +534,7 @@ async def delete_images_from_list( @images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult) -async def delete_uncategorized_images( +def delete_uncategorized_images( current_user: CurrentUserOrDefault, ) -> DeleteImagesResult: """Deletes all uncategorized images owned by the current user (or all if admin)""" @@ -573,7 +573,7 @@ class ImagesUpdatedFromListResult(BaseModel): @images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult) -async def star_images_in_list( +def star_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to star", embed=True), ) -> StarredImagesResult: @@ -610,7 +610,7 @@ async def star_images_in_list( @images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult) -async def unstar_images_in_list( +def unstar_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), ) -> UnstarredImagesResult: @@ -658,7 +658,7 @@ class ImagesDownloaded(BaseModel): @images_router.post( "/download", operation_id="download_images_from_list", response_model=ImagesDownloaded, status_code=202 ) -async def download_images_from_list( +def download_images_from_list( current_user: CurrentUserOrDefault, background_tasks: BackgroundTasks, image_names: Optional[list[str]] = Body( @@ -707,7 +707,7 @@ async def download_images_from_list( 404: {"description": "Image not found"}, }, ) -async def get_bulk_download_item( +def get_bulk_download_item( current_user: CurrentUserOrDefault, background_tasks: BackgroundTasks, bulk_download_item_name: str = Path(description="The bulk_download_item_name of the bulk download item to get"), @@ -740,8 +740,8 @@ async def get_bulk_download_item( raise HTTPException(status_code=404) -@images_router.get("/names", operation_id="get_image_names") -async def get_image_names( +@images_router.get("/names", operation_id="get_image_names", deprecated=True) +def get_image_names( current_user: CurrentUserOrDefault, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), @@ -754,7 +754,11 @@ async def get_image_names( starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> ImageNamesResult: - """Gets ordered list of image names with metadata for optimistic updates""" + """Gets ordered list of image names with metadata for optimistic updates. + + Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + in one ordered list. This image-only endpoint predates the polymorphic gallery. + """ # Validate that the caller can read from this board before listing its images. if board_id is not None and board_id != "none": @@ -782,7 +786,7 @@ async def get_image_names( operation_id="get_images_by_names", responses={200: {"model": list[ImageDTO]}}, ) -async def get_images_by_names( +def get_images_by_names( current_user: CurrentUserOrDefault, image_names: list[str] = Body(embed=True, description="Object containing list of image names to fetch DTOs for"), ) -> list[ImageDTO]: diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 09c9a8bca06..0e20790bc84 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -5,7 +5,9 @@ import contextlib import io import pathlib +import threading import traceback +from collections.abc import Generator from copy import deepcopy from enum import Enum from tempfile import TemporaryDirectory @@ -30,7 +32,7 @@ ModelRecordOrderBy, UnknownModelException, ) -from invokeai.app.services.orphaned_models import OrphanedModelInfo +from invokeai.app.services.orphaned_models import CONVERSION_SCRATCH_DIRNAME, OrphanedModelInfo from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.util.suppress_output import SuppressOutput from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig @@ -58,6 +60,45 @@ model_manager_router = APIRouter(prefix="/v2/models", tags=["model_manager"]) +# Conversion loads a model, writes a diffusers copy, then swaps the record. As an `async def` +# body with no `await` it could not overlap with another request; running in the threadpool it +# can, and two conversions in flight means two models resident at once with nothing bounding the +# RAM/VRAM that takes. Held non-blocking: an admin gets a 409 telling them to wait rather than +# an HTTP request that hangs for the minutes a conversion takes. +_MODEL_CONVERSION_LOCK = threading.Lock() + +# Bounding conversions against each other is not enough: deletion runs in the threadpool too, and +# conversion is a read-modify-replace spanning many service calls. Interleaved on one key, a +# delete removes the source a conversion is still reading, the conversion's own final +# `installer.delete` then fails, and the converted copy it already installed survives - so the +# admin is told 204 and the model reappears under a new key. Claiming the key makes operations on +# one model serialize while leaving different models free to run in parallel. +_MODEL_KEY_CLAIM_LOCK = threading.Lock() +_CLAIMED_MODEL_KEYS: set[str] = set() + + +@contextlib.contextmanager +def _claim_model_key(key: str) -> Generator[None, None, None]: + """Hold the exclusive claim on one model key, or raise 409 if another request holds it.""" + with _MODEL_KEY_CLAIM_LOCK: + if key in _CLAIMED_MODEL_KEYS: + raise HTTPException( + status_code=409, + detail=f"Another operation on model {key} is already in progress. Wait for it to finish and try again.", + ) + _CLAIMED_MODEL_KEYS.add(key) + try: + yield + finally: + with _MODEL_KEY_CLAIM_LOCK: + _CLAIMED_MODEL_KEYS.discard(key) + + +# The HF token is process-global state backed by a file in the HF cache. Concurrent writers would +# interleave set/reset with the status read that follows it, so the reported status need not +# describe the token that was just written. +_HF_TOKEN_LOCK = threading.Lock() + # images are immutable; set a high max-age IMAGE_MAX_AGE = 31536000 @@ -156,7 +197,7 @@ def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type "/", operation_id="list_model_records", ) -async def list_model_records( +def list_model_records( current_user: CurrentUserOrDefault, base_models: Optional[List[BaseModelType]] = Query(default=None, description="Base models to include"), model_type: Optional[ModelType] = Query(default=None, description="The type of model to get"), @@ -202,7 +243,7 @@ async def list_model_records( operation_id="list_missing_models", responses={200: {"description": "List of models with missing files"}}, ) -async def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: +def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: """Get models whose files are missing from disk. These are models that have database entries but their corresponding @@ -229,7 +270,7 @@ async def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList: operation_id="get_model_records_by_attrs", response_model=AnyModelConfig, ) -async def get_model_records_by_attrs( +def get_model_records_by_attrs( current_user: CurrentUserOrDefault, name: str = Query(description="The name of the model"), type: ModelType = Query(description="The type of the model"), @@ -251,7 +292,7 @@ async def get_model_records_by_attrs( operation_id="get_model_records_by_hash", response_model=AnyModelConfig, ) -async def get_model_records_by_hash( +def get_model_records_by_hash( current_user: CurrentUserOrDefault, hash: str = Query(description="The hash of the model"), ) -> AnyModelConfig: @@ -276,7 +317,7 @@ async def get_model_records_by_hash( 404: {"description": "The model could not be found"}, }, ) -async def get_model_record( +def get_model_record( current_user: CurrentUserOrDefault, key: str = Path(description="Key of the model record to fetch."), ) -> AnyModelConfig: @@ -300,7 +341,7 @@ async def get_model_record( 404: {"description": "The model could not be found"}, }, ) -async def reidentify_model( +def reidentify_model( key: Annotated[str, Path(description="Key of the model to reidentify.")], current_admin: AdminUserOrDefault, ) -> AnyModelConfig: @@ -349,7 +390,7 @@ class FoundModel(BaseModel): status_code=200, response_model=List[FoundModel], ) -async def scan_for_models( +def scan_for_models( current_admin: AdminUserOrDefault, scan_path: str = Query(description="Directory path to search for models", default=None), ) -> List[FoundModel]: @@ -415,7 +456,7 @@ class HuggingFaceModels(BaseModel): status_code=200, response_model=HuggingFaceModels, ) -async def get_hugging_face_models( +def get_hugging_face_models( current_admin: AdminUserOrDefault, hugging_face_repo: str = Query(description="Hugging face repo to search for models", default=None), ) -> HuggingFaceModels: @@ -523,7 +564,7 @@ def _load_settings_changed(previous: AnyModelConfig, updated: AnyModelConfig) -> }, status_code=200, ) -async def get_model_image( +def get_model_image( key: str = Path(description="The name of model image file to get"), ) -> FileResponse: """Gets an image file that previews the model""" @@ -590,7 +631,7 @@ async def update_model_image( }, status_code=204, ) -async def delete_model( +def delete_model( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model to remove from model registry."), ) -> Response: @@ -602,14 +643,15 @@ async def delete_model( """ logger = ApiDependencies.invoker.services.logger - try: - installer = ApiDependencies.invoker.services.model_manager.install - installer.delete(key) - logger.info(f"Deleted model: {key}") - return Response(status_code=204) - except UnknownModelException as e: - logger.error(str(e)) - raise HTTPException(status_code=404, detail=str(e)) + with _claim_model_key(key): + try: + installer = ApiDependencies.invoker.services.model_manager.install + installer.delete(key) + logger.info(f"Deleted model: {key}") + return Response(status_code=204) + except UnknownModelException as e: + logger.error(str(e)) + raise HTTPException(status_code=404, detail=str(e)) class BulkDeleteModelsRequest(BaseModel): @@ -646,7 +688,7 @@ class BulkReidentifyModelsResponse(BaseModel): }, status_code=200, ) -async def bulk_delete_models( +def bulk_delete_models( current_admin: AdminUserOrDefault, request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"), ) -> BulkDeleteModelsResponse: @@ -665,9 +707,15 @@ async def bulk_delete_models( for key in request.keys: try: - installer.delete(key) + # Per key, so one model busy elsewhere is reported as a failure for that key rather + # than aborting the whole request or racing the operation that holds it. + with _claim_model_key(key): + installer.delete(key) deleted.append(key) logger.info(f"Deleted model: {key}") + except HTTPException as e: + logger.error(f"Failed to delete model {key}: {e.detail}") + failed.append({"key": key, "error": e.detail}) except UnknownModelException as e: logger.error(f"Failed to delete model {key}: {str(e)}") failed.append({"key": key, "error": str(e)}) @@ -687,7 +735,7 @@ async def bulk_delete_models( }, status_code=200, ) -async def bulk_reidentify_models( +def bulk_reidentify_models( current_admin: AdminUserOrDefault, request: BulkReidentifyModelsRequest = Body(description="List of model keys to reidentify"), ) -> BulkReidentifyModelsResponse: @@ -749,7 +797,7 @@ async def bulk_reidentify_models( }, status_code=204, ) -async def delete_model_image( +def delete_model_image( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model image to remove from model_images directory."), ) -> None: @@ -775,7 +823,7 @@ async def delete_model_image( }, status_code=201, ) -async def install_model( +def install_model( current_admin: AdminUserOrDefault, source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), @@ -846,7 +894,7 @@ async def install_model( status_code=201, response_class=HTMLResponse, ) -async def install_hugging_face_model( +def install_hugging_face_model( current_admin: AdminUserOrDefault, source: str = Query(description="HuggingFace repo_id to install"), ) -> HTMLResponse: @@ -967,7 +1015,7 @@ def generate_html(title: str, heading: str, repo_id: str, is_error: bool, messag "/install", operation_id="list_model_installs", ) -async def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelInstallJob]: +def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelInstallJob]: """Return the list of model install jobs. Install jobs have a numeric `id`, a `status`, and other fields that provide information on @@ -999,7 +1047,7 @@ async def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelIn 404: {"description": "No such job"}, }, ) -async def get_model_install_job( +def get_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install id") ) -> ModelInstallJob: """ @@ -1022,7 +1070,7 @@ async def get_model_install_job( }, status_code=201, ) -async def cancel_model_install_job( +def cancel_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID"), ) -> None: @@ -1044,7 +1092,7 @@ async def cancel_model_install_job( }, status_code=201, ) -async def pause_model_install_job( +def pause_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Pause the model install job corresponding to the given job ID.""" @@ -1066,7 +1114,7 @@ async def pause_model_install_job( }, status_code=201, ) -async def resume_model_install_job( +def resume_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Resume a paused model install job corresponding to the given job ID.""" @@ -1088,7 +1136,7 @@ async def resume_model_install_job( }, status_code=201, ) -async def restart_failed_model_install_job( +def restart_failed_model_install_job( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID") ) -> ModelInstallJob: """Restart failed or non-resumable file downloads for the given job.""" @@ -1110,7 +1158,7 @@ async def restart_failed_model_install_job( }, status_code=201, ) -async def restart_model_install_file( +def restart_model_install_file( current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID"), file_source: AnyHttpUrl = Body(description="File download URL to restart"), @@ -1133,7 +1181,7 @@ async def restart_model_install_file( 400: {"description": "Bad request"}, }, ) -async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response: +def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response: """Prune all completed and errored jobs from the install job list.""" ApiDependencies.invoker.services.model_manager.install.prune_jobs() return Response(status_code=204) @@ -1152,7 +1200,7 @@ async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Respons 409: {"description": "There is already a model registered at this location"}, }, ) -async def convert_model( +def convert_model( current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of the safetensors main model to convert to diffusers format."), ) -> AnyModelConfig: @@ -1161,6 +1209,23 @@ async def convert_model( Note that during the conversion process the key and model hash will change. The return value is the model configuration for the converted model. """ + if not _MODEL_CONVERSION_LOCK.acquire(blocking=False): + raise HTTPException( + status_code=409, + detail="Another model conversion is already in progress. Wait for it to finish and try again.", + ) + try: + # Claimed for the whole conversion, so a delete arriving mid-way is refused rather than + # pulling the source out from under it. Deletion never takes the conversion lock, so the + # two are ordered consistently and cannot deadlock. + with _claim_model_key(key): + return _convert_model(key, user_id=current_admin.user_id) + finally: + _MODEL_CONVERSION_LOCK.release() + + +def _convert_model(key: str, user_id: str) -> AnyModelConfig: + """Converts one model. Callers must hold `_MODEL_CONVERSION_LOCK`.""" model_manager = ApiDependencies.invoker.services.model_manager loader = model_manager.load logger = ApiDependencies.invoker.services.logger @@ -1186,9 +1251,16 @@ async def convert_model( logger.error(msg) raise HTTPException(400, msg) - with TemporaryDirectory(dir=ApiDependencies.invoker.services.configuration.models_path) as tmpdir: + # Under the models root so `install_path` below moves the result rather than copying it across + # a filesystem boundary, but inside the scratch directory the orphan scan skips: a half-written + # diffusers copy is model files with no database record, which is exactly what that scan hunts + # for, and `DELETE /sync/orphaned` would rmtree it while it is still being written. + scratch_dir = ApiDependencies.invoker.services.configuration.models_path / CONVERSION_SCRATCH_DIRNAME + scratch_dir.mkdir(parents=True, exist_ok=True) + + with TemporaryDirectory(dir=scratch_dir) as tmpdir: convert_path = pathlib.Path(tmpdir) / pathlib.Path(model_config.path).stem - converted_model = loader.load_model(model_config, user_id=current_admin.user_id) + converted_model = loader.load_model(model_config, user_id=user_id) # write the converted file to the convert path raw_model = converted_model.model assert hasattr(raw_model, "save_pretrained") @@ -1291,7 +1363,7 @@ def get_is_installed( @model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=StarterModelResponse) -async def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelResponse: +def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelResponse: installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr() starter_models = deepcopy(STARTER_MODELS) starter_bundles = deepcopy(STARTER_BUNDLES) @@ -1324,7 +1396,7 @@ async def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelR response_model=Optional[CacheStats], summary="Get model manager RAM cache performance statistics.", ) -async def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: +def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: """Return performance statistics on the model manager's RAM cache. In multi-GPU mode there is one cache per generation device; their statistics are aggregated. Will return null if no models have been loaded.""" @@ -1364,7 +1436,7 @@ async def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]: operation_id="empty_model_cache", status_code=200, ) -async def empty_model_cache(current_admin: AdminUserOrDefault) -> None: +def empty_model_cache(current_admin: AdminUserOrDefault) -> None: """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped.""" # Request 1000GB of room in order to force each per-device cache to drop all models. ApiDependencies.invoker.services.logger.info("Emptying model cache.") @@ -1404,7 +1476,7 @@ def reset_token(cls) -> HFTokenStatus: @model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus) -async def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatus: +def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatus: token_status = HFTokenHelper.get_status() if token_status is HFTokenStatus.UNKNOWN: @@ -1414,12 +1486,14 @@ async def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatu @model_manager_router.post("/hf_login", operation_id="do_hf_login", response_model=HFTokenStatus) -async def do_hf_login( +def do_hf_login( current_admin: AdminUserOrDefault, token: str = Body(description="Hugging Face token to use for login", embed=True), ) -> HFTokenStatus: - HFTokenHelper.set_token(token) - token_status = HFTokenHelper.get_status() + # Write and read-back as one step; see _HF_TOKEN_LOCK. + with _HF_TOKEN_LOCK: + HFTokenHelper.set_token(token) + token_status = HFTokenHelper.get_status() if token_status is HFTokenStatus.UNKNOWN: ApiDependencies.invoker.services.logger.warning("Unable to verify HF token") @@ -1428,8 +1502,9 @@ async def do_hf_login( @model_manager_router.delete("/hf_login", operation_id="reset_hf_token", response_model=HFTokenStatus) -async def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus: - return HFTokenHelper.reset_token() +def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus: + with _HF_TOKEN_LOCK: + return HFTokenHelper.reset_token() # Orphaned Models Management Routes @@ -1453,7 +1528,7 @@ class DeleteOrphanedModelsResponse(BaseModel): operation_id="get_orphaned_models", response_model=list[OrphanedModelInfo], ) -async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: +def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: """Find orphaned model directories. Orphaned models are directories in the models folder that contain model files @@ -1480,9 +1555,7 @@ async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]: operation_id="delete_orphaned_models", response_model=DeleteOrphanedModelsResponse, ) -async def delete_orphaned_models( - request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault -) -> DeleteOrphanedModelsResponse: +def delete_orphaned_models(request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault) -> DeleteOrphanedModelsResponse: """Delete specified orphaned model directories. Args: diff --git a/invokeai/app/api/routers/model_relationships.py b/invokeai/app/api/routers/model_relationships.py index 0ec45070955..3a882038c0f 100644 --- a/invokeai/app/api/routers/model_relationships.py +++ b/invokeai/app/api/routers/model_relationships.py @@ -85,7 +85,7 @@ class ModelRelationshipBatchRequest(BaseModel): 422: {"description": "Validation error"}, }, ) -async def get_related_models( +def get_related_models( current_user: CurrentUserOrDefault, model_key: str = Path(..., description="The key of the model to get relationships for"), ) -> list[str]: @@ -108,7 +108,7 @@ async def get_related_models( summary="Add Model Relationship", description="Creates a **bidirectional** relationship between two models, allowing each to reference the other as related.", ) -async def add_model_relationship( +def add_model_relationship( current_user: AdminUserOrDefault, req: ModelRelationshipCreateRequest = Body(..., description="The model keys to relate"), ) -> None: @@ -145,7 +145,7 @@ async def add_model_relationship( summary="Remove Model Relationship", description="Removes a **bidirectional** relationship between two models. The relationship must already exist.", ) -async def remove_model_relationship( +def remove_model_relationship( current_user: AdminUserOrDefault, req: ModelRelationshipCreateRequest = Body(..., description="The model keys to disconnect"), ) -> None: @@ -194,7 +194,7 @@ async def remove_model_relationship( summary="Get Related Model Keys (Batch)", description="Retrieves all **unique related model keys** for a list of given models. This is useful for contextual suggestions or filtering.", ) -async def get_related_models_batch( +def get_related_models_batch( current_user: CurrentUserOrDefault, req: ModelRelationshipBatchRequest = Body(..., description="Model keys to check for related connections"), ) -> list[str]: diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 1f96280f4f3..44ba5e05a4b 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -399,7 +399,7 @@ def _assert_recall_image_access(parameters: "RecallParameter", current_user: Cur operation_id="update_recall_parameters", response_model=dict[str, Any], ) -async def update_recall_parameters( +def update_recall_parameters( current_user: CurrentUserOrDefault, queue_id: str = Path(..., description="The queue id to perform this operation on"), parameters: RecallParameter = Body(..., description="Recall parameters to update"), @@ -585,7 +585,7 @@ async def update_recall_parameters( operation_id="get_recall_parameters", response_model=dict[str, Any], ) -async def get_recall_parameters( +def get_recall_parameters( current_user: CurrentUserOrDefault, queue_id: str = Path(..., description="The queue id to retrieve parameters for"), ) -> dict[str, Any]: diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index f2ec8f7cb63..b4a93294710 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -1,4 +1,5 @@ -from typing import Optional +from collections.abc import Callable +from typing import Any, Optional, TypeVar from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter @@ -24,6 +25,7 @@ SessionQueueCountsByDestination, SessionQueueItem, SessionQueueItemNotFoundError, + SessionQueueItemSummary, SessionQueueStatus, ) from invokeai.app.services.shared.graph import Graph, GraphExecutionState @@ -31,6 +33,12 @@ session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"]) +# Upper bound on the number of item ids a client may ask about in one request. Without it a +# caller can post tens of thousands of ids, which the SQLite layer would either expand past the +# per-statement bind limit or grind through in a long-running query. The list is meant to cover +# the rows a client actually has on screen, so this is far above any legitimate use. +MAX_QUEUE_ITEM_IDS_PER_REQUEST = 1000 + class SessionQueueAndProcessorStatus(BaseModel): """The overall status of session queue and processor""" @@ -45,54 +53,67 @@ def _get_workflow_call_root_queue_item(queue_item: SessionQueueItem) -> SessionQ return ApiDependencies.invoker.services.session_queue.get_queue_item(queue_item.root_item_id) -def sanitize_queue_item_for_user( - queue_item: SessionQueueItem, current_user_id: str, is_admin: bool -) -> SessionQueueItem: - """Sanitize queue item for non-admin users viewing other users' items. - - For non-admin users viewing queue items belonging to other users, - only timestamps, status, and error information are exposed. All other - fields (user identity, generation parameters, graphs, workflows) are stripped. +# What a non-admin must not see on another user's queue item, and what each field is replaced +# with. One table for both the full item and the list summary: the two are different projections +# of the same row, and a second redaction list is how they drift apart - a field stripped from the +# list but left on the detail view is still leaked. Fields absent from a model are skipped, so the +# full-item-only entries below simply do not apply to the summary. +# +# Replacements are built per call so that no two sanitized items share one mutable object. +# +# `device` is deliberately not redacted: it names the GPU the instance ran the job on, which is a +# property of the hardware rather than of the other user's work, and the queue list has always +# shown it. +_REDACTIONS: dict[str, Callable[[], Any]] = { + "user_id": lambda: "redacted", + "user_display_name": lambda: None, + "user_email": lambda: None, + "batch_id": lambda: "redacted", + "session_id": lambda: "redacted", + "origin": lambda: None, + "destination": lambda: None, + "priority": lambda: 0, + "field_values": lambda: None, + "retried_from_item_id": lambda: None, + "workflow_call_id": lambda: None, + "parent_item_id": lambda: None, + "parent_session_id": lambda: None, + "root_item_id": lambda: None, + "workflow_call_depth": lambda: None, + "workflow": lambda: None, + "error_type": lambda: None, + "error_message": lambda: None, + "error_traceback": lambda: None, + "session": lambda: GraphExecutionState(id="redacted", graph=Graph()), +} + +AnyQueueItem = TypeVar("AnyQueueItem", SessionQueueItem, SessionQueueItemSummary) + + +def sanitize_queue_item_for_user(queue_item: AnyQueueItem, current_user_id: str, is_admin: bool) -> AnyQueueItem: + """Sanitize a queue item, or a queue item summary, for a non-admin viewing another user's item. + + Only item_id, queue_id, status, device and the timestamps survive; identity, generation + parameters, graphs and workflows are stripped. Admins and the item's owner see everything. Args: - queue_item: The queue item to sanitize + queue_item: The queue item or summary to sanitize current_user_id: The ID of the current user viewing the item is_admin: Whether the current user is an admin Returns: - The sanitized queue item (sensitive fields cleared if necessary) + The sanitized item (sensitive fields cleared if necessary) """ # Admins and item owners can see everything if is_admin or queue_item.user_id == current_user_id: return queue_item - # For non-admins viewing other users' items, strip everything except - # item_id, queue_id, status, and timestamps - sanitized_item = queue_item.model_copy(deep=False) - sanitized_item.user_id = "redacted" - sanitized_item.user_display_name = None - sanitized_item.user_email = None - sanitized_item.batch_id = "redacted" - sanitized_item.session_id = "redacted" - sanitized_item.origin = None - sanitized_item.destination = None - sanitized_item.priority = 0 - sanitized_item.field_values = None - sanitized_item.retried_from_item_id = None - sanitized_item.workflow_call_id = None - sanitized_item.parent_item_id = None - sanitized_item.parent_session_id = None - sanitized_item.root_item_id = None - sanitized_item.workflow_call_depth = None - sanitized_item.workflow = None - sanitized_item.error_type = None - sanitized_item.error_message = None - sanitized_item.error_traceback = None - sanitized_item.session = GraphExecutionState( - id="redacted", - graph=Graph(), - ) - return sanitized_item + updates = { + field: build_replacement() + for field, build_replacement in _REDACTIONS.items() + if field in type(queue_item).model_fields + } + return queue_item.model_copy(update=updates) @session_queue_router.post( @@ -126,7 +147,7 @@ async def enqueue_batch( 200: {"model": list[SessionQueueItem]}, }, ) -async def list_all_queue_items( +def list_all_queue_items( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), @@ -150,7 +171,7 @@ async def list_all_queue_items( 200: {"model": ItemIdsResult}, }, ) -async def get_queue_item_ids( +def get_queue_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), @@ -176,7 +197,7 @@ async def get_queue_item_ids( operation_id="get_queue_items_by_item_ids", responses={200: {"model": list[SessionQueueItem]}}, ) -async def get_queue_items_by_item_ids( +def get_queue_items_by_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body( @@ -206,12 +227,36 @@ async def get_queue_items_by_item_ids( raise HTTPException(status_code=500, detail="Failed to get queue items") +@session_queue_router.post( + "/{queue_id}/item_summaries_by_ids", + operation_id="get_queue_item_summaries_by_ids", + responses={200: {"model": list[SessionQueueItemSummary]}}, +) +def get_queue_item_summaries_by_ids( + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id to perform this operation on"), + item_ids: list[int] = Body( + embed=True, + max_length=MAX_QUEUE_ITEM_IDS_PER_REQUEST, + description="Object containing list of queue item ids to fetch summaries for", + ), +) -> list[SessionQueueItemSummary]: + """Gets lightweight queue item summaries for specified IDs in requested order.""" + try: + summaries = ApiDependencies.invoker.services.session_queue.get_queue_item_summaries_by_ids( + queue_id=queue_id, item_ids=item_ids + ) + return [sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) for item in summaries] + except Exception: + raise HTTPException(status_code=500, detail="Failed to get queue item summaries") + + @session_queue_router.put( "/{queue_id}/processor/resume", operation_id="resume", responses={200: {"model": SessionProcessorStatus}}, ) -async def resume( +def resume( current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: @@ -227,7 +272,7 @@ async def resume( operation_id="pause", responses={200: {"model": SessionProcessorStatus}}, ) -async def pause( +def pause( current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: @@ -243,7 +288,7 @@ async def pause( operation_id="cancel_all_except_current", responses={200: {"model": CancelAllExceptCurrentResult}}, ) -async def cancel_all_except_current( +def cancel_all_except_current( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> CancelAllExceptCurrentResult: @@ -263,7 +308,7 @@ async def cancel_all_except_current( operation_id="delete_all_except_current", responses={200: {"model": DeleteAllExceptCurrentResult}}, ) -async def delete_all_except_current( +def delete_all_except_current( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> DeleteAllExceptCurrentResult: @@ -283,7 +328,7 @@ async def delete_all_except_current( operation_id="cancel_by_batch_ids", responses={200: {"model": CancelByBatchIDsResult}}, ) -async def cancel_by_batch_ids( +def cancel_by_batch_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True), @@ -304,7 +349,7 @@ async def cancel_by_batch_ids( operation_id="cancel_by_destination", responses={200: {"model": CancelByDestinationResult}}, ) -async def cancel_by_destination( +def cancel_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: str = Query(description="The destination to cancel all queue items for"), @@ -325,7 +370,7 @@ async def cancel_by_destination( operation_id="retry_items_by_id", responses={200: {"model": RetryItemsResult}}, ) -async def retry_items_by_id( +def retry_items_by_id( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body(description="The queue item ids to retry"), @@ -371,7 +416,7 @@ async def retry_items_by_id( 200: {"model": ClearResult}, }, ) -async def clear( +def clear( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> ClearResult: @@ -398,7 +443,7 @@ async def clear( 200: {"model": PruneResult}, }, ) -async def prune( +def prune( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> PruneResult: @@ -418,7 +463,7 @@ async def prune( 200: {"model": Optional[SessionQueueItem]}, }, ) -async def get_current_queue_item( +def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> Optional[SessionQueueItem]: @@ -439,7 +484,7 @@ async def get_current_queue_item( 200: {"model": Optional[SessionQueueItem]}, }, ) -async def get_next_queue_item( +def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> Optional[SessionQueueItem]: @@ -460,7 +505,7 @@ async def get_next_queue_item( 200: {"model": SessionQueueAndProcessorStatus}, }, ) -async def get_queue_status( +def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionQueueAndProcessorStatus: @@ -485,7 +530,7 @@ async def get_queue_status( 200: {"model": BatchStatus}, }, ) -async def get_batch_status( +def get_batch_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch_id: str = Path(description="The batch to get the status of"), @@ -508,7 +553,7 @@ async def get_batch_status( }, response_model_exclude_none=True, ) -async def get_queue_item( +def get_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to get"), @@ -530,7 +575,7 @@ async def get_queue_item( "/{queue_id}/i/{item_id}", operation_id="delete_queue_item", ) -async def delete_queue_item( +def delete_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to delete"), @@ -566,7 +611,7 @@ async def delete_queue_item( 200: {"model": SessionQueueItem}, }, ) -async def cancel_queue_item( +def cancel_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to cancel"), @@ -596,7 +641,7 @@ async def cancel_queue_item( operation_id="counts_by_destination", responses={200: {"model": SessionQueueCountsByDestination}}, ) -async def counts_by_destination( +def counts_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to query"), destination: str = Query(description="The destination to query"), @@ -616,7 +661,7 @@ async def counts_by_destination( operation_id="delete_by_destination", responses={200: {"model": DeleteByDestinationResult}}, ) -async def delete_by_destination( +def delete_by_destination( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to query"), destination: str = Path(description="The destination to query"), diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 91acf8e7a6b..d470da1e8cf 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -78,7 +78,7 @@ def _load_record_or_404(style_preset_id: str) -> StylePresetRecordDTO: 200: {"model": StylePresetRecordWithImage}, }, ) -async def get_style_preset( +def get_style_preset( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The style preset to get"), ) -> StylePresetRecordWithImage: @@ -157,7 +157,7 @@ async def update_style_preset( "/i/{style_preset_id}", operation_id="delete_style_preset", ) -async def delete_style_preset( +def delete_style_preset( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The style preset to delete"), ) -> None: @@ -238,7 +238,7 @@ async def create_style_preset( 200: {"model": list[StylePresetRecordWithImage]}, }, ) -async def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePresetRecordWithImage]: +def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePresetRecordWithImage]: """Gets the style presets visible to the current user.""" style_presets_with_image: list[StylePresetRecordWithImage] = [] style_presets = ApiDependencies.invoker.services.style_preset_records.get_many( @@ -265,7 +265,7 @@ async def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePr }, status_code=200, ) -async def get_style_preset_image( +def get_style_preset_image( current_user: CurrentUserOrDefault, style_preset_id: str = Path(description="The id of the style preset image to get"), ) -> FileResponse: @@ -294,7 +294,7 @@ async def get_style_preset_image( responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}}, status_code=200, ) -async def export_style_presets(current_user: AdminUserOrDefault): +def export_style_presets(current_user: AdminUserOrDefault): # Admin-only export covers every user preset. output = io.StringIO() writer = csv.writer(output) diff --git a/invokeai/app/api/routers/system_prompts.py b/invokeai/app/api/routers/system_prompts.py index f0fa9ac7b50..6bfbba7ab58 100644 --- a/invokeai/app/api/routers/system_prompts.py +++ b/invokeai/app/api/routers/system_prompts.py @@ -19,7 +19,7 @@ operation_id="list_system_prompts", responses={200: {"model": list[SystemPromptRecordDTO]}}, ) -async def list_system_prompts(current_user: CurrentUserOrDefault) -> list[SystemPromptRecordDTO]: +def list_system_prompts(current_user: CurrentUserOrDefault) -> list[SystemPromptRecordDTO]: """Lists system prompts visible to the current user (own + public).""" config = ApiDependencies.invoker.services.configuration # Admins (and single-user installs) see everything; multiuser non-admins are scoped to own + public. @@ -34,7 +34,7 @@ async def list_system_prompts(current_user: CurrentUserOrDefault) -> list[System operation_id="get_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def get_system_prompt( +def get_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to get"), ) -> SystemPromptRecordDTO: @@ -57,7 +57,7 @@ async def get_system_prompt( operation_id="create_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def create_system_prompt( +def create_system_prompt( current_user: CurrentUserOrDefault, system_prompt: SystemPromptWithoutId = Body(description="The system prompt to create"), ) -> SystemPromptRecordDTO: @@ -75,7 +75,7 @@ async def create_system_prompt( operation_id="update_system_prompt", responses={200: {"model": SystemPromptRecordDTO}}, ) -async def update_system_prompt( +def update_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to update"), changes: SystemPromptChanges = Body(description="The changes to apply"), @@ -100,7 +100,7 @@ async def update_system_prompt( "/i/{system_prompt_id}", operation_id="delete_system_prompt", ) -async def delete_system_prompt( +def delete_system_prompt( current_user: CurrentUserOrDefault, system_prompt_id: str = Path(description="The id of the system prompt to delete"), ) -> None: diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index 023f653df6c..48e4d885366 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -45,7 +45,7 @@ class DynamicPromptsResponse(BaseModel): 200: {"model": DynamicPromptsResponse}, }, ) -async def parse_dynamicprompts( +def parse_dynamicprompts( current_user: CurrentUserOrDefault, prompt: str = Body(description="The prompt to parse with dynamicprompts"), max_prompts: int = Body(ge=1, le=10000, default=1000, description="The max number of prompts to generate"), diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 29e8b99cdf7..52408688445 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -470,7 +470,7 @@ def update_video( @videos_router.get("/i/{video_name}", operation_id="get_video_dto", response_model=VideoDTO) -async def get_video_dto( +def get_video_dto( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video to get"), ) -> VideoDTO: @@ -484,7 +484,7 @@ async def get_video_dto( @videos_router.get( "/i/{video_name}/metadata", operation_id="get_video_metadata", response_model=Optional[MetadataField] ) -async def get_video_metadata( +def get_video_metadata( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video to get"), ) -> Optional[MetadataField]: @@ -498,7 +498,7 @@ async def get_video_metadata( @videos_router.get( "/i/{video_name}/workflow", operation_id="get_video_workflow", response_model=WorkflowAndGraphResponse ) -async def get_video_workflow( +def get_video_workflow( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of video whose workflow to get"), ) -> WorkflowAndGraphResponse: @@ -572,7 +572,7 @@ def _parse_range_header(range_header: str, file_size: int) -> Optional[tuple[int 404: {"description": "Video not found"}, }, ) -async def get_video_full( +def get_video_full( request: Request, current_user: CurrentMediaUserOrDefault, video_name: str = PathParam(description="The name of video file to get"), @@ -675,7 +675,7 @@ def iter_video() -> Iterator[bytes]: 404: {"description": "Video not found"}, }, ) -async def get_video_thumbnail( +def get_video_thumbnail( current_user: CurrentMediaUserOrDefault, video_name: str = PathParam(description="The name of thumbnail file to get"), ) -> Response: @@ -699,7 +699,7 @@ async def get_video_thumbnail( @videos_router.get("/i/{video_name}/urls", operation_id="get_video_urls", response_model=VideoUrlsDTO) -async def get_video_urls( +def get_video_urls( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video whose URL to get"), ) -> VideoUrlsDTO: @@ -713,7 +713,7 @@ async def get_video_urls( @videos_router.get("/", operation_id="list_video_dtos", response_model=OffsetPaginatedResults[VideoDTO]) -async def list_video_dtos( +def list_video_dtos( current_user: CurrentUserOrDefault, video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."), @@ -750,8 +750,8 @@ async def list_video_dtos( ) -@videos_router.get("/names", operation_id="get_video_names") -async def get_video_names( +@videos_router.get("/names", operation_id="get_video_names", deprecated=True) +def get_video_names( current_user: CurrentUserOrDefault, video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."), @@ -764,7 +764,11 @@ async def get_video_names( starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> VideoNamesResult: - """Gets ordered list of video names with metadata for optimistic updates.""" + """Gets ordered list of video names with metadata for optimistic updates. + + Deprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved + in one ordered list. This video-only endpoint predates the polymorphic gallery. + """ # Validate that the caller can read from this board. "none" is handled by the SQL layer. if board_id is not None and board_id != "none": _assert_board_read_access(board_id, current_user) @@ -854,7 +858,7 @@ class VideoBoardArg(BaseModel): operation_id="add_video_to_board", response_model=AddVideosToBoardResult, ) -async def add_video_to_board( +def add_video_to_board( current_user: CurrentUserOrDefault, arg: VideoBoardArg = Body(), ) -> AddVideosToBoardResult: @@ -882,7 +886,7 @@ async def add_video_to_board( operation_id="remove_video_from_board", response_model=RemoveVideosFromBoardResult, ) -async def remove_video_from_board( +def remove_video_from_board( current_user: CurrentUserOrDefault, video_name: str = Body(description="The name of the video to remove from its board", embed=True), ) -> RemoveVideosFromBoardResult: diff --git a/invokeai/app/api/routers/virtual_boards.py b/invokeai/app/api/routers/virtual_boards.py index 78902dd5dec..9837cbd899e 100644 --- a/invokeai/app/api/routers/virtual_boards.py +++ b/invokeai/app/api/routers/virtual_boards.py @@ -16,7 +16,7 @@ operation_id="list_virtual_boards_by_date", response_model=list[VirtualSubBoardDTO], ) -async def list_virtual_boards_by_date( +def list_virtual_boards_by_date( current_user: CurrentUserOrDefault, ) -> list[VirtualSubBoardDTO]: """Gets a list of virtual sub-boards grouped by date. Covers both images and videos.""" @@ -33,8 +33,9 @@ async def list_virtual_boards_by_date( "/by_date/{date}/image_names", operation_id="list_virtual_board_image_names_by_date", response_model=ImageNamesResult, + deprecated=True, ) -async def list_virtual_board_image_names_by_date( +def list_virtual_board_image_names_by_date( current_user: CurrentUserOrDefault, date: str = Path(description="The ISO date string, e.g. '2026-03-18'"), starred_first: bool = Query(default=True, description="Whether to sort starred images first"), @@ -42,8 +43,11 @@ async def list_virtual_board_image_names_by_date( categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"), search_term: str | None = Query(default=None, description="Search term to filter images"), ) -> ImageNamesResult: - """Gets ordered image names for a specific date. Image-only; kept for API compatibility — - the UI uses the polymorphic `/by_date/{date}/item_names` endpoint.""" + """Gets ordered image names for a specific date. Image-only. + + Deprecated: use `GET /v1/gallery/item_names?created_date=`, which covers images and + videos in one ordered list. + """ try: return ApiDependencies.invoker.services.image_records.get_image_names_by_date( date=date, @@ -62,8 +66,9 @@ async def list_virtual_board_image_names_by_date( "/by_date/{date}/item_names", operation_id="list_virtual_board_item_names_by_date", response_model=GalleryItemNamesResult, + deprecated=True, ) -async def list_virtual_board_item_names_by_date( +def list_virtual_board_item_names_by_date( current_user: CurrentUserOrDefault, date: str = Path(description="The ISO date string, e.g. '2026-03-18'"), starred_first: bool = Query(default=True, description="Whether to sort starred items first"), @@ -71,7 +76,11 @@ async def list_virtual_board_item_names_by_date( categories: list[ImageCategory] | None = Query(default=None, description="The categories of items to include"), search_term: str | None = Query(default=None, description="Search term to filter items"), ) -> GalleryItemNamesResult: - """Gets ordered polymorphic (image + video) item refs for a specific date.""" + """Gets ordered polymorphic (image + video) item refs for a specific date. + + Deprecated: use `GET /v1/gallery/item_names?created_date=`, which returns the same + order as a flat name list instead of one model per item. + """ try: return ApiDependencies.invoker.services.gallery.list_item_names( starred_first=starred_first, diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 768a8f4e8d5..14707ebf1e1 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -35,7 +35,7 @@ 200: {"model": WorkflowRecordWithThumbnailDTO}, }, ) -async def get_workflow( +def get_workflow( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to get"), ) -> WorkflowRecordWithThumbnailDTO: @@ -74,7 +74,7 @@ async def get_workflow( 200: {"model": WorkflowRecordDTO}, }, ) -async def update_workflow( +def update_workflow( current_user: CurrentUserOrDefault, workflow: Workflow = Body(description="The updated workflow", embed=True), ) -> WorkflowRecordDTO: @@ -103,7 +103,7 @@ async def update_workflow( "/i/{workflow_id}", operation_id="delete_workflow", ) -async def delete_workflow( +def delete_workflow( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to delete"), ) -> None: @@ -138,7 +138,7 @@ async def delete_workflow( 200: {"model": WorkflowRecordDTO}, }, ) -async def create_workflow( +def create_workflow( current_user: CurrentUserOrDefault, workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), ) -> WorkflowRecordDTO: @@ -165,7 +165,7 @@ async def create_workflow( 200: {"model": PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]}, }, ) -async def list_workflows( +def list_workflows( current_user: CurrentUserOrDefault, page: int = Query(default=0, description="The page to get"), per_page: Optional[int] = Query(default=None, description="The number of workflows per page"), @@ -306,7 +306,7 @@ async def set_workflow_thumbnail( 200: {"model": WorkflowRecordDTO}, }, ) -async def delete_workflow_thumbnail( +def delete_workflow_thumbnail( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), ): @@ -338,7 +338,7 @@ async def delete_workflow_thumbnail( }, status_code=200, ) -async def get_workflow_thumbnail( +def get_workflow_thumbnail( workflow_id: str = Path(description="The id of the workflow thumbnail to get"), ) -> FileResponse: """Gets a workflow's thumbnail image. @@ -369,7 +369,7 @@ async def get_workflow_thumbnail( 200: {"model": WorkflowRecordDTO}, }, ) -async def update_workflow_is_public( +def update_workflow_is_public( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), is_public: bool = Body(description="Whether the workflow should be shared publicly", embed=True), @@ -398,7 +398,7 @@ async def update_workflow_is_public( @workflows_router.get("/tags", operation_id="get_all_tags") -async def get_all_tags( +def get_all_tags( current_user: CurrentUserOrDefault, categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"), @@ -417,7 +417,7 @@ async def get_all_tags( @workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag") -async def get_counts_by_tag( +def get_counts_by_tag( current_user: CurrentUserOrDefault, tags: list[str] = Query(description="The tags to get counts for"), categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), @@ -438,7 +438,7 @@ async def get_counts_by_tag( @workflows_router.get("/counts_by_category", operation_id="counts_by_category") -async def counts_by_category( +def counts_by_category( current_user: CurrentUserOrDefault, categories: list[WorkflowCategory] = Query(description="The categories to include"), has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"), @@ -461,7 +461,7 @@ async def counts_by_category( "/i/{workflow_id}/opened_at", operation_id="update_opened_at", ) -async def update_opened_at( +def update_opened_at( current_user: CurrentUserOrDefault, workflow_id: str = Path(description="The workflow to update"), ) -> None: diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index c0722c4bd1c..d94c3d24543 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -7,7 +7,6 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.security.utils import get_authorization_scheme_param @@ -16,6 +15,7 @@ from starlette.concurrency import run_in_threadpool from starlette.datastructures import Headers from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.middleware.gzip import GZipMiddleware, GZipResponder, IdentityResponder from starlette.types import ASGIApp, Message, Receive, Scope, Send import invokeai.frontend.web as web_dir @@ -159,6 +159,77 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): return response +# Response types worth compressing. Everything else is passed through untouched. +# +# This is an allowlist rather than a blocklist of media types on purpose: a type missing from +# this list only loses compression it would barely have benefited from, whereas a binary type +# missing from a blocklist costs real CPU on the event loop. The app serves a small, known set +# of compressible things — the UI bundle, the API's JSON, SVG icons. +COMPRESSIBLE_CONTENT_TYPES = ( + "text/", + "application/json", + "application/javascript", + "application/xml", + "application/xhtml+xml", + "application/manifest+json", + "image/svg+xml", +) + +# `text/` would otherwise match this, and compressing an event stream defeats its purpose by +# withholding events until the compressor flushes. Starlette excludes it by default too. +UNCOMPRESSIBLE_CONTENT_TYPES = ("text/event-stream",) + + +def _is_compressible(content_type: str) -> bool: + if content_type.startswith(UNCOMPRESSIBLE_CONTENT_TYPES): + return False + return content_type.startswith(COMPRESSIBLE_CONTENT_TYPES) + + +class _ContentTypeAwareGZipResponder(GZipResponder): + """Skips compression for response types that are already compressed. + + `content_type_is_excluded` is computed when the response starts and only read once the + body arrives, so widening it right after the base class has set it is enough — no need to + reimplement Starlette's streaming/pathsend handling. + """ + + async def send_with_compression(self, message: Message) -> None: + await super().send_with_compression(message) + if message["type"] == "http.response.start" and not self.content_type_is_excluded: + self.content_type_is_excluded = not _is_compressible( + Headers(raw=message["headers"]).get("content-type", "") + ) + + +class ContentTypeAwareGZipMiddleware(GZipMiddleware): + """GZip, but only for content types that actually compress. + + Starlette's GZipMiddleware compresses every response type except `text/event-stream`. The + gallery serves PNG, WebP and MP4 bytes, which are already compressed: a 3 MB PNG costs + ~52ms of event-loop time to gzip and comes back *larger* than it went in. With auto-switch + enabled the UI fetches the full image after every generated image, so that cost lands + repeatedly during a batch — exactly when the server can least afford to stall. + + Lowering `compresslevel` does not help here: on incompressible input, level 1 costs + essentially the same as level 9 because deflate still has to scan the data. + """ + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + if "gzip" in Headers(scope=scope).get("Accept-Encoding", ""): + responder: ASGIApp = _ContentTypeAwareGZipResponder( + self.app, self.minimum_size, compresslevel=self.compresslevel + ) + else: + responder = IdentityResponder(self.app, self.minimum_size) + + await responder(scope, receive, send) + + class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware): """When a request is made to the root path with a query string, redirect to the root path without the query string. @@ -400,7 +471,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: expose_headers=["X-Refreshed-Token"], ) -app.add_middleware(GZipMiddleware, minimum_size=1000) +app.add_middleware(ContentTypeAwareGZipMiddleware, minimum_size=1000) # Include all routers diff --git a/invokeai/app/services/gallery/gallery_base.py b/invokeai/app/services/gallery/gallery_base.py index bd6591884de..a2679aba7a8 100644 --- a/invokeai/app/services/gallery/gallery_base.py +++ b/invokeai/app/services/gallery/gallery_base.py @@ -1,7 +1,12 @@ from abc import ABC, abstractmethod from typing import Optional -from invokeai.app.services.gallery.gallery_common import BoardMediaSummary, GalleryItem, GalleryItemNamesResult +from invokeai.app.services.gallery.gallery_common import ( + BoardMediaSummary, + GalleryItem, + GalleryItemNames, + GalleryItemNamesResult, +) from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection @@ -45,6 +50,27 @@ def list_item_names( ) -> GalleryItemNamesResult: """Returns ordered (kind, name) refs for optimistic UI / virtualized lists. + Deprecated — use :meth:`get_item_names`, which returns the same order without building + a model per row. + """ + pass + + @abstractmethod + def get_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNames: + """Returns the ordered flat name list for optimistic UI / virtualized lists. + `created_date` restricts the result to items created on the given ISO date — used by date-based virtual boards. """ diff --git a/invokeai/app/services/gallery/gallery_common.py b/invokeai/app/services/gallery/gallery_common.py index 85753c958c7..00a3befdb87 100644 --- a/invokeai/app/services/gallery/gallery_common.py +++ b/invokeai/app/services/gallery/gallery_common.py @@ -49,13 +49,31 @@ class GalleryItem(BaseModelExcludeNull): class GalleryItemNamesResult(BaseModel): - """Ordered list of gallery item references plus counts for optimistic UI.""" + """Ordered list of gallery item references plus counts for optimistic UI. + + Deprecated in favour of :class:`GalleryItemNames`. Wrapping every name in an object to + carry a `kind` discriminator costs ~800ms of model construction on a 200k-item library, + for a field callers derive from the filename extension anyway. + """ items: list[GalleryItemRef] = Field(description="Ordered list of (kind, name) references.") starred_count: int = Field(description="Number of starred items (when starred_first=True).") total_count: int = Field(description="Total number of items matching the query.") +class GalleryItemNames(BaseModel): + """Ordered flat list of gallery item names plus counts for optimistic UI. + + Names are polymorphic — images and videos are interleaved by `created_at`. The kind of a + given name is its file extension (`.mp4` is a video), which is how every consumer already + discriminates. Mirrors the shape of the image-only `ImageNamesResult`. + """ + + item_names: list[str] = Field(description="Ordered list of image and video names.") + starred_count: int = Field(description="Number of starred items (when starred_first=True).") + total_count: int = Field(description="Total number of items matching the query.") + + @dataclass(frozen=True) class BoardMediaSummary: cover_image_name: Optional[str] = None diff --git a/invokeai/app/services/gallery/gallery_default.py b/invokeai/app/services/gallery/gallery_default.py index 0a23717eb86..0fdeaca48cc 100644 --- a/invokeai/app/services/gallery/gallery_default.py +++ b/invokeai/app/services/gallery/gallery_default.py @@ -6,6 +6,7 @@ BoardMediaSummary, GalleryItem, GalleryItemKind, + GalleryItemNames, GalleryItemNamesResult, GalleryItemRef, ) @@ -100,19 +101,24 @@ def list_items( total=image_count + video_count, ) - def list_item_names( + def _query_name_rows( self, - starred_first: bool = True, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - user_id: Optional[str] = None, - is_admin: bool = False, - created_date: Optional[str] = None, - ) -> GalleryItemNamesResult: + starred_first: bool, + order_dir: SQLiteDirection, + origin: Optional[ResourceOrigin], + categories: Optional[list[ImageCategory]], + is_intermediate: Optional[bool], + board_id: Optional[str], + search_term: Optional[str], + user_id: Optional[str], + is_admin: bool, + created_date: Optional[str], + ) -> tuple[list[sqlite3.Row], int]: + """Runs the ordered name query and returns its rows plus the starred count. + + Shared by both name-list shapes so the deprecated `(kind, name)` variant and the flat + one can never drift apart in ordering or filtering. + """ image_half, image_params, _ = self._build_half( kind="image", origin=origin, @@ -158,9 +164,66 @@ def list_item_names( if starred_first: starred_count = sum(1 for r in rows if r["starred"]) + return rows, starred_count + + def list_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNamesResult: + rows, starred_count = self._query_name_rows( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + created_date=created_date, + ) refs = [GalleryItemRef(kind=GalleryItemKind(row["kind"]), name=row["name"]) for row in rows] return GalleryItemNamesResult(items=refs, starred_count=starred_count, total_count=len(refs)) + def get_item_names( + self, + starred_first: bool = True, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_date: Optional[str] = None, + ) -> GalleryItemNames: + rows, starred_count = self._query_name_rows( + starred_first=starred_first, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + created_date=created_date, + ) + # A list comprehension over the raw column, deliberately: building one model per row + # is what made the deprecated variant expensive. + names = [row["name"] for row in rows] + return GalleryItemNames(item_names=names, starred_count=starred_count, total_count=len(names)) + def get_dates( self, user_id: Optional[str] = None, diff --git a/invokeai/app/services/orphaned_models/__init__.py b/invokeai/app/services/orphaned_models/__init__.py index db9eaae7bb4..edbab30e3fc 100644 --- a/invokeai/app/services/orphaned_models/__init__.py +++ b/invokeai/app/services/orphaned_models/__init__.py @@ -1,5 +1,9 @@ """Service for finding and removing orphaned model files.""" -from invokeai.app.services.orphaned_models.orphaned_models_service import OrphanedModelInfo, OrphanedModelsService +from invokeai.app.services.orphaned_models.orphaned_models_service import ( + CONVERSION_SCRATCH_DIRNAME, + OrphanedModelInfo, + OrphanedModelsService, +) -__all__ = ["OrphanedModelsService", "OrphanedModelInfo"] +__all__ = ["OrphanedModelsService", "OrphanedModelInfo", "CONVERSION_SCRATCH_DIRNAME"] diff --git a/invokeai/app/services/orphaned_models/orphaned_models_service.py b/invokeai/app/services/orphaned_models/orphaned_models_service.py index 8d2894c8671..2499f69ccaf 100644 --- a/invokeai/app/services/orphaned_models/orphaned_models_service.py +++ b/invokeai/app/services/orphaned_models/orphaned_models_service.py @@ -14,6 +14,12 @@ from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +# Scratch area for operations that must build a model on the models volume before registering it - +# model conversion writes its diffusers copy here. It lives under the models root so the finished +# result can be moved into place without crossing a filesystem boundary, and it is hidden from the +# orphan scan by name (see SKIP_DIRS below). +CONVERSION_SCRATCH_DIRNAME = ".convert_tmp" + class OrphanedModelInfo(BaseModel): """Information about an orphaned model directory.""" @@ -38,10 +44,14 @@ class OrphanedModelsService: ".gguf", } - # Directories to skip during scan + # Directories to skip during scan. An "orphan" is a model file with no database record, which + # is also an exact description of a model an operation is midway through writing - so any + # scratch area under the models root has to be listed here, or a scan taken during that + # operation reports its working directory and the delete route rmtrees it mid-write. SKIP_DIRS = { ".download_cache", ".convert_cache", + CONVERSION_SCRATCH_DIRNAME, "__pycache__", ".git", } diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 52b6d7bd75d..1872ab7759b 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -21,6 +21,7 @@ RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, + SessionQueueItemSummary, SessionQueueStatus, ) from invokeai.app.services.shared.graph import GraphExecutionState @@ -223,6 +224,11 @@ def get_queue_item_ids( """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user.""" pass + @abstractmethod + def get_queue_item_summaries_by_ids(self, queue_id: str, item_ids: list[int]) -> list[SessionQueueItemSummary]: + """Gets lightweight queue item summaries in the requested item ID order.""" + pass + @abstractmethod def get_queue_item(self, item_id: int) -> SessionQueueItem: """Gets a session queue item by ID for a given queue""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d9535c47e00..447893c509f 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -313,6 +313,35 @@ def queue_item_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItem": ) +class SessionQueueItemSummary(BaseModel): + """Queue item fields needed to render the queue list.""" + + item_id: int = Field(description="The identifier of the session queue item") + created_at: Union[datetime.datetime, str] = Field(description="When this queue item was created") + status: QUEUE_ITEM_STATUS = Field(description="The status of this queue item") + device: Optional[str] = Field( + default=None, + description="The device that processed this queue item, e.g. 'cuda:1'", + ) + started_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was started") + completed_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was completed") + origin: str | None = Field(description="The origin of this queue item") + destination: str | None = Field(description="The destination of this queue item") + batch_id: str = Field(description="The ID of the batch associated with this queue item") + user_id: str = Field(description="The ID of the user who created this queue item") + user_display_name: Optional[str] = Field(description="The display name of the user who created this queue item") + user_email: Optional[str] = Field(description="The email of the user who created this queue item") + field_values: Optional[list[NodeFieldValue]] = Field(description="The batch field values used for this queue item") + # Carried because the list rows decide from it whether to offer a retry: a child item of a + # workflow call cannot be retried on its own. + parent_item_id: Optional[int] = Field(description="The ID of the parent queue item, if this is a child item") + + @classmethod + def queue_item_summary_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemSummary": + queue_item_dict["field_values"] = get_field_values(queue_item_dict) + return cls(**queue_item_dict) + + # endregion Queue Items # region Query Results diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 00e82b34064..9a73de77d2e 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -31,6 +31,7 @@ SessionQueueCountsByDestination, SessionQueueItem, SessionQueueItemNotFoundError, + SessionQueueItemSummary, SessionQueueStatus, TooManySessionsError, ValueToInsertTuple, @@ -42,6 +43,11 @@ from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +# Maximum number of ids bound into a single `IN (...)` clause. SQLite's compile-time bind limit is +# 999 on builds older than 3.32 and 32766 on newer ones; staying under the lower figure (leaving +# room for the other bind params in the statement) keeps the queries portable across both. +SQLITE_MAX_BIND_PARAMS_PER_CHUNK = 900 + # Round-robin dequeue (multiuser fairness): pick the next pending item from the user who was # least-recently served. # @@ -1361,6 +1367,48 @@ def get_queue_item_ids( return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids)) + def get_queue_item_summaries_by_ids(self, queue_id: str, item_ids: list[int]) -> list[SessionQueueItemSummary]: + if not item_ids: + return [] + + rows: list[sqlite3.Row] = [] + with self._db.transaction() as cursor: + # Each id becomes one bind parameter, so a single IN (...) would blow past SQLite's + # per-statement variable limit for large id lists. Query in chunks instead - callers + # are bounded at the API layer, but this keeps any caller from hitting that ceiling. + for chunk_start in range(0, len(item_ids), SQLITE_MAX_BIND_PARAMS_PER_CHUNK): + chunk = item_ids[chunk_start : chunk_start + SQLITE_MAX_BIND_PARAMS_PER_CHUNK] + placeholders = ", ".join("?" for _ in chunk) + cursor.execute( + f"""--sql + SELECT + sq.item_id, + sq.created_at, + sq.status, + sq.device, + sq.started_at, + sq.completed_at, + sq.origin, + sq.destination, + sq.batch_id, + sq.user_id, + u.display_name AS user_display_name, + u.email AS user_email, + sq.field_values, + sq.parent_item_id + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + WHERE sq.queue_id = ? AND sq.item_id IN ({placeholders}) + """, + (queue_id, *chunk), + ) + rows.extend(cast(list[sqlite3.Row], cursor.fetchall())) + + summaries_by_id = { + row["item_id"]: SessionQueueItemSummary.queue_item_summary_from_dict(dict(row)) for row in rows + } + return [summaries_by_id[item_id] for item_id in item_ids if item_id in summaries_by_id] + def get_queue_status( self, queue_id: str, diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 3ddc1a03274..8e57f21ab41 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -28,6 +28,19 @@ def __init__(self, db: SqliteDatabase): def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: """Create a new user.""" + return self._create(user_data, strict_password_checking=strict_password_checking, require_no_admin=False) + + def _create( + self, + user_data: UserCreateRequest, + strict_password_checking: bool, + require_no_admin: bool, + ) -> UserDTO: + """Insert a user, optionally conditional on no administrator existing yet. + + `require_no_admin` is evaluated on the cursor that performs the INSERT so that the check + and the write are one atomic step - see `create_admin` for why that matters. + """ # Validate password strength if strict_password_checking: is_valid, error_msg = validate_password_strength(user_data.password) @@ -41,9 +54,22 @@ def create(self, user_data: UserCreateRequest, strict_password_checking: bool = raise ValueError(f"User with email {user_data.email} already exists") user_id = str(uuid4()) + # Hash before opening the transaction: hashing is deliberately slow, and the database is a + # single connection behind a process-wide lock. password_hash = hash_password(user_data.password) with self._db.transaction() as cursor: + if require_no_admin: + # BEGIN IMMEDIATE takes the write lock up front, so this count cannot change + # before the INSERT below commits. In-process callers are additionally + # serialized by the database's shared RLock; the explicit lock also covers a + # second process (invoke-useradd --admin) writing during the setup window. + cursor.execute("BEGIN IMMEDIATE") + # Same predicate as has_admin(), read on the cursor that performs the write. + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE") + row = cursor.fetchone() + if row and row[0] > 0: + raise ValueError("Admin user already exists") try: cursor.execute( """ @@ -255,10 +281,14 @@ def has_admin(self) -> bool: return bool(count > 0) def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: - """Create an admin user (for initial setup).""" - if self.has_admin(): - raise ValueError("Admin user already exists") + """Create the first admin user (for initial setup). + The "no admin exists yet" condition is enforced inside the INSERT's own transaction rather + than by a preceding has_admin() call. `POST /auth/setup` is necessarily unauthenticated + during the first-run window and runs in the threadpool, so two concurrent requests can both + pass a check made in a separate transaction and both create an administrator. Callers may + still check has_admin() first for a friendly error; this is the backstop that decides. + """ # Force is_admin to True admin_data = UserCreateRequest( email=user_data.email, @@ -266,7 +296,7 @@ def create_admin(self, user_data: UserCreateRequest, strict_password_checking: b password=user_data.password, is_admin=True, ) - return self.create(admin_data, strict_password_checking=strict_password_checking) + return self._create(admin_data, strict_password_checking=strict_password_checking, require_no_admin=True) def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: """List all users.""" diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 0499e6f426d..b60aaf3c293 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -5923,8 +5923,9 @@ "get": { "tags": ["images"], "summary": "Get Image Names", - "description": "Gets ordered list of image names with metadata for optimistic updates", + "description": "Gets ordered list of image names with metadata for optimistic updates.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved\nin one ordered list. This image-only endpoint predates the polymorphic gallery.", "operationId": "get_image_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -6972,8 +6973,9 @@ "get": { "tags": ["videos"], "summary": "Get Video Names", - "description": "Gets ordered list of video names with metadata for optimistic updates.", + "description": "Gets ordered list of video names with metadata for optimistic updates.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns images and videos interleaved\nin one ordered list. This video-only endpoint predates the polymorphic gallery.", "operationId": "get_video_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -7475,12 +7477,184 @@ } } }, + "/api/v1/gallery/item_names": { + "get": { + "tags": ["gallery"], + "summary": "List Gallery Item Names", + "description": "Returns the ordered flat list of item names \u2014 used to drive virtualized gallery selection.\n\nNames are polymorphic: image and video names are interleaved by `created_at`. A name ending\nin `.mp4` is a video.", + "operationId": "list_gallery_item_names", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceOrigin" + }, + { + "type": "null" + } + ], + "description": "The origin of items to list.", + "title": "Origin" + }, + "description": "The origin of items to list." + }, + { + "name": "categories", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageCategory" + } + }, + { + "type": "null" + } + ], + "description": "The categories to include. Shared between images and videos.", + "title": "Categories" + }, + "description": "The categories to include. Shared between images and videos." + }, + { + "name": "is_intermediate", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to list intermediate items.", + "title": "Is Intermediate" + }, + "description": "Whether to list intermediate items." + }, + { + "name": "board_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The board id to filter by. Use 'none' to find items without a board.", + "title": "Board Id" + }, + "description": "The board id to filter by. Use 'none' to find items without a board." + }, + { + "name": "created_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards.", + "title": "Created Date" + }, + "description": "Restrict to items created on this ISO date, e.g. '2026-03-18'. Used by date-based virtual boards." + }, + { + "name": "order_dir", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SQLiteDirection", + "description": "The order of sort", + "default": "DESC" + }, + "description": "The order of sort" + }, + { + "name": "starred_first", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to sort by starred items first", + "default": true, + "title": "Starred First" + }, + "description": "Whether to sort by starred items first" + }, + { + "name": "search_term", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The term to search for", + "title": "Search Term" + }, + "description": "The term to search for" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GalleryItemNames" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/gallery/items/names": { "get": { "tags": ["gallery"], "summary": "Get Gallery Item Names", - "description": "Returns an ordered (kind, name) list \u2014 used to drive virtualized gallery selection.", + "description": "Returns an ordered (kind, name) list \u2014 used to drive virtualized gallery selection.\n\nDeprecated: use `GET /v1/gallery/item_names`, which returns the same order as a flat name\nlist. The `kind` discriminator here costs a model per row \u2014 ~800ms on a 200k-item library \u2014\nfor a value callers already derive from the file extension.", "operationId": "get_gallery_item_names", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -8283,8 +8457,9 @@ "get": { "tags": ["virtual_boards"], "summary": "List Virtual Board Image Names By Date", - "description": "Gets ordered image names for a specific date. Image-only; kept for API compatibility \u2014\nthe UI uses the polymorphic `/by_date/{date}/item_names` endpoint.", + "description": "Gets ordered image names for a specific date. Image-only.\n\nDeprecated: use `GET /v1/gallery/item_names?created_date=`, which covers images and\nvideos in one ordered list.", "operationId": "list_virtual_board_image_names_by_date", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -8393,8 +8568,9 @@ "get": { "tags": ["virtual_boards"], "summary": "List Virtual Board Item Names By Date", - "description": "Gets ordered polymorphic (image + video) item refs for a specific date.", + "description": "Gets ordered polymorphic (image + video) item refs for a specific date.\n\nDeprecated: use `GET /v1/gallery/item_names?created_date=`, which returns the same\norder as a flat name list instead of one model per item.", "operationId": "list_virtual_board_item_names_by_date", + "deprecated": true, "security": [ { "HTTPBearer": [] @@ -9436,6 +9612,68 @@ } } }, + "/api/v1/queue/{queue_id}/item_summaries_by_ids": { + "post": { + "tags": ["queue"], + "summary": "Get Queue Item Summaries By Ids", + "description": "Gets lightweight queue item summaries for specified IDs in requested order.", + "operationId": "get_queue_item_summaries_by_ids", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id to perform this operation on", + "title": "Queue Id" + }, + "description": "The queue id to perform this operation on" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_get_queue_item_summaries_by_ids" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionQueueItemSummary" + }, + "title": "Response 200 Get Queue Item Summaries By Ids" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/queue/{queue_id}/processor/resume": { "put": { "tags": ["queue"], @@ -15725,7 +15963,7 @@ "anyOf": [ { "type": "string", - "format": "binary" + "contentMediaType": "application/octet-stream" }, { "type": "null" @@ -15884,6 +16122,22 @@ "required": ["image_names"], "title": "Body_get_images_by_names" }, + "Body_get_queue_item_summaries_by_ids": { + "properties": { + "item_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "maxItems": 1000, + "title": "Item Ids", + "description": "Object containing list of queue item ids to fetch summaries for" + } + }, + "type": "object", + "required": ["item_ids"], + "title": "Body_get_queue_item_summaries_by_ids" + }, "Body_get_queue_items_by_item_ids": { "properties": { "item_ids": { @@ -15903,7 +16157,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File", "description": "The file to import" } @@ -15993,7 +16247,7 @@ "properties": { "image": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "Image", "description": "The image file to upload" } @@ -16036,7 +16290,7 @@ "properties": { "image": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "Image" } }, @@ -16050,7 +16304,7 @@ "anyOf": [ { "type": "string", - "format": "binary" + "contentMediaType": "application/octet-stream" }, { "type": "null" @@ -16096,7 +16350,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File" }, "resize_to": { @@ -16133,7 +16387,7 @@ "properties": { "file": { "type": "string", - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File" }, "metadata": { @@ -33600,6 +33854,32 @@ "title": "GalleryItemKind", "description": "Discriminator for polymorphic gallery items." }, + "GalleryItemNames": { + "properties": { + "item_names": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Item Names", + "description": "Ordered list of image and video names." + }, + "starred_count": { + "type": "integer", + "title": "Starred Count", + "description": "Number of starred items (when starred_first=True)." + }, + "total_count": { + "type": "integer", + "title": "Total Count", + "description": "Total number of items matching the query." + } + }, + "type": "object", + "required": ["item_names", "starred_count", "total_count"], + "title": "GalleryItemNames", + "description": "Ordered flat list of gallery item names plus counts for optimistic UI.\n\nNames are polymorphic \u2014 images and videos are interleaved by `created_at`. The kind of a\ngiven name is its file extension (`.mp4` is a video), which is how every consumer already\ndiscriminates. Mirrors the shape of the image-only `ImageNamesResult`." + }, "GalleryItemNamesResult": { "properties": { "items": { @@ -33624,7 +33904,7 @@ "type": "object", "required": ["items", "starred_count", "total_count"], "title": "GalleryItemNamesResult", - "description": "Ordered list of gallery item references plus counts for optimistic UI." + "description": "Ordered list of gallery item references plus counts for optimistic UI.\n\nDeprecated in favour of :class:`GalleryItemNames`. Wrapping every name in an object to\ncarry a `kind` discriminator costs ~800ms of model construction on a 200k-item library,\nfor a field callers derive from the filename extension anyway." }, "GalleryItemRef": { "properties": { @@ -78974,6 +79254,181 @@ "title": "SessionQueueItem", "description": "Session queue item without the full graph. Used for serialization." }, + "SessionQueueItemSummary": { + "properties": { + "item_id": { + "type": "integer", + "title": "Item Id", + "description": "The identifier of the session queue item" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + } + ], + "title": "Created At", + "description": "When this queue item was created" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"], + "title": "Status", + "description": "The status of this queue item" + }, + "device": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device", + "description": "The device that processed this queue item, e.g. 'cuda:1'" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "When this queue item was started" + }, + "completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At", + "description": "When this queue item was completed" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Origin", + "description": "The origin of this queue item" + }, + "destination": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination", + "description": "The destination of this queue item" + }, + "batch_id": { + "type": "string", + "title": "Batch Id", + "description": "The ID of the batch associated with this queue item" + }, + "user_id": { + "type": "string", + "title": "User Id", + "description": "The ID of the user who created this queue item" + }, + "user_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Display Name", + "description": "The display name of the user who created this queue item" + }, + "user_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Email", + "description": "The email of the user who created this queue item" + }, + "field_values": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/NodeFieldValue" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Field Values", + "description": "The batch field values used for this queue item" + }, + "parent_item_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Parent Item Id", + "description": "The ID of the parent queue item, if this is a child item" + } + }, + "type": "object", + "required": [ + "item_id", + "created_at", + "status", + "started_at", + "completed_at", + "origin", + "destination", + "batch_id", + "user_id", + "user_display_name", + "user_email", + "field_values", + "parent_item_id" + ], + "title": "SessionQueueItemSummary", + "description": "Queue item fields needed to render the queue list." + }, "SessionQueueStatus": { "properties": { "queue_id": { @@ -86453,6 +86908,13 @@ "type": { "type": "string", "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" } }, "type": "object", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index d4af42ca681..30be0e4d1df 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -418,6 +418,7 @@ "queueActionsMenu": "Queue Actions Menu", "queueEmpty": "Queue Empty", "queueItem": "Queue Item", + "queueItemLoadFailed": "This queue item could not be loaded.", "enqueueing": "Queueing Batch", "resume": "Resume", "resumeTooltip": "Resume Processor", diff --git a/invokeai/frontend/web/scripts/typegen.js b/invokeai/frontend/web/scripts/typegen.js index 87c00a28833..f526b149466 100644 --- a/invokeai/frontend/web/scripts/typegen.js +++ b/invokeai/frontend/web/scripts/typegen.js @@ -24,7 +24,13 @@ async function generateTypes(schema) { const types = await openapiTS(schema, { exportType: true, transform: (schemaObject) => { - if ('format' in schemaObject && schemaObject.format === 'binary') { + // File upload fields. FastAPI emitted `format: binary` up to 0.129 and switched to the + // OpenAPI 3.1 form `contentMediaType: application/octet-stream` in 0.130 — both must map + // to `Blob`, or upload call sites silently start typing their `File` argument as `string`. + const isBinary = + ('format' in schemaObject && schemaObject.format === 'binary') || + ('contentMediaType' in schemaObject && schemaObject.contentMediaType === 'application/octet-stream'); + if (isBinary) { return schemaObject.nullable ? ts.factory.createUnionTypeNode([BLOB, NULL]) : BLOB; } if (schemaObject.title === 'MetadataField') { diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index a53b5e6767f..aa1a03986b6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,4 +1,4 @@ -import { createAction, isAnyOf } from '@reduxjs/toolkit'; +import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; import { noop } from 'es-toolkit'; import { setInfillMethod } from 'features/controlLayers/store/paramsSlice'; @@ -6,7 +6,6 @@ import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors' import { imageSelected } from 'features/gallery/store/gallerySlice'; import { appInfoApi } from 'services/api/endpoints/appInfo'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; export const appStarted = createAction('app/appStarted'); @@ -31,20 +30,14 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { .catch(noop); // Ensure a gallery item is selected when we load the first board. The grid is fed by the - // polymorphic `getGalleryItemNames` endpoint (image + video names interleaved by date), + // polymorphic `listGalleryItemNames` endpoint (image + video names interleaved by date), // so that's what we wait on — the older `getImageNames` is no longer dispatched and would - // time out forever. + // time out forever. Date-based virtual boards go through the same endpoint. // // The effect must be async and await take() so that RTK keeps the listener's AbortController // alive until the query resolves; a synchronous effect causes the controller to be aborted // immediately after the effect returns, before any network response arrives. - const firstLoad = await take( - isAnyOf( - galleryApi.endpoints.getGalleryItemNames.matchFulfilled, - virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.matchFulfilled - ), - 5000 - ); + const firstLoad = await take(galleryApi.endpoints.listGalleryItemNames.matchFulfilled, 5000); if (firstLoad === null) { // timeout or cancelled return; @@ -54,9 +47,9 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { if (selectedItem) { return; } - const firstItem = payload.items[0]; - if (firstItem) { - dispatch(imageSelected(firstItem.name)); + const firstItemName = payload.item_names[0]; + if (firstItemName) { + dispatch(imageSelected(firstItemName)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 65d2af4437b..05dd8e9f208 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,10 +1,8 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { startAppListening({ @@ -18,23 +16,11 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) return; } - const state = getState(); - - const board_id = selectSelectedBoardId(state); - - // The grid is now backed by the polymorphic getGalleryItemNames endpoint (the legacy + // The grid is backed by the polymorphic listGalleryItemNames endpoint (the legacy // getImageNames query is no longer dispatched), so the auto-select probe must read its - // cache or it will time out and clear the user's selection on every board switch. - const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id }; - const selectQuery = isVirtualBoardId(board_id) - ? virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select({ - date: getDateFromVirtualBoardId(board_id), - categories: queryArgs.categories ?? undefined, - search_term: queryArgs.search_term || undefined, - order_dir: queryArgs.order_dir, - starred_first: queryArgs.starred_first, - }) - : galleryApi.endpoints.getGalleryItemNames.select(queryArgs); + // cache or it will time out and clear the user's selection on every board switch. The + // selector already maps a virtual board id to its `created_date` filter. + const selectQuery = galleryApi.endpoints.listGalleryItemNames.select(selectGalleryItemNamesQueryArgs(getState())); // wait until the board has some items - maybe it already has some from a previous fetch // must use getState() to ensure we do not have stale state const isSuccess = await condition(() => selectQuery(getState()).isSuccess, 5000); @@ -45,11 +31,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) } // the board was just changed - we can select the first gallery item (image or video) - const items = selectQuery(getState()).data?.items; - - const itemToSelect = items && items.length > 0 ? (items[0]?.name ?? null) : null; + const itemNames = selectQuery(getState()).data?.item_names; - dispatch(imageSelected(itemToSelect)); + dispatch(imageSelected(itemNames?.[0] ?? null)); }, }); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index b62a8b01c8d..7a33001384d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -3,7 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { getVideoPrefetchOptions, useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectImageToCompare, @@ -42,7 +42,7 @@ import { scrollIntoView } from './scrollIntoView'; import { useGalleryImageNames } from './use-gallery-image-names'; import { useScrollableGallery } from './useScrollableGallery'; -type ListImageNamesQueryArgs = ReturnType; +type ListImageNamesQueryArgs = ReturnType; type GridContext = { queryArgs: ListImageNamesQueryArgs; diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts index eff9123cc61..a42ae3d3122 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.test.ts @@ -1,34 +1,38 @@ /** - * Pins the polymorphic name-flattening used by `useGalleryImageNames` for both regular boards - * and date-based virtual boards. + * Pins the translation of a selected board into name-list query args. * - * The bug (PR #9163 review): virtual boards were image-only — selecting a virtual date fetched - * from the legacy image_names endpoint, so videos created on that date never appeared. The hook - * now consumes the by-date item_names endpoint, which returns the same (kind, name) refs as the - * regular gallery names endpoint, and this shared mapper must keep video refs in the flat list. - * (The server-side guarantee that a date query returns video refs is pinned by - * tests/app/routers/test_virtual_boards.py.) + * A virtual board is a date, not a board row. Regular boards and virtual dates now share one + * endpoint (`listGalleryItemNames`), so the only thing keeping virtual dates working is that + * the id is converted into a `created_date` filter and *not* forwarded as `board_id` — the + * backend would filter on a board that does not exist and return an empty gallery. + * + * The original bug this area guards (PR #9163 review): virtual boards were image-only, so + * videos created on that date never appeared. The server-side half of that guarantee is pinned + * by tests/app/routers/test_virtual_boards.py. */ -import type { GalleryItemRef } from 'services/api/types'; +import { createStore } from 'app/store/store'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { describe, expect, it } from 'vitest'; -import { itemRefsToNames } from './use-gallery-image-names'; +describe('selectGalleryItemNamesQueryArgs', () => { + it('converts a virtual board id into a created_date filter', () => { + const store = createStore(); + store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); + + const args = selectGalleryItemNamesQueryArgs(store.getState()); -describe('itemRefsToNames', () => { - it('keeps video refs interleaved with images, preserving order', () => { - const items: GalleryItemRef[] = [ - { kind: 'image', name: 'newest.png' }, - { kind: 'video', name: 'middle.mp4' }, - { kind: 'image', name: 'oldest.png' }, - ]; - expect(itemRefsToNames(items)).toEqual(['newest.png', 'middle.mp4', 'oldest.png']); + expect(args.created_date).toBe('2026-07-26'); + expect(args.board_id).toBeUndefined(); }); - it('handles a video-only list (video-only virtual date)', () => { - const items: GalleryItemRef[] = [ - { kind: 'video', name: 'a.mp4' }, - { kind: 'video', name: 'b.mp4' }, - ]; - expect(itemRefsToNames(items)).toEqual(['a.mp4', 'b.mp4']); + it('passes a regular board id through untouched', () => { + const store = createStore(); + store.dispatch(boardIdSelected({ boardId: 'some-board-uuid' })); + + const args = selectGalleryItemNamesQueryArgs(store.getState()); + + expect(args.board_id).toBe('some-board-uuid'); + expect(args.created_date).toBeUndefined(); }); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index 4749dc9de8b..d4275b39d56 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,12 +1,7 @@ -import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; -import { useMemo } from 'react'; -import { useGetGalleryItemNamesQuery } from 'services/api/endpoints/gallery'; -import { useGetVirtualBoardItemNamesByDateQuery } from 'services/api/endpoints/virtual_boards'; -import type { GalleryItemRef } from 'services/api/types'; +import { selectGalleryItemNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useListGalleryItemNamesQuery } from 'services/api/endpoints/gallery'; import { useDebounce } from 'use-debounce'; const selectFromGalleryItemNamesResult = ({ @@ -14,11 +9,11 @@ const selectFromGalleryItemNamesResult = ({ isLoading, isFetching, }: { - currentData?: { items: GalleryItemRef[] }; + currentData?: { item_names: string[] }; isLoading: boolean; isFetching: boolean; }) => ({ - items: currentData?.items ?? (EMPTY_ARRAY as GalleryItemRef[]), + imageNames: currentData?.item_names ?? (EMPTY_ARRAY as string[]), isLoading, isFetching, }); @@ -28,56 +23,19 @@ const galleryQueryOptions = { selectFromResult: selectFromGalleryItemNamesResult, }; -/** - * Flattens polymorphic (kind, name) refs into the ordered name list consumed by the gallery - * grid and navigation hotkeys. Video refs must pass through untouched — regular boards and - * date-based virtual boards both contain them. Exported for tests. - */ -export const itemRefsToNames = (items: GalleryItemRef[]): string[] => items.map((ref) => ref.name); - /** * Returns the ordered flat list of gallery item names. Names are polymorphic — both image and * video names appear in the same list, interleaved by created_at. Callers that need to know the * kind of a particular name use `isVideoName` from `features/gallery/store/types`. * - * Virtual boards (date-based) go through their own by-date endpoint, which returns the same - * polymorphic (kind, name) refs as the regular gallery names endpoint. + * Regular boards and date-based virtual boards share one endpoint; the selector translates a + * virtual board id into the `created_date` filter. */ export const useGalleryImageNames = () => { - const selectedBoardId = useAppSelector(selectSelectedBoardId); - const _imageQueryArgs = useAppSelector(selectGetImageNamesQueryArgs); - const [imageQueryArgs] = useDebounce(_imageQueryArgs, 300); - const isVirtual = isVirtualBoardId(selectedBoardId); - - // The polymorphic gallery names endpoint shares the same filter args as the image names - // endpoint (board_id, categories, search_term, order_dir, starred_first, is_intermediate). - const galleryResult = useGetGalleryItemNamesQuery(isVirtual ? skipToken : imageQueryArgs, galleryQueryOptions); - - const date = isVirtual ? getDateFromVirtualBoardId(selectedBoardId) : ''; - const virtualResult = useGetVirtualBoardItemNamesByDateQuery( - isVirtual - ? { - date, - categories: imageQueryArgs.categories ?? undefined, - search_term: imageQueryArgs.search_term || undefined, - order_dir: imageQueryArgs.order_dir, - starred_first: imageQueryArgs.starred_first, - } - : skipToken, - galleryQueryOptions - ); + const _queryArgs = useAppSelector(selectGalleryItemNamesQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 300); - // Flat names + isLoading exposed for backward compatibility with the existing callers (paged - // grid, search, navigation hotkeys). The kind is recoverable from the filename extension. - const imageNames = useMemo(() => { - const items = isVirtual ? virtualResult.items : galleryResult.items; - return itemRefsToNames(items); - }, [isVirtual, virtualResult.items, galleryResult.items]); + const { imageNames, isLoading, isFetching } = useListGalleryItemNamesQuery(queryArgs, galleryQueryOptions); - return { - imageNames, - isLoading: isVirtual ? virtualResult.isLoading : galleryResult.isLoading, - isFetching: isVirtual ? virtualResult.isFetching : galleryResult.isFetching, - queryArgs: imageQueryArgs, - }; + return { imageNames, isLoading, isFetching, queryArgs }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index aad849fdb59..54c65c541ea 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,8 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { GetImageNamesArgs, ListBoardsArgs } from 'services/api/types'; +import { + ASSETS_CATEGORIES, + getDateFromVirtualBoardId, + IMAGE_CATEGORIES, + isVirtualBoardId, +} from 'features/gallery/store/types'; +import type { GetImageNamesArgs, ListBoardsArgs, ListGalleryItemNamesArgs } from 'services/api/types'; export const selectFirstSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -48,6 +53,25 @@ export const selectGetImageNamesQueryArgs = createMemoizedSelector( }) ); +/** + * Query args for the polymorphic name list the gallery grid runs off. + * + * A virtual board is a date, not a board: its id carries the date and there is no board row to + * filter on. Translating it to `created_date` here keeps that translation in one place — every + * consumer of the name list (grid, range selection, auto-select probes) shares this selector, + * so none of them can disagree about the cache key. + */ +export const selectGalleryItemNamesQueryArgs = createMemoizedSelector( + [selectGetImageNamesQueryArgs], + (args): ListGalleryItemNamesArgs => { + if (args.board_id && isVirtualBoardId(args.board_id)) { + const { board_id: _virtualBoardId, ...rest } = args; + return { ...rest, created_date: getDateFromVirtualBoardId(args.board_id) }; + } + return args; + } +); + export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts b/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts index f07a251b1bf..aa935c88dae 100644 --- a/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts +++ b/invokeai/frontend/web/src/features/gallery/store/selectCachedGalleryItemNames.ts @@ -1,18 +1,16 @@ import type { AppGetState } from 'app/store/store'; -import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types'; import { galleryApi } from 'services/api/endpoints/gallery'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; -import type { GetGalleryItemNamesArgs } from 'services/api/types'; +import type { ListGalleryItemNamesArgs } from 'services/api/types'; -import { selectGetImageNamesQueryArgs } from './gallerySelectors'; +import { selectGalleryItemNamesQueryArgs } from './gallerySelectors'; /** * Returns the names (in display order) of the currently-cached gallery item list. * - * The grid renders via the polymorphic ``getGalleryItemNames`` endpoint, which returns a - * mixed image+video list. Range-selection click handlers (shift-click for ranges, ctrl-click - * for discontiguous selection) need that ordered list to compute the items between two - * clicks. + * The grid renders via the polymorphic ``listGalleryItemNames`` endpoint, which returns a + * mixed image+video list — regular boards and date-based virtual boards alike. Range-selection + * click handlers (shift-click for ranges, ctrl-click for discontiguous selection) need that + * ordered list to compute the items between two clicks. * * We look up the cache entry whose args match the gallery's current query args. RTK Query * keeps recently-used entries warm (60s default ``keepUnusedDataFor``), so after a board @@ -23,68 +21,32 @@ import { selectGetImageNamesQueryArgs } from './gallerySelectors'; * forced a refetch. */ export const selectCachedGalleryItemNames = (state: ReturnType): string[] => { - const args = selectGetImageNamesQueryArgs(state); - if (args.board_id && isVirtualBoardId(args.board_id)) { - const virtualArgs = { - date: getDateFromVirtualBoardId(args.board_id), - categories: args.categories ?? undefined, - search_term: args.search_term || undefined, - order_dir: args.order_dir, - starred_first: args.starred_first, - }; - const virtual = virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select(virtualArgs)(state).data; - if (virtual) { - return virtual.items.map((ref) => ref.name); - } - const entries = virtualBoardsApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']); - let mostRecent: - | { - names: string[]; - fulfilledTimeStamp: number; - } - | undefined; - for (const entry of entries) { - if (entry.endpointName !== 'getVirtualBoardItemNamesByDate') { - continue; - } - const entryArgs = entry.originalArgs as typeof virtualArgs; - if (entryArgs.date !== virtualArgs.date) { - continue; - } - const query = virtualBoardsApi.endpoints.getVirtualBoardItemNamesByDate.select(entryArgs)(state); - if (query.data && (query.fulfilledTimeStamp ?? 0) >= (mostRecent?.fulfilledTimeStamp ?? -1)) { - mostRecent = { - names: query.data.items.map((ref) => ref.name), - fulfilledTimeStamp: query.fulfilledTimeStamp ?? 0, - }; - } - } - return mostRecent?.names ?? []; - } + const args = selectGalleryItemNamesQueryArgs(state); // Exact match: the entry the grid is actively subscribed to. This is the common case. - const exact = galleryApi.endpoints.getGalleryItemNames.select(args)(state).data; + const exact = galleryApi.endpoints.listGalleryItemNames.select(args)(state).data; if (exact) { - return exact.items.map((ref) => ref.name); + return exact.item_names; } // Debounce window: the grid hook debounces its args by ~300ms, so for a moment after the - // user changes a filter the cache key may not match Redux yet. Best-effort fallback to any - // cached entry on the same board so range selection still feels responsive — but do not - // silently fall back to an unrelated board's entry, which was the bug. + // user changes a filter the cache key may not match Redux yet. Best-effort fallback to the + // most recent cached entry for the same board or date, so range selection still feels + // responsive — but do not silently fall back to an unrelated board's entry, which was the bug. const entries = galleryApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']); + let mostRecent: { names: string[]; fulfilledTimeStamp: number } | undefined; for (const entry of entries) { - if (entry.endpointName !== 'getGalleryItemNames') { + if (entry.endpointName !== 'listGalleryItemNames') { continue; } - const entryArgs = entry.originalArgs as GetGalleryItemNamesArgs | undefined; - if (!entryArgs || entryArgs.board_id !== args.board_id) { + const entryArgs = entry.originalArgs as ListGalleryItemNamesArgs | undefined; + if (!entryArgs || entryArgs.board_id !== args.board_id || entryArgs.created_date !== args.created_date) { continue; } - const data = galleryApi.endpoints.getGalleryItemNames.select(entryArgs)(state).data; - if (data) { - return data.items.map((ref) => ref.name); + const query = galleryApi.endpoints.listGalleryItemNames.select(entryArgs)(state); + if (query.data && (query.fulfilledTimeStamp ?? 0) >= (mostRecent?.fulfilledTimeStamp ?? -1)) { + mostRecent = { names: query.data.item_names, fulfilledTimeStamp: query.fulfilledTimeStamp ?? 0 }; } } - return []; + return mostRecent?.names ?? []; }; /** diff --git a/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts b/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts index e66b4870425..d4c3d94db02 100644 --- a/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts +++ b/invokeai/frontend/web/src/features/gallery/store/virtualBoardGalleryConsumers.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { createStore } from 'app/store/store'; import { boardIdSelected, searchTermChanged } from 'features/gallery/store/gallerySlice'; -import { virtualBoardsApi } from 'services/api/endpoints/virtual_boards'; +import { galleryApi } from 'services/api/endpoints/gallery'; import { describe, expect, it } from 'vitest'; import { selectCachedGalleryItemNames } from './selectCachedGalleryItemNames'; @@ -27,29 +27,28 @@ describe('virtual board gallery consumers', () => { ['range selection', './selectCachedGalleryItemNames.ts'], ['board auto-selection', '../../../app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts'], ['initial board auto-selection', '../../../app/store/middleware/listenerMiddleware/listeners/appStarted.ts'], - ])('%s reads the virtual-board item-name cache', (_label, relativePath) => { + ])('%s reads the polymorphic item-name cache', (_label, relativePath) => { const source = readSource(relativePath); - expect(source).toContain('getVirtualBoardItemNamesByDate'); + // Regular boards and virtual dates share one endpoint; a consumer that reached for a + // different cache would silently see an empty list on virtual dates. + expect(source).toContain('listGalleryItemNames'); }); it('keeps the prior virtual-board cache available during filter debounce', async () => { const store = createStore(); store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'cached.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['cached.mp4'], starred_count: 0, total_count: 1 } ) ); @@ -62,39 +61,33 @@ describe('virtual board gallery consumers', () => { const store = createStore(); store.dispatch(boardIdSelected({ boardId: 'by_date:2026-07-26' })); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], search_term: 'older filter', order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'older.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['older.mp4'], starred_count: 0, total_count: 1 } ) ); await new Promise((resolve) => { setTimeout(resolve, 5); }); await store.dispatch( - virtualBoardsApi.util.upsertQueryData( - 'getVirtualBoardItemNamesByDate', + galleryApi.util.upsertQueryData( + 'listGalleryItemNames', { - date: '2026-07-26', + created_date: '2026-07-26', categories: ['general'], order_dir: 'DESC', starred_first: true, + is_intermediate: false, }, - { - items: [{ name: 'active.mp4', kind: 'video' }], - starred_count: 0, - total_count: 1, - } + { item_names: ['active.mp4'], starred_count: 0, total_count: 1 } ) ); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index 9583a3b2349..8316f682d2c 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -23,7 +23,7 @@ const selectedStyles = { bg: 'base.700' }; type InnerItemProps = { index: number; - item: S['SessionQueueItem']; + item: S['SessionQueueItemSummary']; }; const sx: ChakraProps['sx'] = { diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx index 02c25761c57..04e2b64bb80 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx @@ -18,24 +18,26 @@ import type { S } from 'services/api/types'; import { getQueueItemActionVisibility } from './getQueueItemActionVisibility'; type Props = { - queueItem: S['SessionQueueItem']; + queueItem: S['SessionQueueItemSummary']; }; -const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => { - const { session_id, batch_id, item_id, origin, destination } = queueItemDTO; +const QueueItemComponent = ({ queueItem: queueItemSummary }: Props) => { + const { batch_id, item_id, origin, destination } = queueItemSummary; const { t } = useTranslation(); const isBatchCanceled = useBatchIsCanceled(batch_id); const cancelBatch = useCancelBatch(); const cancelQueueItem = useCancelQueueItem(); const retryQueueItem = useRetryQueueItem(); - const { data: queueItem } = useGetQueueItemQuery(item_id); + const { data: queueItem, isError } = useGetQueueItemQuery(item_id); const originText = useOriginText(origin); const destinationText = useDestinationText(destination); const statusAndTiming = useMemo(() => { if (!queueItem) { - return t('common.loading'); + // Distinguish the two, or a queue item the backend cannot serve — one whose graph + // references a node type this build no longer registers, say — reads as loading forever. + return isError ? t('common.error') : t('common.loading'); } if (!queueItem.completed_at || !queueItem.started_at) { return t(`queue.${queueItem.status}`); @@ -45,7 +47,7 @@ const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => { return `${t('queue.completedIn')} ${seconds}${seconds === 1 ? '' : 's'}`; } return `${seconds}s`; - }, [queueItem, t]); + }, [isError, queueItem, t]); const isCanceled = useMemo( () => !!queueItem && ['canceled', 'completed', 'failed'].includes(queueItem.status), @@ -54,8 +56,8 @@ const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => { const isFailed = useMemo(() => !!queueItem && ['canceled', 'failed'].includes(queueItem.status), [queueItem]); const { canShowCancelQueueItem, canShowRetryQueueItem } = useMemo( - () => getQueueItemActionVisibility(queueItemDTO), - [queueItemDTO] + () => getQueueItemActionVisibility(queueItemSummary), + [queueItemSummary] ); const onCancelBatch = useCallback(() => { @@ -86,7 +88,10 @@ const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => { - + {canShowCancelQueueItem && !isFailed && (