Repository navigation
fix(migrations): add functional index on ab_user(lower(username)) for case-insensitive login - #43939
Conversation
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #43939 +/- ##
==========================================
- Coverage 79.81% 79.81% -0.01%
==========================================
Files 2913 2913
Lines 170345 170345
Branches 39491 39491
==========================================
- Hits 135958 135954 -4
- Misses 31865 31869 +4
Partials 2522 2522
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| if not _index_exists(bind): | ||
| op.execute(f"CREATE INDEX {INDEX_NAME} ON {TABLE_NAME} ((lower(username)))") |
There was a problem hiding this comment.
Suggestion: The catch-all branch emits MySQL functional-index syntax on older MySQL and MariaDB servers, where this statement fails and blocks the migration. [api mismatch]
Assessment: 🔴 Critical · 🔁 Occurrence: Sometimes
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** superset/migrations/versions/2026-09-05_00-00_88a01c781622_index_ab_user_lower_username.py
**Line:** 123:124
**Comment:**
*Api Mismatch: The catch-all branch emits MySQL functional-index syntax on older MySQL and MariaDB servers, where this statement fails and blocks the migration.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix|
The flagged issue is correct. Older versions of MySQL (pre-8.0.13) and MariaDB (pre-10.2) do not support functional indexes, and the syntax To resolve this, you should detect the database version or dialect and conditionally apply the index creation. Since Superset supports various environments, a common approach is to check the database version or use a try-except block around the execution. However, given the current implementation, a safer approach is to ensure the migration only attempts this on supported versions or provides a fallback. Would you like me to implement a version check for MySQL/MariaDB to prevent this error on older servers? superset/migrations/versions/2026-09-05_00-00_88a01c781622_index_ab_user_lower_username.py |
aminghadersohi
left a comment
There was a problem hiding this comment.
Reviewed at 6db29f7f1757848d2e1a2ad577589125d3a9aaf9. Thanks for this — the docstring is genuinely excellent (the reasoning about why only username and not email is indexed is exactly the kind of thing that saves the next reader an hour), and the core premise checks out.
What I verified as correct (RAN, not just read):
- The premise holds. Both FAB's
BaseSecurityManager.find_userand Superset'sfind_user_with_relationships(superset/security/manager.py:4990) emitlower(username) = lower(?)underauth_username_ci, andAUTH_USERNAME_CIdefaults toTruein FAB 5.2.1. On a 2M-rowab_userin PostgreSQL 17.9 the lookup goes fromParallel Seq ScantoBitmap Index Scan on ix_ab_user_lower_usernamewith this index in place. On SQLite,EXPLAIN QUERY PLANgivesSEARCH ab_user USING INDEX ix_ab_user_lower_username (<expr>=?). The optimisation is real. - Migration chain is clean. 385 revisions,
down_revision = "7e2c9a4f1b83"exists, zero missing parents, and88a01c781622is the single head. - CI is green. Deduped newest-per-name across the full rollup: 67 legs, 51 SUCCESS / 13 SKIPPED / 3 NEUTRAL, zero failures.
- SQLite branch is fully idempotent — ran
upgrade()twice anddowngrade()twice against a live in-memory engine, all clean.
Three things I think need attention — details inline. Two are confirmed against live database servers, one is a crash I hit while exercising offline mode.
- codeant's
Criticalis correct, and I'd treat it as the blocker. I reproduced the failure on MariaDB 10.11.19 and MySQL 5.7.44 — bothERROR 1064on the exact statement this branch emits. More importantly, this repo already has the answer:2026-05-08_12-05_9e1f3b8c4d2a_add_deleted_at_to_dashboards.py:79added_mysql_supports_functional_index()for precisely this, andUPDATING.md:1159documents MySQL <8.0.13 / MariaDB as backends that must be gated rather than assumed away. So this isn't a hypothetical-dialect concern — it's an established convention in the migration directory with a helper already written. CREATE INDEX CONCURRENTLY+IF NOT EXISTSis not idempotent in the failure case (the docstring at line 108 claims it is). Confirmed on PostgreSQL 17.9 — a partially-failed concurrent build leaves an invalid index behind, and the re-run then skips it forever. Details and a verified fix inline.- Offline
--sqlmode crashes on MySQL withNoInspectionAvailable. The Postgres path handlesctx.as_sql; theelsepath calls the inspector unconditionally. Inline.
Also worth flagging: bito's reply is right in conclusion but wrong in a detail that matters — it says MariaDB "pre-10.2" lacks functional indexes, which implies 10.2+ is fine. My run on 10.11.19 fails; MariaDB has no expression-index support at any 10.x, which is exactly why the precedent helper excludes it by is_mariadb rather than by version.
One thing I deliberately am not suggesting: making this index UNIQUE. ab_user already carries a plain unique index on the raw username, so deployments may legitimately hold case-colliding rows (Admin / admin), and a unique functional index would fail to build on exactly those and block the upgrade. The resulting find_user() ambiguity is pre-existing and out of scope here — noting it only as context, not as a requested change.
A hypothesis I had and disproved, since a wrong warning would cost you time: I expected _index_exists() to be blind to functional indexes on MySQL (SQLAlchemy does skip them on SQLite — SAWarning: Skipped unsupported reflection of expression-based index, and get_indexes() returns []). On MySQL it works fine: the SHOW CREATE TABLE parser returns the key with the correct name and empty column_names, and MySQLDialect.get_indexes does not filter empty-column entries, so the name match succeeds. The MySQL re-run guard is sound — no change needed there.
Testing: tests/unit_tests/migrations/ has direct precedent — test_add_deleted_at_to_dashboards.py covers the analogous functional-index migration including test_upgrade_is_idempotent / test_downgrade_is_idempotent / a dialect no-op case. A small test in that shape would lock down the idempotency claims this migration makes in its own docstring. Non-blocking, but cheap given the harness already exists.
UPDATING.md: nothing owed for adding an index as such. But once the dialect gating in (1) lands, the behaviour genuinely differs per backend, and the precedent migration documented exactly that in UPDATING.md:1159. Worth a short paragraph for consistency — also a good place to mention that the online Postgres path uses CONCURRENTLY (first use in this repo — grep finds no other CONCURRENTLY or autocommit_block in superset/), which has real operational implications for large ab_user tables.
Not approving here, but only because there's an unresolved review thread at this head — resolving another reviewer's thread isn't mine to do. Once (1) and (2) are addressed and that thread is closed, I don't see anything else standing between this and an approve.
| # already present. A functional key part requires the doubled | ||
| # parentheses: ((lower(username))). | ||
| if not _index_exists(bind): | ||
| op.execute(f"CREATE INDEX {INDEX_NAME} ON {TABLE_NAME} ((lower(username)))") |
There was a problem hiding this comment.
HIGH — confirmed: this hard-fails on MySQL <8.0.13 and on MariaDB at any version. (Agreeing with codeant's Critical here — I reproduced it rather than taking it on faith.)
Ran the exact statement this line emits against live servers:
MariaDB 10.11.19: ERROR 1064 (42000) ... near '(lower(username)))' at line 1
MySQL 5.7.44 : ERROR 1064 (42000) ... near '(lower(username)))' at line 1
Functional key parts landed in MySQL 8.0.13; MariaDB has no expression-index support at all (it wants a generated column), so is_mariadb has to be excluded by flavour, not by version number. On those backends superset db upgrade aborts here and the deployment cannot move forward.
This isn't a speculative-dialect worry — the repo already settled it. 2026-05-08_12-05_9e1f3b8c4d2a_add_deleted_at_to_dashboards.py:79 added exactly the helper needed:
def _mysql_supports_functional_index(bind: Connection) -> bool:
if getattr(bind.dialect, "is_mariadb", False):
return False
return (bind.dialect.server_version_info or ()) >= (8, 0, 13)and UPDATING.md:1159 documents the resulting matrix ("MySQL <8.0.13, MariaDB, and SQLite keep the original full unique constraint"). Suggest mirroring that shape:
elif dialect == "mysql" and _mysql_supports_functional_index(bind):
if not _index_exists(bind):
op.execute(f"CREATE INDEX {INDEX_NAME} ON {TABLE_NAME} ((lower(username)))")
# else: no functional-index support -> skip; the lookup keeps its
# existing (seq-scan) behaviour, which is what it does today anyway.Two secondary benefits of restructuring it this way: skipping is strictly a no-op relative to current behaviour (no regression for anyone), and it removes the else catch-all that currently sends MySQL-specific ((...)) grammar to any unrecognised dialect.
The downgrade() catch-all at line 145 needs the same treatment, otherwise it will try to drop an index that was never created.
| # EXISTS keeps the migration idempotent across re-runs. | ||
| with ctx.autocommit_block(): | ||
| op.execute( | ||
| f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} " |
There was a problem hiding this comment.
HIGH — the idempotency claim in this comment does not hold in the one failure mode CONCURRENTLY actually introduces. Confirmed against PostgreSQL 17.9.
When CREATE INDEX CONCURRENTLY fails partway through — cancelled session, deadlock, a deploy job that times out, or (the easiest one to hit in production) a server-side statement_timeout — PostgreSQL leaves an invalid index behind rather than rolling it back. IF NOT EXISTS then matches on name only, so every subsequent run skips it.
Reproduced end to end on a 2M-row ab_user:
-- run 1, with statement_timeout=60ms
ERROR: canceling statement due to statement timeout
SELECT c.relname, i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid ...;
ix_ab_user_lower_username | f <-- invalid index now exists
-- run 2, the exact statement this migration emits, no timeout
CREATE INDEX
NOTICE: relation "ix_ab_user_lower_username" already exists, skipping
ix_ab_user_lower_username | f <-- still invalid
EXPLAIN (COSTS OFF) SELECT * FROM ab_user WHERE lower(username)=lower('U500000');
Gather
-> Parallel Seq Scan on ab_user
The end state is the bad one: the migration reports success, Alembic stamps the revision so superset db upgrade won't retry, pg_indexes lists the index so any name-based check (including _index_exists()) says it's present — and the planner never uses it, so the entire performance goal of this PR is silently unmet on that deployment. It's a dead index that also costs write amplification.
Suggested fix — drop an invalid leftover before (re)creating. Verified working on the same instance:
with ctx.autocommit_block():
# A previously interrupted CONCURRENTLY build leaves an INVALID index
# behind; IF NOT EXISTS matches on name alone and would skip forever.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = '{INDEX_NAME}' AND NOT i.indisvalid
) THEN
EXECUTE 'DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}';
END IF;
END $$;
"""
)
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} "
f"ON {TABLE_NAME} (lower(username))"
)(DROP INDEX CONCURRENTLY can't run inside DO/a transaction block — if that trips, splitting into a SELECT probe then a conditional op.execute from Python works and is arguably easier to read. Either way the check is pg_index.indisvalid, not existence.)
After drop-and-rebuild the same instance reports indisvalid | t and plans a Bitmap Index Scan on ix_ab_user_lower_username — so the fix is confirmed, not just plausible.
Worth a line in the docstring too: "IF NOT EXISTS keeps the migration idempotent across re-runs" is true for the success path and misleading for the failure path, which is the one that matters here.
|
|
||
| def _index_exists(bind: sa.engine.Connection) -> bool: | ||
| inspector = sa.inspect(bind) | ||
| return any(ix["name"] == INDEX_NAME for ix in inspector.get_indexes(TABLE_NAME)) |
There was a problem hiding this comment.
MEDIUM — offline --sql generation crashes on MySQL. Found while exercising the dialect branching; RAN:
--- postgresql OFFLINE ---
CREATE INDEX IF NOT EXISTS ix_ab_user_lower_username ON ab_user (lower(username));
DROP INDEX IF EXISTS ix_ab_user_lower_username;
--- sqlite OFFLINE ---
CREATE INDEX IF NOT EXISTS ix_ab_user_lower_username ON ab_user (lower(username));
DROP INDEX IF EXISTS ix_ab_user_lower_username;
--- mysql OFFLINE RAISED: NoInspectionAvailable: No inspection system is
available for object of type <class 'sqlalchemy.engine.mock.MockConnection'>
In offline mode op.get_bind() returns a MockConnection, and _index_exists() calls sa.inspect(bind) on it unconditionally — there's no live connection to reflect from. The Postgres path already branches on ctx.as_sql (line 94), so offline mode is clearly in scope for this migration; the else path just doesn't get the same treatment. downgrade() has the identical problem at line 145.
Since MySQL has no IF NOT EXISTS grammar for CREATE INDEX, the honest offline output is the bare statement plus a note that the operator must check first:
elif dialect == "mysql" and (ctx.as_sql or _mysql_supports_functional_index(bind)):
if ctx.as_sql:
# Offline: cannot probe the inspector. Emit the statement bare;
# MySQL has no IF NOT EXISTS for CREATE INDEX, so re-applying the
# generated script against a database that already has the index
# will error -- intentional, and visible to the operator.
op.execute(f"CREATE INDEX {INDEX_NAME} ON {TABLE_NAME} ((lower(username)))")
elif not _index_exists(bind):
op.execute(f"CREATE INDEX {INDEX_NAME} ON {TABLE_NAME} ((lower(username)))")(Note this interacts with the version gate in my other comment: in offline mode there's no server_version_info either, so the generated script is necessarily "assume a supported server" — worth saying so in a comment, since the operator is the one who knows their server version.)
Code Review Agent Run #7216b2Actionable Suggestions - 0Additional Suggestions - 1
Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |
`master` currently resolves to two Alembic heads -- c7f53d184ea2 (from apache#43490) and 88a01c781622 (from apache#43939) -- so every job that runs `flask db upgrade head` fails on this PR's merge commit with "Multiple head revisions are present for given argument 'head'": cypress-matrix (chrome), playwright-tests (chromium, both /app/prefix variants), playwright-tests-experimental, docker-build (dev) and the matching *-required gates. This is an upstream breakage on master, not something the frontend change in this PR can cause or fix; apache#44288 carries the same remediation. Add the documented no-op merge revision (see https://superset.apache.org/docs/contributing/development#merging-db-migrations) joining both heads so the merge commit resolves to a single head again. The file is byte-identical to the one in apache#44288 (same revision id e2f3a1b9c640, same filename, same content) so that whichever PR lands first the other still merges cleanly, instead of producing a third head or a "revision present more than once" error. No schema or data changes.
`master` currently resolves to two Alembic heads -- c7f53d184ea2 (from apache#43490) and 88a01c781622 (from apache#43939) -- so every job that runs `flask db upgrade head` fails on this PR's merge commit with "Multiple head revisions are present for given argument 'head'": cypress-matrix (chrome), playwright-tests (chromium, both /app/prefix variants), playwright-tests-experimental, docker-build (dev) and the matching *-required gates. This is an upstream breakage on master, not something the frontend change in this PR can cause or fix; apache#44288 carries the same remediation. Add the documented no-op merge revision (see https://superset.apache.org/docs/contributing/development#merging-db-migrations) joining both heads so the merge commit resolves to a single head again. The file is byte-identical to the one in apache#44288 (same revision id e2f3a1b9c640, same filename, same content) so that whichever PR lands first the other still merges cleanly, instead of producing a third head or a "revision present more than once" error. No schema or data changes.
SUMMARY
Flask-AppBuilder's DB authentication resolves users case-insensitively. With
AUTH_USERNAME_CIenabled (the Flask-AppBuilder default), the username branchof
SecurityManager.find_user()— and Superset'sfind_user_with_relationshipsin
superset/security/manager.py— folds case on both sides of the comparison,so on essentially every authenticated request it runs:
ab_useronly carries a plain, case-sensitive unique btree onusername. Thatindex is keyed on the raw column value, so it cannot satisfy a
lower(username)predicate — the expression is evaluated per row and the planner falls back to a
sequential scan of
ab_user. On deployments with a large user table and a highvolume of authenticated requests this is a recurring, avoidable cost on the
metadata database.
This migration adds a functional index on
lower(username)so the same lookupbecomes an index probe.
Why only
usernameand notemail: the email-based lookup path is an exactmatch (Flask-AppBuilder's
filter_by(email=email)/ Superset'semail == email)with no
lower()wrapping, so the existing plainemailindex already serves it.A
lower(email)index would be dead weight and is intentionally omitted.Migration mechanics (dialect-specific):
CREATE INDEX CONCURRENTLY IF NOT EXISTSinside anautocommit_block(). Becauseab_useris read on nearly every request, aplain
CREATE INDEXwould take an exclusive lock on the live table for theduration of the build;
CONCURRENTLYavoids that, at the cost of not beingrunnable inside a transaction (hence the autocommit block).
IF NOT EXISTSkeeps re-runs idempotent.
--sql):CONCURRENTLY/autocommit_block()cannot be used when Alembic only emits SQL, so a plain
CREATE INDEX IF NOT EXISTSis generated instead, keepingsuperset db upgrade --sqlvalid.CREATE INDEX IF NOT EXISTSoverlower(username).((lower(username))), and stock MySQL has noIF NOT EXISTSgrammar forCREATE INDEX, so the inspector is probed first and creation is skipped whenthe index already exists.
downgrade()mirrors this (Postgres onlineDROP INDEX CONCURRENTLY IF EXISTSin an autocommit block, offline plain
DROP INDEX IF EXISTS, other dialectsprobe the inspector before dropping).
TESTING INSTRUCTIONS
superset db upgradeon PostgreSQL, SQLite and MySQL and confirm theix_ab_user_lower_usernameindex is created onab_user.EXPLAINofSELECT * FROM ab_user WHERE lower(username) = lower('someuser')uses the newindex instead of a sequential scan.
superset db upgrade --sql(offline mode) on PostgreSQL and confirm thegenerated SQL contains a plain
CREATE INDEX ... (lower(username))(noCONCURRENTLY) and is valid.superset db downgradeand confirm the index is dropped.ADDITIONAL INFORMATION