fix(api): stop synchronous route work from stalling the whole server - #9436
fix(api): stop synchronous route work from stalling the whole server#9436Pfannkuchensack wants to merge 20 commits into
Conversation
The gallery list/name routes and the auth dependencies were declared `async def` while calling synchronous SQLite services, so their database work ran on the event loop. For its whole duration the process served no other request and delivered no socket.io event, which users experienced as the backend freezing mid-generation rather than as a slow gallery. Declaring them `def` hands them to Starlette's threadpool instead. Measured against a 200k-image, 1.7 GB database: the latency of an unrelated request issued while a gallery name query is in flight drops from 1662 ms to 4 ms (search) and from 2298 ms to 881 ms (no search). The queries themselves are unchanged; only the loop is freed. The residual 881 ms in the no-search case is response serialization of 202k items, which is tracked separately. Adds a regression test that stubs a blocking service call and asserts an unrelated route still answers during it, plus a contributor doc describing the rule.
…y ones The name list that drives the virtualized gallery wrapped every entry in an object carrying a `kind` discriminator. Building those models cost 820ms of the 2225ms service call on a 200k-item library, and every consumer threw the field away — `itemRefsToNames` mapped it off immediately and each caller re-derived the kind from the file extension via `isVideoName`. Adds `GET /v1/gallery/item_names`, returning a flat name list in the same shape as the image-only `ImageNamesResult`. An optional `created_date` filter subsumes the separate by-date virtual-board route, so regular boards and virtual dates now share one endpoint, one cache and one query-args selector instead of a skipToken branch duplicated across the grid hook, range selection and both auto-select listeners. Measured on a 200k-image, 1.7 GB database: 2.51s -> 1.57s per request, 8.48 MB -> 3.85 MB of response, and the residual event-loop stall from serializing the response drops from 466ms to 102ms at p95. Existing integrations still call the old routes, so all five legacy name endpoints keep working and are marked `deprecated=True` with a pointer to the replacement.
Starlette's GZipMiddleware compresses every response type except text/event-stream, so every image and video the gallery serves was being deflate-compressed a second time. Measured: a 1024x1024 PNG (3.00 MB) costs 52ms of event-loop time to gzip and comes back at 3.01 MB — larger than it went in; a 2048x2048 PNG costs 210ms for the same non-result. Compression runs on the event loop, so that time is a full stall of the process. With auto-switch enabled the UI fetches the full image after every generated image, so the cost lands repeatedly during a batch. Replaces it with a content-type-aware subclass that compresses an allowlist of text, JSON, XML and SVG responses and passes everything else through. The UI bundle and the API's JSON keep their compression unchanged. Lowering compresslevel is not an alternative for this case: on already-compressed input level 1 costs 51ms against level 9's 52ms, because deflate still scans the whole body. Making the level configurable is worthwhile for the *compressible* path and is tracked separately. Note for deployments: media responses no longer carry Content-Encoding: gzip.
The pin sat at 0.118.3 with a comment guessing the OpenAPI crash on 0.119 was "probably Invoke's [bug], because we are doing something unusual with AnyInvocation". It was not: fastapi/_compat/v2.py assumed every field mapping carries a `$ref` and raised KeyError otherwise. Upstream fixed it in 0.124.0 with no change needed here. Two later changes needed adapting to, both of which fail silently: - 0.130 emits `contentMediaType: application/octet-stream` instead of `format: binary` for file uploads. typegen.js mapped only the latter to `Blob`, so upload call sites would have started typing their `File` argument as `string`. It now maps both. - 0.141 keeps an included router as a single node in `app.routes` instead of copying its routes into it. The default-deny auth guard walked `app.routes` looking for APIRoute instances and found 2 of 197 — passing while inspecting almost nothing. It now walks `iter_route_contexts`, the traversal FastAPI's own OpenAPI generation uses, and asserts a floor on the route count so going blind fails loudly instead. Schema changes are limited to ValidationError gaining the optional `input`/`ctx` fields; upload fields still resolve to Blob. Starlette stays at 0.48.0.
Package A converted the eight gallery and search routes that caused the reported multi-minute stalls. The same defect was present across the rest of the API: 167 route handlers were declared `async def` while awaiting nothing, so their synchronous service calls ran on the event loop. Each one stalls the entire process for its duration - no other request served, no socket.io event delivered - which is why the symptom looked like the application freezing rather than one slow endpoint. Candidates were identified by AST rather than by hand: `async def` route handlers with no `await`, `async with` or `async for` anywhere in the body, cross-checked for references to asyncio, anyio or the loop. Two flagged candidates were false positives (both the word "loop" in a comment). The diff is 167 signature lines plus one signature that ruff collapsed onto a single line once `async ` was removed. Adds tests/app/routers/test_no_blocking_async_routes.py, which enforces the rule for every handler including ones written later - a per-route test cannot cover a route that does not exist yet, and this failure mode is invisible until a user has a large enough library to notice. Two tests that invoked route handlers directly were updated to call them as the plain functions they now are.
|
I was just bumbling around yesterday trying to figure out Invoke's approach to concurrency, as I realized the FastAPI handlers are async but most of our code (including the BaseInvocation API) very much is not.
Wait, what? This is just removing "async" from 167 existing "async def"? Is that…? Okay, the explanation for how this works is at FastAPI, Concurrency and async / await: FastAPI automatically kicks any non-async function to a thread pool. In an async-first application, "parse every router module and fail if any handler is async def without awaiting" is really not the heuristic you want to use. You want to be async by default and only introduce the complexity of thread switching if you're doing a blocking operation. Especially in Python, where threads don't actually get you multi-core parallel execution. The aforementioned FastAPI docs back me up on that:
However… in a codebase where most of the code is written synchronously and people aren't used to thinking about whether they're about to call a blocking function? As a former Twisted developer, it hurts me to even think it, but no-async-by-default might be the right call, I guess? We're not trying to optimize requests/second throughput, as the number of users and frequency of requests on any one InvokeAI sever is actually pretty low. We're trying to reduce the chances of someone accidentally making a commit that blocks the server's event loop. So. I can't say I'm a fan, but I guess I understand why you might want to do it that way. I still feel tempted to argue for some kind of "it doesn't use |
|
I guess the hazard of "kick it to the thread pool by default" is then all your code has to be thread-safe by default. Which I don't think is an easier/safer assumption to make than knowing if your code is blocking. i.e. does sending all sqlite-related activity to a general-purpose thread-pool mean we have multiple threads opening and writing to the same sqlite database at once? Is that a thing we can assume it's safe to do? |
Worth answering precisely, because the premise is slightly off in a way that matters. We never open a connection per thread. There is exactly one And this predates the PR. Multiple threads already write to that database on every generation: What does change, and I don't want to gloss over it: two On the general point, I'd argue the two assumptions aren't symmetric. "Does this handler block?" is |
Fair challenge, and the FastAPI docs quote is right — but note its condition: "unless your path Two things I could measure rather than argue: The thread hop costs ~156 µs. Trivial handler, in-process, 600 requests: 0.320 ms as The high-volume route you're hoping is a StaticFile isn't one. One clarification on the GIL point: for pure-Python CPU work you're right that threads buy nothing. But On the escape hatch — you're right that "doesn't await" ≠ "doesn't block", and the guard is deliberately |
lstein
left a comment
There was a problem hiding this comment.
Reviewed adversarially at 83683696e5 — the goal was to assume the change is broken and prove it, not to evaluate it. The core diagnosis and the mechanism fix are correct and unusually well evidenced; the write-up made the review much faster than it would otherwise have been. Two findings, both reproduced rather than argued.
1. POST /api/v1/auth/setup loses its atomicity → duplicate-admin race
setup_admin went async def → def. An async def handler whose body contains no await cannot yield, so has_admin() → create_admin() was an atomic check-then-act. Dispatched to the threadpool, it is not.
Triggering sequence: multiuser mode, no admin yet (the endpoint is unauthenticated by necessity in this window). Two concurrent POST /api/v1/auth/setup with different emails. Both threads run has_admin(), both see no admin, both create_admin(). Against the real app, with a has_admin that returns its result after a delay (modelling the SELECT returning and the thread being descheduled before the INSERT):
statuses: [200, 200]
admins created: ['attacker0@x.com', 'attacker1@x.com']
Minimal A/B confirming this is the change and not something pre-existing — same body, same two concurrent requests, only the def/async def keyword differs:
async def -> [True, False] admins: 1 # main
def -> [True, True] admins: 2 # this PR
users_default.create_admin re-checks has_admin(), which does not close the window — both checks precede the INSERT. Before: a racer got a deterministic 400. Now: a persistent admin account. Fix is cheap — a threading.Lock around check+create inside UserService, or a partial UNIQUE index / INSERT … WHERE NOT EXISTS so the database arbitrates.
This is the general hazard of the sweep, and setup_admin is the one place I found where it is security-relevant. Worth a scan of the other 166 for check-then-act on process-global state before merge — the AST guard can't see this class of problem.
2. The queue-summaries commit has no consumer
POST /v1/queue/{queue_id}/item_summaries_by_ids and SessionQueueItemSummary appear only in the generated schema.ts. services/api/endpoints/queue.ts still calls getQueueItemDTOsByItemIds → items_by_ids, the full-item route. So the commit's measured "~66 ms of event-loop time plus ~1.8 MB per request" is not saved by this PR.
What it does add: public API surface, a new schema type, a second sanitizer that must stay in sync with sanitize_queue_item_for_user (they already diverge — the summary sanitizer redacts device, the full one doesn't), and a route to maintain. Either wire queue.ts to it, or drop the commit and land it with its consumer.
Non-blocking
- Concurrent custom-node install can delete the winner's directory.
install_custom_node_packdoesif target_dir.exists(): return…git clone…except Exception: shutil.rmtree(target_dir). Two concurrent POSTs for the same source both pass the exists check; the loser's failed clonermtrees the directory the winner is cloning into. Admin-only and self-inflicted, but previously impossible._load_node_packalso mutates the global invocation registry from two threads now. require_admin/require_admin_or_defaultdo no blocking work — they only readcurrent_user.is_admin. Declaring themdefbuys a threadpool round-trip per admin request and nothing else. (get_current_user/get_current_user_or_default/get_current_media_user_or_defaultgenuinely do ausers.get, so those conversions are right; note that in single-user modeget_current_user_or_defaultalso returns before touching the DB.)- The stall shifts rather than vanishes past 40 in-flight blocking requests. anyio's default thread limiter is 40 tokens, and the SQLite layer is one connection behind a process-wide
RLock, so 40 concurrent slow searches occupy every worker and later requests — including the now-sync auth dependency — queue behind them. The probe route intest_event_loop_blocking.py(/api/v1/app/version) has no auth dependency, so it wouldn't surface this. Worth a sentence inblocking-work-in-api-routes, which currently says "keeping everything else responsive" without the bound. - AST guard scope.
test_no_blocking_async_routes.pywalks only module-top-level nodes ofapi/routers/*.py, and_awaits_somethingcounts anawaitthat appears inside a nested closure. Fine for a guard — just narrower than "every handler including ones written later" implies.
Attacks that failed
Recording these so the clean areas are legible as checked, not skipped:
- GZip subclass — I expected the
super()-then-widen ordering to be wrong. It isn't: Starlette buffershttp.response.startand only readscontent_type_is_excludedwhen the first body message arrives, so widening after the base class has set it is sound, and theand not self.content_type_is_excludedguard correctly prevents un-setting thetext/event-streamexclusion. Verified end-to-end against a standalone app: png/webp/jpeg/mp4/zip/octet-stream/event-stream pass through, json/html/css/js/svg compress,Vary: Accept-Encodingstill added to compressed responses, aStreamingResponsekeeps its explicitContent-Lengthwhen excluded, identity path unchanged. One consequence worth knowing: a response with nocontent-typeis now uncompressed. - Events from worker threads —
FastAPIEventService.dispatchusescall_soon_threadsafe, so socket.io delivery from threaded handlers is safe. - DB access outside the lock — the only direct
_connuse outsideSqliteDatabase.transaction()is the startup-only migrator, so the doc's "single connection behind a process-wide lock" claim holds. That is the precise answer to @keturn's question above. - Deadlock — no converted handler blocks on a future the event loop must complete; there is no cycle between the threadpool and the loop.
created_dateinjection — parameterized (AND DATE(x.created_at) = ?).- Frontend cache regression —
providesTagson the unified endpoint is a strict superset of what the two old endpoints provided (the virtual-date case gains the hashedGalleryItemNameListid), so invalidation can't regress. The selector correctly dropsboard_idfor virtual ids and sendsis_intermediate: false, matching the deprecated by-date route's hardcodedFalse. No stale references to the removed endpoint remain. - Build hygiene —
openapi.jsonis byte-identical to a freshgenerate_openapi_schema.pyrun;uv lock --checkis clean andannotated-docis properly locked;pins.jsondoesn't track fastapi, so no sync issue with #9351. 2186 backend tests pass, 0 failures locally.
Worth calling out
The auth-guard repair is the most valuable thing in this PR and is underweighted in the description. Confirmed independently: iter_route_contexts finds 197 APIRoutes where app.routes finds 2. The default-deny guard had been inspecting ~1% of the API and passing. PUBLIC_ROUTES is unchanged, which is the part that matters — the restored guard found no unauthenticated routes, so nothing was papered over to make it pass. That commit stands on its own and deserves the separate title you offer in the merge plan; the FastAPI bump is much easier to justify with that finding in its own changelog entry.
|
Following up on the action item from my review — I swept all the converted handlers for the same lost-atomicity class, since the AST guard can't see it. One new finding, and it chains into the MethodThe diff converts 180 functions: 175 route handlers (8 in package A + 167 in the sweep) + the 5 auth dependencies. I pulled them from the diff by name, filtered by AST to the 78 whose bodies contain a mutating call, and read those. The filter that matters: an So the regression set is narrow: check-then-act across two service calls, where the racing party is another request, and the write does not re-check. That leaves two, one of which I'd already reported. New finding —
|
| Handler | What changed |
|---|---|
custom_nodes.install_custom_node_pack |
(already reported) the loser's except handler rmtrees the winner's directory |
model_manager.convert_model |
get_model → minutes of conversion → delete + install_path. Same-key double-convert is now possible; separately, two different conversions can now run in parallel and compete for RAM/VRAM, which nothing bounds |
workflows.update_workflow / delete_workflow / update_workflow_is_public |
read existing → authorize → write. The write re-passes user_id, so authorization stays safe at the SQL layer — only the old_is_public in the emitted event can be stale. Cosmetic |
model_manager.do_hf_login |
global token + HF cache file; last writer wins |
Cleared, with the reason
Recording these so the sweep is legible as done rather than sampled:
create_user—email TEXT NOT NULL UNIQUE, so the check-then-insert race is caught by the constraint andcreateturns theIntegrityErrorinto a 400.images.star_images_in_list/unstar_images_in_list/delete_images_from_list,board_images.add_images_to_board,videos.*,system_prompts.*,style_presets.delete_style_preset— gate on ownership, which no endpoint can change, and/or re-passuser_idinto the write.app_info.update_runtime_config/set_external_provider_config/reset_external_provider_config— already hold_EXTERNAL_PROVIDER_CONFIG_LOCK.image_moves.start_image_move/start_image_move_recovery— the service is alreadythreading.Lock-guarded because it manages a worker thread.session_queue.retry_items_by_id— per-item authorize then bulk retry; the only mutable input is item status, which already raced the session processor. Not a regression.download_queue.*,client_state.*,boards.create_board,recall_parameters.update_recall_parameters(a singleset_by_key, no read-modify-write),model_manager.delete_orphaned_models(per-path result reporting absorbs it).- The
assert_image_move_maintenance_inactive()→ loop-of-writes pattern in the images/videos bulk routes: maintenance can begin mid-loop, but that already raced the move worker, which sets the flag from its own thread. Pre-existing.
Pre-existing, found en route — not caused by this PR
update_user has no last-admin guard at all. PATCH /api/v1/auth/users/{id} with {"is_admin": false} or {"is_active": false} against the sole admin succeeds single-threaded, reaching the same zero-admin state with no race needed — count_admins() is called from exactly one place in the codebase, delete_user. Flagging it because it means fixing only the race would not restore the invariant, and both point at the same fix: put the guard in UserService next to the write.
|
Follow-up to the sweep above, and a correction to one line in it. Correction: I wrote that What was genuinely unclaimed is the TOCTOU itself: both #9360's new guard and the existing
17 new tests, 9 of which fail without the guard, including three that race two threads through a barrier and assert exactly one survives. #9479 is independent of this PR — the invariant is wrong on That leaves the two findings in my review unchanged: the |
JPPhoto
left a comment
There was a problem hiding this comment.
@Pfannkuchensack Can you fix this?
invokeai/app/services/session_queue/session_queue_sqlite.py:1365expands every client-supplied ID into one SQLite bind, with no batch limit atinvokeai/app/api/routers/session_queue.py:236. Posting 32,766 IDs causesOperationalError: too many SQL variables, returned as HTTP 500. Test: POST{"item_ids": list(range(32766))}to/api/v1/queue/default/item_summaries_by_ids; expect controlled rejection or chunking.
Suggestions:
- Consider bounding/chunking IDs and adding this overflow regression test. Keep the earlier @lstein review follow-ups separate: #9479 for admin invariants and the summary-route consumer decision.
Ping me when you're ready for a re-read!
`item_summaries_by_ids` expanded every client-supplied id into one SQLite bind parameter, with no limit on the route. Posting more ids than the per-statement variable limit (32766 on SQLite >= 3.32) raised `OperationalError: too many SQL variables`, which the route reported as a generic HTTP 500. Cap the request body at 1000 ids so oversized lists are rejected by validation before any database work starts, matching the existing MAX_VIDEO_BATCH_SIZE precedent. Independently, chunk the `IN (...)` expansion at 900 binds so no caller — including internal ones not covered by the route bound — can hit the ceiling; 900 stays under the 999 limit of pre-3.32 builds too. Both regression tests fail without the fix: the router test posts 32767 ids and gets 200 instead of 422, and the service test reproduces the OperationalError verbatim, sized off the limit the running SQLite build actually enforces.
Converting the routes from `async def` to `def` removed an implicit guarantee: a handler whose body contains no `await` could not be interleaved with another request, because the event loop had no point at which to switch. Two handlers relied on it. `POST /auth/setup` did has_admin() then create_admin() in separate transactions. Two concurrent requests both saw no admin and both created one, so the loser ended up with a persistent admin account instead of the intended 400. The condition now lives inside the INSERT's own transaction, behind BEGIN IMMEDIATE so a second process (invoke-useradd --admin) cannot slip a write in either. This mirrors what invoke-ai#9360 does for the update/delete last-admin invariant; create_admin was the one path it does not cover. Custom node install, uninstall and reload all mutate the same custom-nodes directory, sys.modules and invocation registry. Interleaved, a failed install's cleanup rmtree'd the directory a concurrent install had just cloned into. A module-level lock restores the exclusion; the install and uninstall bodies moved into helpers so the lock scope is visible rather than an 80-line reindent. Both regression tests fail without their fix: the admin one creates two administrators, the pack one loses the successful install's directory. perf(queue): render the queue list from summaries, with one sanitizer The list fetched full queue items for every visible row, each carrying its session graph and workflow — megabytes per screenful for fields no row draws. The rows now render from SessionQueueItemSummary and the full item is fetched only when a row is expanded, which is what the summary route added in this branch was for; until now nothing consumed it. The per-item summary query provides the same cache tags as getQueueItem, so every existing invalidation path covers the list rows with nothing to wire up, and the optimistic status write is mirrored so a row's status still flips without a round trip. The range hook batches at the backend's 1000-id limit, which a fast fling could otherwise exceed. Both sanitizers are now one generic function over a single redaction table: the summary and the full item are two projections of one row, and a field stripped from the list but left on the detail view is leaked anyway. A test walks the intersection of both models and asserts they redact it identically. `device` is deliberately not redacted in either. It names the instance's GPU rather than anything about the other user's work, and the list has always shown it — redacting it here would have quietly changed what non-admins see. parent_item_id joins the summary because the rows decide from it whether to offer a retry.
Two review follow-ups landed together here. Converting the routes from `async def` to `def` removed an implicit guarantee: a handler whose body contains no `await` could not be interleaved with another request, because the event loop had no point at which to switch. `POST /auth/setup` did has_admin() then create_admin() in separate transactions. Two concurrent requests both saw no admin and both created one, so the loser ended up with a persistent admin account instead of the intended 400. The condition now lives inside the INSERT's own transaction, behind BEGIN IMMEDIATE so a second process (invoke-useradd --admin) cannot slip a write in either. This mirrors what invoke-ai#9360 does for the update/delete last-admin invariant; create_admin is the one path it does not cover. Custom node install, uninstall and reload all mutate the same custom-nodes directory, sys.modules and invocation registry. Interleaved, a failed install's cleanup rmtree'd the directory a concurrent install had just cloned into. A module-level lock restores the exclusion; the install and uninstall bodies moved into helpers so the lock scope is visible rather than an 80-line reindent. Both regression tests fail without their fix: the admin one creates two administrators, the pack one loses the successful install's directory. The route added earlier in this branch had none — the list still fetched full queue items for every visible row, each carrying its session graph and workflow, so the claimed saving was not being realised. The rows now render from SessionQueueItemSummary and the full item is fetched only when a row is expanded. Measured against the previous commit, same backend and same 396-item queue, identical scroll (page load, queue tab, scroll to 60%): requests 62 -> 2 payload (gzip) 262 KB -> 1.3 KB (30 items) server time 60ms -> 4ms (30 items) The request count collapses because the old path was self-amplifying: the range hook re-asks which ids are uncached on every range event, and at ~60ms per response the cache had not filled yet, so overlapping fetches piled up. A side effect worth knowing: `items_by_ids` silently skips items it cannot deserialize, so a queue item whose graph references an unregistered node type left its row permanently blank. Summaries never touch the graph, so the row now renders and only the expanded detail is affected. The per-item summary query provides the same cache tags as getQueueItem, so every existing invalidation path covers the list rows with nothing to wire up; the optimistic status write is mirrored so a row still flips without a round trip. The range hook batches at the backend's 1000-id limit, which a fast fling could otherwise exceed. The summary and the full item are two projections of one row, and a field stripped from the list but left on the detail view is leaked anyway. Both now go through one generic function over a single redaction table; a test walks the intersection of the two models and asserts they redact it identically. `device` is deliberately not redacted in either. It names the instance's GPU rather than anything about the other user's work, and the list has always shown it — redacting it would have quietly changed what non-admins see. parent_item_id joins the summary because the rows decide from it whether to offer a retry.
Six follow-ups from @lstein's sweep that were left open. `require_admin` and `require_admin_or_default` go back to `async def`. They only read `is_admin` off already-resolved token data, so declaring them `def` bought a threadpool round-trip per admin request and nothing else. The `users.get` that can block lives in `get_current_user`, which stays synchronous — the docstrings now say why the two layers differ. The AST guard now inspects what it claims to. It walked only `tree.body`, so a handler registered from inside a factory function or an `if` block was never seen, and `_awaits_something` used `ast.walk`, which counts `await`s inside nested closures — a handler could have passed by defining an inner async helper it never awaits. Both are fixed and both now have their own tests, so the guard's behaviour is pinned rather than asserted in a comment. `convert_model` takes a lock non-blocking and answers 409 otherwise. Blocking would be wrong: a conversion runs for minutes, and for the same key the second caller reads a record the first is midway through replacing. Two conversions in flight also means two models resident at once, which nothing bounds. The body moved into `_convert_model` so the lock scope is visible. Tested for the 409 and for the lock surviving a failed conversion rather than wedging the endpoint for the process's lifetime. `do_hf_login` and `reset_hf_token` hold a lock across the write and the status read-back, which otherwise could report a status belonging to a different token than the one just written. The blocking-work doc gains the bound it was missing: anyio's thread limiter holds 40 tokens, so past forty concurrent blocking requests the stall moves rather than vanishes — and anything else needing a thread queues behind them, including the synchronous auth dependency that runs before a handler is reached. Noted there too that `test_event_loop_blocking.py` cannot show this, because its probe route has neither auth nor database access. `QueueItemDetail` tells a failed fetch apart from a pending one. A queue item the backend cannot serve — one whose graph references a node type this build no longer registers — previously read as "Loading" forever. Left alone deliberately: the stale `old_is_public` in the workflow-updated event, which is cosmetic and would need `workflow_records.update()` to return the previous row to fix properly; and the `delete_user` / `update_user` last-admin invariants, which belong to invoke-ai#9360.
…nkuchensack/InvokeAI into fix/routes-block-event-loop
These arrived via a merge of the fork's own branch, where they had been tracked since an earlier `git add -A`: ten *_PLAN.md files at the repo root, the `plans/` tree (fp8-compute, pid-porting, gzip-compresslevel) and `testscript.py`. None of them belong to this change — they are working notes for unrelated features — and they made up 21 of the 90 files a reviewer had to page past. The files stay on disk; only the index drops them. They are listed in .git/info/exclude locally rather than in .gitignore, so the repository carries no opinion about one contributor's notes.
There was a problem hiding this comment.
Merge blockers:
- None. The last-admin deletion/demotion race is covered by #9360.
Other findings/issues:
invokeai/app/api/routers/model_manager.py:1169-1186: conversion lock excludes concurrentdelete_model/bulk deletion now running in worker threads. Same-key convert/delete can fail, delete the source mid-conversion, or leave a replacement after HTTP 204. Test: barrier conversion aroundloader.load_modelorinstaller.install_path, racePUT /models/convert/{key}withDELETE /models/i/{key}; expect serialized outcome and no orphan/replacement.
Suggestions:
- Consider making PR 9360 a merge dependency for PR 9436: its transactional user-service guard addresses the earlier last-admin race. PR 9360 does not address model conversion versus deletion.
Follow-up to the conversion lock, which bounded conversions against each other but not against everything else that mutates a model now that those routes run in the threadpool too. `delete_model` and `bulk_delete_models` ran free alongside a conversion. Conversion is a read-modify-replace spanning many service calls — load, write a diffusers copy, rename the record, install the copy, delete the original — so a delete landing in the middle removes the record it is still working from. The conversion's own final delete then fails, and the copy it already installed survives: the admin is answered 204 and the model reappears under a new key. A per-key claim serializes operations on one model while leaving different models free to run in parallel; a global lock would have made every delete wait out an unrelated conversion. Bulk deletion claims each key separately and reports a busy one through its existing per-key `failed` list rather than aborting the request or racing the holder. Deletion never takes the conversion lock, so the two are always acquired in the same order. `DELETE /sync/orphaned` was the same collision from the other side. An orphan is defined as model files under the models root with no database record, which is also an exact description of a conversion in progress: it built its diffusers copy in a `TemporaryDirectory` directly under `models/`, so a scan taken during a conversion reported that working directory and the delete route would rmtree it mid-write. Fixed at the cause rather than with another lock — the copy is now built in `models/.convert_tmp`, still on the models volume so `install_path` moves rather than copies across a filesystem boundary, but named in `SKIP_DIRS`. The name lives next to that list as `CONVERSION_SCRATCH_DIRNAME` so writer and scanner cannot drift apart. All three regression tests fail without their fix: the delete reaches the installer mid-conversion, bulk deletion removes the busy key, and the scan reports `.convert_tmp` as an orphan. The scan test carries a control asserting a real orphan is still found, so a scan that has stopped finding anything cannot pass it. The same scan is equally blind to an in-flight install, but the installer has always run in its own worker thread — that race predates this branch and is left alone.
There was a problem hiding this comment.
Other findings/issues:
invokeai/app/api/routers/model_manager.py:344-372,493-525,598-618,738-777,1203-1224: Head6c6e38efixes the conversion/delete race, but reidentify, record/image updates, and bulk reidentify still bypass the per-key claim. Concurrent conversion can discard metadata or images while the other request reports success. Test: barrier conversion after reading the old config and race each operation; expect serialization and preserved replacement data.
Suggestions:
-
Consider marking the conversion/delete finding resolved against
6c6e38e, then extending the claim to these remaining operations. -
Consider keeping #9360 as a merge-order dependency rather than a suggestion; 9436 itself lacks the service-level last-admin guard.
Summary
Kind: fix + perf (backend, frontend, dependencies)
Users reported the backend becoming unresponsive for minutes at a time. The cause was not one bug but a chain, and reproducing it needed three conditions at once — a large library, an active gallery search, and a running generation — which is why it resisted diagnosis.
The mechanism. The gallery list and name routes were declared
async defwhile calling synchronous SQLite services. That work therefore ran on the event loop, so for its entire duration the process served no other HTTP request and delivered no socket.io event. Users experienced this as the whole application freezing mid-generation rather than as a slow gallery. The same defect was present in 167 further route handlers.Measured on a seeded 200k-image, 1.7 GB database (1.56 GB of it metadata blobs). The metric is the latency of an unrelated trivial request issued while a gallery name query is in flight — the queries themselves are not made faster, the loop is freed:
Sample counts tell the same story more plainly: in the two-second window the probe returned 1 response before and 28 after.
What each commit does:
fix(api): run gallery and search routes off the event loop— eight gallery/search routes declareddefso FastAPI dispatches them to the threadpool.perf(gallery): add a flat item-names endpoint— the name list wrapped every entry in an object carrying akinddiscriminator. Building those models cost 820 ms of the 2225 ms service call at 200k items, and every consumer discarded the field, re-deriving the kind from the file extension. A newGET /v1/gallery/item_namesreturns a flat list; an optionalcreated_datefilter subsumes the separate by-date virtual-board route, so regular boards and virtual dates now share one endpoint, one cache and one query-args selector instead of askipTokenbranch duplicated in four places. Result: 2.51 s → 1.57 s, 8.48 MB → 3.85 MB. Five legacy name endpoints keep working and are markeddeprecated=True— external integrations still call them.perf(api): stop gzipping responses that are already compressed— Starlette'sGZipMiddlewarecompresses every content type excepttext/event-stream. A 3 MB PNG cost 52 ms of event-loop time to gzip and came back at 3.01 MB, larger than it went in; a 12 MB PNG cost 210 ms. With auto-switch on, that lands after every generated image. Loweringcompresslevelis not an alternative — on incompressible input level 1 costs 51 ms against level 9's 52 ms.build: unpin FastAPI and move to 0.141.1— see Merge Plan.perf(api): run every synchronous route handler off the event loop— the remaining 167 handlers, identified by AST rather than by hand.feat(queue): add lightweight item summaries endpoint+fix(api): close the sync-sweep races and wire up the queue summary route— see below; the endpoint originates from @JPPhoto'soptimize-queue-return-dataand now has its consumer.fix(queue): bound and chunk the id list on the queue summary route— the route expanded every client-supplied id into one SQLite bind with no batch limit, so 32 766 ids raisedOperationalError: too many SQL variablesand came back as an HTTP 500. Bounded at 1000 ids at the API, and chunked at 900 binds in the SQLite layer so no caller — including internal ones not covered by the route bound — can reach the ceiling. 900 stays under the 999-variable limit of SQLite builds predating 3.32, not just the 32 766 of current ones.fix(api): close the two check-then-act races the sync sweep opened— anasync defbody containing noawaitcannot be interleaved with another request, because the event loop has no point at which to switch. Two handlers relied on that.POST /auth/setupdidhas_admin()thencreate_admin()in separate transactions, so two concurrent requests both saw no admin and both created one — the loser ending up with a persistent admin account instead of the intended 400. Custom node install, uninstall and reload all mutate the same directory,sys.modulesand invocation registry; interleaved, a failed install's cleanuprmtreed the directory a concurrent install had just cloned into.fix(api): finish the review's non-blocking list—require_admin/require_admin_or_defaultback toasync def(they only read a field off already-resolved token data, sodefbought a threadpool round-trip and nothing else); the AST guard widened to see handlers below module level and to stop countingawaits inside nested closures; model conversion and HF-token writes serialized explicitly; and the threadpool's own bound documented.The queue list, measured
The queue list fetched full
SessionQueueItemobjects carrying the completeGraphExecutionStatefor every visible row. It now renders fromSessionQueueItemSummary, and the full item is fetched only when a row is expanded. Measured in the running app against a 396-item queue, same backend on both sides, identical scenario (page load → queue tab → scroll to 60 %):The request count collapses because the old path was self-amplifying: the range hook re-asks which ids are uncached on every range event, and at ~60 ms per response the cache had not filled yet, so overlapping fetches piled up. The per-item summary query provides the same cache tags as
getQueueItem, so every existing invalidation path — socket status events, cancel, delete, retry — covers the list rows with nothing to wire up.Two sanitizers became one generic function over a single redaction table. The summary and the full item are two projections of the same row, and a field stripped from the list but left on the detail view is leaked anyway; a test walks the intersection of both models and asserts they redact it identically.
deviceis deliberately not redacted in either — it names the instance's GPU rather than anything about the other user's work, and the list has always shown it.One side effect worth knowing:
items_by_idssilently skips items it cannot deserialize, so a queue item whose graph references a node type this build no longer registers left its row permanently blank. Summaries never touch the graph, so the row now renders and only the expanded detail is affected — where a failed fetch is now reported as an error instead of reading as "Loading" forever.POST /v1/queue/{queue_id}/items_by_idsis unchanged and still served; the UI no longer calls it.Related Issues / Discussions
optimize-queue-return-databranch.gallery_default.py, which PR perf(db): streamline gallery membership queries #9385 also rewrites — see Merge Plan.QA Instructions
Automated. Guards that were each verified to fail before their fix and pass after:
tests/app/routers/test_no_blocking_async_routes.pyparses every router module and fails if any handler isasync defwithout awaiting. It now walks the whole module rather than only its top level, so a handler registered from inside a factory function or anifblock is seen too, and it no longer countsawaits inside nested closures — a handler could otherwise pass by defining an async helper it never awaits. Both properties have their own tests.tests/app/routers/test_event_loop_blocking.pystubs a service call to block for one second and asserts an unrelated route still answers during it. Six routes covered, GET and POST.tests/app/routers/test_session_queue_item_id_limits.pyposts 32 767 ids and expects a 422 with no database work attempted; without the bound it returns 200.tests/app/services/session_queue/test_session_queue_status_user_scoping.pysizes its id list off the limit the running SQLite build actually enforces; unchunked it reproducesOperationalError: too many SQL variables.tests/app/services/users/test_user_service.pyraces two threads through a barrier intocreate_admin; without the fix both succeed and the instance ends up with two administrators.tests/app/routers/test_custom_nodes.pyraces two installs of the same pack so the loser's cleanup runs after the winner has written its files; without the lock the winner's directory is deleted.tests/app/routers/test_model_manager.pycovers the 409 a second concurrent conversion gets, and that a failed conversion releases the lock rather than wedging the endpoint for the process's lifetime.Plus
tests/app/routers/test_gallery_item_names.py(7 tests, two of which compare the new endpoint against the deprecated one so ordering and counts cannot drift while both are served),tests/app/api/test_gzip_content_types.py(14 tests, including one asserting the real app has the middleware wired) andtests/app/routers/test_session_queue_sanitization.py(the summary/full-item redaction equivalence).Verified locally: 713 tests across
tests/app/routers/andtests/app/api/, 1792 frontend tests, ruff / tsc / eslint / knip / dpdm / prettier clean. Nine pre-existing failures intest_download_queue,test_model_installandtest_load_apiare network-dependent and reproduce identically on an unmodified tree.Manual, to see the stall fix. Needs a large library — a few hundred MB of image metadata is enough; the effect scales with
SUM(LENGTH(metadata)) FROM images.To measure rather than eyeball it: fire
GET /api/v1/gallery/item_names?search_term=…and pollGET /api/v1/app/versionconcurrently, recording the latency of the second. Note that a benchmark of the search endpoint alone shows no improvement — that is the wrong instrument here.Manual, to check the queue list. Open the Queue tab with a few hundred items and watch the network panel: it should issue
POST item_summaries_by_idsand noitems_by_ids, including while scrolling. Expanding a row issues oneGET /v1/queue/default/i/{id}— that request is the intended trade. Rows for other users' items must still show redacted identity but a visible GPU column.Deprecated routes.
GET /v1/gallery/items/names,/v1/images/names,/v1/videos/namesand both/v1/virtual_boards/by_date/{date}/*_namesstill return their original shapes; only the OpenAPIdeprecatedflag changed.Media responses no longer carry
Content-Encoding: gzip. Confirm images and videos still load, including behind a reverse proxy.Merge Plan
The FastAPI bump needs attention.
pyproject.tomlmoves fromfastapi==0.118.3to>=0.141.1,<0.142; contributors must re-sync (uv sync) after pulling. The old pin carried a comment guessing the OpenAPI crash on 0.119 was "probably Invoke's [bug], because we are doing something unusual with AnyInvocation". It was not:fastapi/_compat/v2.pyassumed every field mapping carries a$ref. Upstream fixed it in 0.124.0 with no change needed here.Two later FastAPI changes break silently and are handled — both are worth a reviewer's attention:
contentMediaTypeinstead offormat: binaryfor file uploads.typegen.jsmapped only the latter toBlob, so upload call sites would have started typing theirFileargument asstring. Caught only becausetschappened to fail.app.routesinstead of copying its routes into it. The default-deny auth guard walkedapp.routesforAPIRouteinstances and found 2 of 197 — passing while inspecting almost nothing. It now walksiter_route_contexts(the traversal FastAPI's own OpenAPI generation uses) and asserts a floor on the route count so going blind fails loudly. Only the allowlist-staleness assertion caught this; "fixing" it by trimmingPUBLIC_ROUTESwould have killed the guard.Ordering against #9360. The last-administrator invariant in
update_user/delete_useris a TOCTOU onmaintoday, but reachable only from non-HTTP paths. This PR makes it reachable from two concurrent HTTP requests, and zero administrators meanshas_admin()is false, which reopensPOST /auth/setupunauthenticated. The fix belongs to #9360 (@lstein) and is deliberately not duplicated here. If this PR merges first, that window is open inmainuntil #9360 follows.Conflicts with PR #9385, which rewrites
_build_halfingallery_default.py. This PR adds a shared_query_name_rowsin the same file. Whichever merges second needs a manual pass. Unrelated note for that PR's own review: it introducesINDEXED BYhints into the shared query builder, which is SQLite-only syntax.Not in scope, deliberately: the
metadata LIKE '%…%'full scan (six sites, unindexable by construction) is being addressed differently in v7; the SQLite single-connection/global-lock design is tracked separately; makingcompresslevelconfigurable is written up with measurements but not implemented — level 9 costs 5.5× the CPU of level 1 for 0.4 percentage points of output size; the staleold_is_publicin the workflow-updated event is cosmetic and would needworkflow_records.update()to return the previous row.Suggested split: if the FastAPI commit would rather be reviewed on its own, it is self-contained and the auth-guard finding deserves its own title.
Checklist
docs/contributing/blocking-work-in-api-routes, extended with the threadpool's 40-token boundWhat's Newcopy (if doing a release after this PR)