Repository navigation
feat(db): move every query into one database layer and support MySQL and MariaDB - #9702
Merged
joshistoast merged 38 commits intoOct 10, 2026
Merged
Conversation
Adds shared/database: SQLAlchemy Core engines (SQLite behind the process lock, pooled MySQL/MariaDB), query modules behind Database.queries, backend-neutral errors and a guard ratcheting SQL outside it. SqliteDatabase becomes a cursor facade over it; a nested cursor transaction now joins the outer one. CI runs the layer's tests against MySQL 8.4 and MariaDB 10.11.
Adds shared/database/schema, one module per domain, with types that keep SQLite's declarations and bound keys on MySQL/MariaDB. A parity test holds the metadata to the migrated SQLite schema; server tests check that the tables behave alike. Translates SQLite's ON DELETE RESTRICT error and refuses mysql+ URLs that reach a MariaDB server.
Migrations after PORTABLE_CUTOVER use Alembic operations and run on every backend; the 63 before it stay SQLite-only. A new MySQL or MariaDB database is created from the metadata and seeded from the migration chain, under a migration lock. Adds the row copy core and frozen cutover schemas that hold server migrations to the metadata.
A point read through SQLAlchemy Core and an explicit transaction costs about 11 µs more on SQLite than the raw cursor did; the 10 µs floor failed every ported point read. 50 µs is the planned budget.
Users, app settings, client state and media references run on Database.queries; units retry deadlocks (queries.run) and build DTOs outside the transaction (@mapped). A db_locks table, the first portable migration, keeps the last-admin and first-admin checks atomic on MySQL and MariaDB. Service tests use a new `database` fixture and run on SQLite, MySQL and MariaDB.
Boards and their image and video memberships run on Database.queries; board listings test visibility with EXISTS instead of DISTINCT joins (get_all of 200 boards 1546 -> 626 µs). dialect.OrderedJoin keeps SQLite's membership-first plan, and a board's counts come from one statement per media kind. Tests reach the SQLite facade through a mock_sqlite_database fixture instead of service internals.
Project records run on Database.queries; saves, board claims and deletes lock the project's row before its board's, so concurrent writes on MySQL and MariaDB decide on the rows they write. Writes return the document they stored instead of decoding it again (a save 1589 -> 1517 µs); listings break created_at ties by project id. Adds a @Locking decorator for row locks, and race tests that pause a real write while another one waits for it.
The workflow library runs on Database.queries; saves, deletes and visibility changes lock the workflow's row before their checks, and a creation sent twice at once yields one workflow. Searches match %, _ and \ literally and ignore case on every backend, listings break ties by workflow id, and tags are counted in one statement (counts_by_tag of 5 tags -79 %). Client-chosen list sizes no longer multiply cached statements, and the library page reads each workflow once (-13 %).
Style presets, system prompts and wildcards run on Database.queries; creates return what they stored instead of reading it back, and owner-scoped prompt updates and deletes decide in one statement. The bundled style presets are replaced in one transaction (start-up sync 8.6 -> 0.9 ms), listings break ties of equal names by id, and an unknown wildcard owner raises ForeignKeyViolation. Their common modules no longer load FastAPI or dynamicprompts with the database layer, and the benchmark script times the library's reads and writes.
Model records and relationships run on Database.queries; an update locks the model's row, so concurrent edits on MySQL and MariaDB both apply, and the orphan scan reads every registered path from the record store, also of records whose config no longer validates. Listings break ties by key and keep SQLite's NOCASE ordering through dialect.CaseInsensitiveOrder (now also for boards, style presets and system prompts); hash lookups return the oldest record first, and dialect.insert_ignore skips a pair already related. Removes the unused list_models, ModelSummary, get_model_by_hash and get_related_model_keys_batch.
Image and video records run on the database layer on every backend; the storages lose their SQL and the Sqlite prefix. Listings break ties by name, search matches % and _ literally, day filters are timestamp ranges, and storage errors are no longer reported as missing rows; the unused get_image_dates is removed. Adds the JsonString and fixed_limit dialect helpers and the capture_statements and explain_query_plan test helpers.
Record services validate metadata, so the database layer no longer loads torch; column types adapt once per backend. Removes record delete exceptions nothing raises and stale column lists; the last representable day and an empty day match nothing. Adds tests for video listings, intermediate deletes and chunking, field round trips, paging, cross-account shares and membership plans.
…tables The cleanup's classification, previews, windows and delete guard run on the database layer; session holds and unmeasurable marks move from TEMP tables into tables the service empties at start. A media_protection lock serialises a cleanup's check and delete with every write that protects media, and windows page by creation time and name instead of rowid. Adds the bound-set and sql_true dialect helpers, shared lock acquisition, and the frozen migration copy of the media-reference writers.
The polymorphic gallery listing, name lists, date boards and board summaries run on the database layer on every backend. Date boards and board counts follow the gallery's views, so canvas-owned images neither count as assets nor make or cover a day. Search matches % and _ literally, day filters are ranges, and a listing's total counts without the board join.
Move the image index's SQL into queries/image_index.py; guarded writes lock their parent row shared, and a vocabulary replace takes a new image_index_vocabulary lock, so concurrent writes stay clean on servers. Read embeddings in one bound-set statement, share one board-access rule, and fetch query results whole.
Move the queue's reads (items, listings, ids, summaries, status/batch/destination counts, workflow-call chains, enqueue receipts) into queries/session_queue.py; the writes stay on the legacy cursor until the next step. Pages keep every filter after the keyset, origin prefixes match case-insensitively with wildcards escaped, and a chain is read in one transaction.
Port the queue's writes into queries/session_queue.py and replace SqliteSessionQueue with SessionQueue: claims and status changes are conditional UPDATEs, bulk changes lock their rows first, and enqueue, retry and child enqueue take a new session_queue_admission lock while sharing media protection with the intermediates cleanup.
Move the image move journal's SQL into queries/image_moves.py and run ImageMoveService on Database: the commit is one unit that repoints the job's moved images in a single joined UPDATE, guarded by their old subfolder, and rolls back when any moved image is not where the move put it. Move tests run on every backend.
Move the font catalog's SQL into queries/fonts.py and run FontService on Database: listings skip the count when the page ends them, rescans run one at a time and write only changed rows, and uploads write their file outside any transaction. The fonts package no longer re-exports the service, which closed an import cycle.
Run gallery_maintenance and import_images through the app's config and records, with consistent online backups (Database.backup, now also the migrator's), and drop remove_orphaned_models.py in favour of the Model Manager's orphan cleanup, whose user-guide section is corrected.
A portable migration drops the 20 triggers that stamped updated_at and the queue's started_at, completed_at and session_revision: the application sets these on every backend, as servers always required.
Open the app database through Database and init_database, drop SqliteDatabase, init_db and Database.legacy_cursor, move SQLiteDirection to shared/pagination.py unchanged, and empty the access guard's allowlist; tests use sqlite_cursor for their own SQL.
A db_url setting selects a server database: startup checks its version and max_allowed_packet, holds an instance lock for the process, and warns beside an unused SQLite database; busy-database errors answer 503, the CLIs and maintenance scripts open the configured database and refuse one the app has not migrated.
…copy invoke-db-copy checks a snapshot for orphans and oversized values, copies it into an empty server database and verifies every table by checksum. Adds the MySQL/MariaDB configuration page, links it from the YAML, Docker and admin guides, and installs the mysql extra in the Docker image.
Batch, queue, client-state, project, cover-image and relationship keys take at most 255 characters, a reserved workflow id 36, and board and workflow paging is bounded. Addresses at special-use domains are held to 254 characters, and the model store refuses keys and paths longer than its columns on every backend.
…copy The instance lock is checked every minute and taken again when the server ends its session; one SessionLock serves it and the migrator. Busy, unreachable or exhausted databases answer 503 also behind a route's own error, and URLs that cannot be read never echo their password. invoke-db-copy checks key and computed column lengths and row sizes, clears SET NULL orphans, copies into db_url by default and writes the migrator's records only after verifying every table.
A connection lost during a statement stays an error: a server also drops the connection of an oversized statement, which no retry fixes. invoke-db-copy reports snapshot and check failures without a traceback, and its vocabulary check ignores format characters as the server collations do.
uvicorn returns normally when the lifespan fails, so a refused start (e.g. a database another process serves) exited 0 and looked like a clean stop to supervisors.
invoke-db-copy --to-sqlite FILE copies the db_url database into a new SQLite file under the instance lock, verifies every table and moves it into place without overwriting a file. Ids continue after the server's AUTO_INCREMENT counters, read past MySQL's statistics cache, so no queue item id is issued twice.
Pfannkuchensack
requested review from
blessedcoolant and
joshistoast
as code owners
October 8, 2026 01:06
Collaborator
|
#9700 is now merged |
Ports invoke-ai#9700 (gallery maintenance: image locations, Database.backup with upstream's reserve-and-clean-up, no file backup of a server database), invoke-ai#9682 (queue listing index as a portable migration with the same id, SQLite only; planner guards as dialect.Unindexed; limited and one-pass status reads) and invoke-ai#9669's tests onto the database layer. The access guard lists the two SQLite scratch files of gallery maintenance and the output-folder walk, which are not the app's database.
Ports invoke-ai#9683's gallery item location onto the gallery queries (ROW_NUMBER over both halves, in the name listing's order) and its router test onto the layer. Re-locks uv.lock from main's with the layer's sqlalchemy, alembic and pymysql (uv 0.11.28, no user index).
Re-locks uv.lock from main's (diffusers 0.41.0, invoke-ai#9774) with the layer's sqlalchemy, alembic and pymysql.
joshistoast
approved these changes
Oct 10, 2026
5 of 7 tasks
3 of 7 tasks
lstein
added a commit
that referenced
this pull request
Oct 10, 2026
## Summary `test_workflow_last_run_at.py::test_update_last_run_at_sets_timestamp` fails intermittently, mostly on Windows CI. - The fixture stores the 43 bundled workflows, then the test creates one workflow and expects it in the first 10 results of a listing sorted newest first by `created_at`. - `created_at` has millisecond resolution. On Windows' ~15 ms clock the new workflow usually gets the same timestamp as the bundled ones. - Ties sort by `workflow_id` descending. A random UUID sorts above the bundled `default_...` ids only about 1 time in 7 or 8, so the new workflow usually falls off the page and the test's `next(...)` raises `StopIteration`. The listing now includes only user workflows, which is what the check is about. Nothing else changes. ## Related Issues / Discussions Seen as the `windows-cpu` failure on #9694. The test came in with #9702. ## QA Instructions - Forced tie (a throwaway test setting every row's `created_at` to the same value), 20 runs each: the old query lost the workflow in 19, the new one in none. - `uv run --no-sync pytest tests/app/services/workflow_records`: 137 passed. - `ruff check` and `ruff format --check` are clean. ## Review No material findings. This is a one-line test change. ## Checklist - [x] _The PR has a short but descriptive title, suitable for a changelog_ - [x] _Meaningful regression coverage added / updated where needed; obsolete tests/code removed_ - [ ] _Persisted-state and API changes include required migrations / compatibility validation_ - [ ] _Relevant performance/efficiency opportunities considered; material claims have evidence_ - [x] _Material review findings resolved and relevant checks rerun_ - [ ] _Documentation added / updated (if applicable)_ - [ ] _Updated `What's New` copy (if doing a release after this PR)_
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every SQL statement of the app now lives in one place,
invokeai/app/services/shared/database/, built on SQLAlchemy Core, and InvokeAI can keep its data in MySQL 8.4 or MariaDB 10.11 instead of the SQLite file. SQLite stays the default and keeps working as before.The problem. SQL was spread over ~23 storage services (364
executecalls) on onesqlite3connection behind a process-wide lock. Every database request of every user waits for every other one, and no server database could be used. With multi-user mode, that lock is the ceiling on how many people one install can serve.What changes
shared/database/:Database(engines, transactions, retries of lost races, error translation),schema/(table metadata that matches the migrated SQLite schema exactly, checked by a parity test),queries/(one module per domain),dialect.py(the few constructs that differ per backend),copy.py.shared/sqlite/facade and all 20 SQLite triggers are gone (their logic is in the app, so it runs on every backend).db_locks) on servers.db_url/INVOKEAI_DB_URL(pip install "invokeai[mysql]"; included in the Docker image). Startup checks the server version andmax_allowed_packet, and holds an instance lock: one InvokeAI process per database; a second one refuses to start.invoke-db-copymoves an install's SQLite database into a new server database (preflight, then row-by-row verification), and with--to-sqlite FILEback into a new SQLite file.Before / after (one InvokeAI process, 20,000 images, request mix of reads and writes, median of 3 runs):
A single request is slower on a server (a network round trip per query, pure-Python driver); from two users on, the servers answer more requests, and a slow request no longer holds up everyone else.
Related Issues / Discussions
Replaces the closed v6 attempt #9071 (SQLModel, one class, no migrations for external databases), whose design is not reused.
QA Instructions
Test servers (any free ports):
Results on the final commit (Windows, Python 3.12):
test_16_channel_vae_loaderandminimax_h3/test_text_encoder_checkpoint, whose fixtures are Git LFS pointers in my checkout (unrelated to this PR).python-tests.yml.ruff check/ruff format --checkclean;openapi.jsonandschema.tsregenerated; docs build passes.End to end:
invoke-db-copy --checkand the copy into both servers, with every table verified. InvokeAI on each server answered boards, images, workflows, queue, models, presets, prompts and wildcards exactly as on SQLite; a second process was refused; the runtime config shows no password.--to-sqlitegives a file the app opens without migrating; queue ids continue after the server's counter.main: the app starts on SQLite and on MySQL, serves its main endpoints, and the new vacuum endpoint answers 204.SQLite cost of the layer, each port measured against the code right before it (SQLite file,
synchronous=full, 4 interleaved runs):Point reads pay a fixed ~10–30 µs for SQLAlchemy and an explicit transaction; writes of several statements +20–100 µs; queue writes are 20–25 % faster without the triggers.
scripts/benchmark_database.pyreproduces the SQLite numbers.Review
Material findings were resolved, among them: queue claims and cancels racing without the process lock; deadlocks from lock order between projects and boards; a lost instance lock after a server-side idle timeout; transient database errors hidden behind routes' own 404/500 answers (now 503 with
Retry-After); oversized values only failing in the middle of a copy; MySQL serving a staleAUTO_INCREMENTfrom its statistics cache.Remaining limitations, not addressed here:
dialect.py), but there is no CI lane.move_all_imagesadvances one image per window without moves (20k images in place take 20k queries);ImageService.copydoes not takeimage_mutation_lock.Compatibility / Rollout
boards.updated_at(that was a trigger).max_length255, emails 254, pagingge/le): over-long values now get 422 instead of being stored. A busy, unreachable or exhausted database answers 503.invokeai-webexits with 3 when the app refuses to start (it exited 0).sqlalchemyandalembic; extramysql(PyMySQL). The import pathinvokeai.app.services.shared.sqliteis gone, as are the re-exports ofinvokeai.app.services.fonts, andscripts/remove_orphaned_models.py(the orphaned models dialog and API cover it, but not offline use or file-level deletion).encodingcolumn), Add gallery maintenance UI #9700 (gallery maintenance: image locations,Database.backup, which on a server database it skips, as the operator backs that up), feat(ui): reliability pass plus canvas, gallery and frontend layout features #9682 (queue listing index, now a portable migration with the same id that creates it on SQLite only, and its planner guards asdialect.Unindexed), Fix model manager cache and recovery failures #9669 (tests), Gallery: Browse large collections with a sparse page cache (infinite scrolling) #9683 (gallery item location: one ranked statement over both halves, in the name listing's order).session_queue._db).Checklist
What's Newcopy (if doing a release after this PR)