fix(api): stop synchronous route work from stalling the whole server - #9436
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 |
…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.
…ecord Follow-up to 6c6e38e, which serialized conversion against deletion but left the operations that rewrite a record or its image running free beside it. The hazard is not the individual write — it is that conversion carries a snapshot. It reads the config before it starts and, minutes later, writes that snapshot's name, description, hash and source into the replacement record, then moves the model image over to the new key. Anything accepted on the old key in between is answered 200 and then silently discarded: a rename vanishes, a re-probe's findings vanish, an uploaded cover image is replaced by the one the conversion carried, a deleted one comes back. So `reidentify_model`, `update_model_record`, `update_model_image`, `delete_model_image` and `bulk_reidentify_models` now take the same per-key claim as conversion and deletion. Bulk reidentification claims each key separately and reports a busy one through its existing per-key `failed` list rather than aborting the request. `update_model_record` is the one `async def` among them; the claim only holds a threading lock across a set membership test, never across the `await`, so it cannot deadlock the loop. `reidentify_model`'s body moved into `_reidentify_model` so the bulk route can call it instead of carrying its own copy of the retain-these-fields logic — the two copies had already drifted to opposite `hasattr` orderings, and only one of them would have been updated the next time that list changes. Four new regression tests, each holding a conversion at a barrier and racing one operation against it; with the claim removed, all six of this file's race tests fail.
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
-
invokeai/app/api/routers/model_manager.py:1222,1279-1308: conversion claims only the source key. Concurrent deletion of newly registerednew_keysucceeds, then conversion fails after removing the source. Test: pause afterinstall_path()returns, deletenew_key, resume; expect serialization and valid replacement. -
invokeai/app/api/routers/model_manager.py:609-631: image upload claims only after async read/decode. Conversion can finish first, then upload returns 200 while saving an orphaned image under the deleted source key. Test: synchronize conversion completion before claim acquisition; assert rejection or replacement-image preservation.
Other findings/issues:
-
invokeai/app/services/orphaned_models/orphaned_models_service.py:147-163:.convert_tmpis skipped during discovery, but direct deletion accepts it and can remove active conversion scratch. Test: hold conversion in scratch, submit.convert_tmp, assert rejection and uninterrupted conversion. -
invokeai/app/api/routers/model_manager.py:344-352,609-630,648-668,798-813: busy claims raise HTTP 409, but these route decorators and generated OpenAPI omit 409. Test: hold each claim, inspect OpenAPI, and assert documented 409 responses.
Suggestions:
- Update the PR form with the per-key serialization, scratch-directory behavior, and new 409 responses.
|
Should be ready. |
Brings in the three commits this branch was behind: Intel XPU device support (invoke-ai#9401), single-file Wan 2.2 checkpoints (invoke-ai#9503) and the opt-in Wan low-VRAM mode (invoke-ai#9462). Only uv.lock conflicted. This branch lifts fastapi to 0.141.1, which adds annotated-doc and typing-inspection as dependencies, while invoke-ai#9401 rewrote every marker string in the file to carry the new xpu extra. Regenerated with 'uv lock' instead of hand-merging those marker chains; the result has both (fastapi 0.141.1, 776 xpu markers). openapi.json and schema.ts merged without conflict, and were checked rather than assumed: both are supersets of main (143 -> 145 paths, 870 -> 877 schemas, 187 -> 189 operations), so nothing from main was dropped.
invoke-ai#9436 has landed in main, so most of what this branch carried is now upstream and the overlap had to be unpicked file by file. main wins wherever it evolved past what this branch forked from: - session_queue.py: the two sanitizers were consolidated into one generic sanitize_queue_item_for_user over a TypeVar, so the summary-specific name this branch called no longer exists. main also bounds item_ids with max_length, which is the 422 answer to the bind-limit review comment. - session_queue_sqlite.py: main implements the same chunked IN (...) lookup this branch added, and additionally selects parent_item_id. Its regression test reads the bind limit off the running SQLite build instead of hardcoding one, and interleaves real ids with padding, so it also covers chunks matching nothing. - model_manager.py: main serializes the HF token reset under _HF_TOKEN_LOCK. - blocking-work-in-api-routes.md: main documents the anyio thread limiter ceiling. - test_no_blocking_async_routes.py: main has two closure/await cases more. This branch wins for its own subject: configure_gzip, GZIP_MINIMUM_SIZE, the http_compression_level wiring in api_app.py and the twelve tests in test_gzip_content_types.py (a superset of main's five). openapi.json, schema.ts and uv.lock are taken from main after checking it is a superset - same 145 paths, one schema more, nothing only on this side - and 'uv lock --check' confirms the lockfile still matches the merged pyproject.
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.Concurrency
Mutating model routes now serialize per model key.
_claim_model_keyholds anexclusive claim for the duration of the operation; a second request for the same
key gets 409 rather than racing it. Conversion is the case that motivates it:
it registers a replacement model and deletes the source, so
install_pathand theclaim on the new key are taken under one lock — otherwise a delete could observe
the replacement before it was protected. Image upload takes the claim before
reading the upload body, not after.
409is documented onreidentify_model,update_model_image,delete_modeland
delete_model_image, and appears in the generated OpenAPI schema.Conversion scratch directory
Conversion builds its diffusers copy in
models/.convert_tmpso the result can bemoved rather than copied across a filesystem boundary. That directory is by
definition indistinguishable from an orphan (model files, no database record), so
it is excluded from the orphan scan and rejected by
DELETE /sync/orphaned—the scan alone was not enough once both routes could run concurrently.
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)