Skip to content

refactor(db): composite PK on M2M association tables#39859

Merged
rusackas merged 28 commits into
apache:masterfrom
mikebridge:sc-105349-composite-association-pks
Jul 1, 2026
Merged

refactor(db): composite PK on M2M association tables#39859
rusackas merged 28 commits into
apache:masterfrom
mikebridge:sc-105349-composite-association-pks

Conversation

@mikebridge

@mikebridge mikebridge commented May 4, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Status: ready to merge. Rebased onto current master and validated across all three backends (local fresh-install + round-trip, plus the CI integration matrix — see Cross-DB matrix below). This is a structural schema change that runs on every Superset metadata DB on upgrade, deliberately sequenced to land early in a release cycle per the conservative pattern for changes of this shape. Ready to merge at the maintainers' discretion.

Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2) on the eight pure-junction tables. The redundant UNIQUE(fk1, fk2) on the two tables that previously carried one is dropped (subsumed by the new PK). All eight tables are M:N association tables — there is no semantic load on the surrogate id.

Affected tables and their composite PK pair:

Table Composite PK
dashboard_roles (dashboard_id, role_id)
dashboard_slices (dashboard_id, slice_id)
dashboard_user (user_id, dashboard_id)
report_schedule_user (user_id, report_schedule_id)
rls_filter_roles (role_id, rls_filter_id)
rls_filter_tables (table_id, rls_filter_id)
slice_user (user_id, slice_id)
sqlatable_user (user_id, table_id)

Why now: 6 of the 8 tables had no UNIQUE constraint at all, so duplicate (fk1, fk2) rows could (and did) accumulate in production data. The migration deduplicates by MIN(id) before the PK promotion. This change also lifts a precondition for the entity-versioning epic (versioning SIP #39464) — SQLAlchemy-Continuum's M:N restore replays the version table; surrogate-PK junctions interact poorly with the bulk reassign-and-replace pattern (Continuum issue #129). Continuum verification against the new shape is not part of this PR — it is the responsibility of the versioning epic.

BEFORE/AFTER

dashboard_slices is the canonical example (the other seven follow the same pattern, with dashboard_user, dashboard_roles, slice_user, sqlatable_user, rls_filter_* having no pre-existing UNIQUE):

Before:

CREATE TABLE dashboard_slices (
    id           SERIAL  PRIMARY KEY,
    dashboard_id INTEGER REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER REFERENCES slices(id) ON DELETE CASCADE,
    UNIQUE (dashboard_id, slice_id)
);

After:

CREATE TABLE dashboard_slices (
    dashboard_id INTEGER NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER NOT NULL REFERENCES slices(id) ON DELETE CASCADE,
    PRIMARY KEY (dashboard_id, slice_id)
);

The redundant UNIQUE (dashboard_id, slice_id) is dropped (subsumed by the PK).

TESTING INSTRUCTIONS

Reproduces locally per quickstart.md steps 4–8:

  1. Apply the migrationsuperset db upgrade. Two tables with pre-existing UNIQUE (dashboard_slices, report_schedule_user) are batch-recreated via copy_from; the other six lose id and gain a composite PK directly. Migration logs the duplicate-row count and NULL-FK row count for each affected table.
  2. Confirm composite-PK enforcement (smoke) — for any of the eight tables: INSERT a (fk1, fk2) pair, then INSERT the same pair a second time. The second insert must raise IntegrityError (PK violation). Same pair under different IDs is no longer possible.
  3. Run the migration unit testspytest tests/unit_tests/migrations/composite_pk_association_tables_test.py (16 parametrized assertions: 8 duplicate-rejection, 8 distinct-pair).
  4. Run the migration integration testspytest tests/integration_tests/migrations/composite_pk_association_tables__tests.py tests/integration_tests/migrations/composite_pk_round_trip__tests.py (28 schema-shape assertions plus a round-trip + idempotency test against in-memory SQLite via MigrationContext).
  5. Verify downgradesuperset db downgrade <prior-revision>. The id column is restored (backfilled via sa.Identity(always=False) on Postgres/MySQL), and the original UNIQUE(fk1, fk2) is re-added on the two tables that originally had it. Intentional asymmetry: FK columns remain NOT NULL after downgrade — under SQLAlchemy secondary= semantics, NULL-FK junction rows are meaningless, so we don't restore the original nullable state.
  6. Verify upgrade idempotency — re-running superset db upgrade after downgrade returns the post-upgrade shape.

Cross-DB matrix

Verified end-to-end on all three backends locally — fresh full-history install (superset db upgrade from scratch) plus round-trip (upgrade → downgrade → upgrade) — and CI re-runs the migration during integration-test setup.

Backend Fresh install Round-trip Source
PostgreSQL 17 local docker container; CI test-postgres (current/next/previous), test-postgres-hive, test-postgres-presto
MySQL 8 local docker container with INSERT-without-id sanity check; CI test-mysql
SQLite local file-based DB with INSERT (NULL, 5)-rejection sanity check; CI test-sqlite; in-memory unit/integration tests (44 passed, 1 skip)

Bugs found and fixed during local cross-backend verification

The dedicated unit and integration tests run against in-memory SQLite, and CI's per-backend integration shards exercise the migration only as part of the suite's setup phase (which catches crashes but not subtle behavioural disparities). Doing fresh-install + round-trip locally on each real backend — plus reviewer feedback from @justinpark on a populated DB — surfaced six dialect-specific issues that were masked by both layers:

  • MySQL ERROR 1826 (Duplicate foreign key constraint name) during recreate="always" — InnoDB scopes FK constraint names per-database, not per-table. The temp table created by the recreate path collides with the original. CI's setup phase did catch this (the test-mysql shard turned red on first push); fix: drop FKs by name before the recreate on MySQL.
  • MySQL ERROR 1553 (Cannot drop index 'PRIMARY': needed in a foreign key constraint) on downgrade — InnoDB uses the composite PK index to back the FK on the leftmost column. Dropping the PK without first dropping the FKs orphans the index. Only manifests on a real round-trip — CI's setup-only check would never see it; fix: drop FKs before the PK swap on MySQL, re-add them after.
  • MySQL ERROR 1832 (Cannot change column: used in a foreign key constraint) on populated tables — batch_op.alter_column(fk, nullable=False) (added to enforce NOT NULL on SQLite, see below) emits ALTER COLUMN on a column that participates in an FK, which MySQL 8 rejects when the table has data. CI's test-mysql shard runs against empty tables and so didn't catch this; @justinpark caught it on a populated install. Fix: extract the alter_column nullable=False into a _enforce_not_null_for_sqlite() helper that no-ops on Postgres (where ADD PRIMARY KEY already promotes columns implicitly) and on MySQL (same plus avoids 1832).
  • Missing AUTO_INCREMENT on the restored id column on MySQL — sa.Identity(always=False) only emits AUTO_INCREMENT when the column has primary_key=True at create time, but our portable path adds the column then creates the PK separately. Existing rows would all collide on id=0; subsequent INSERTs would fail with Field 'id' doesn't have a default value. Found by an explicit INSERT-without-id test against the post-downgrade DB; fix: combined DROP PRIMARY KEY, ADD COLUMN AUTO_INCREMENT, ADD PRIMARY KEY ALTER on MySQL.
  • SQLite composite PK doesn't enforce NOT NULL on constituent columns (long-standing SQLite quirk — only INTEGER PRIMARY KEY does). PostgreSQL and MySQL implicitly promote PK columns to NOT NULL; SQLite leaves them nullable for compound keys. A fresh SQLite install would have accepted INSERT (NULL, 5) despite both columns being part of the PK. The integration tests masked this because the test fixture seeds columns with nullable=False. Fix: _enforce_not_null_for_sqlite() helper — runs only on SQLite where the explicit step is required.
  • PostgreSQL: unnecessary full-table copy when recreate="always" is used to drop the redundant UNIQUE (per @aminghadersohi's review). recreate="always" is dialect-agnostic — on Postgres it triggers CREATE TABLE AS SELECT → DROP → RENAME, holding ACCESS EXCLUSIVE for the entire copy duration. The reflected UNIQUE has a stable name on Postgres (default <table>_<cols>_key), so dropping it directly via DROP CONSTRAINT and then running structural change as direct ALTER avoids the copy entirely. MySQL and SQLite keep the recreate="always" path (InnoDB binds FKs to the UNIQUE index → ERROR 1553 on direct drop; SQLite reflects unnamed UNIQUEs with name=None → can't drop by name).

The MySQL-specific fixes use raw triple-quoted SQL (no SQLA-core equivalent for the dialect-specific combined ALTER); the constitution allows raw SQL for dialect-specific DDL with no programmatic equivalent.

Migration runtime — empirical numbers

Measured by seeding synthetic data with scripts/seed_junction_load.py (committed in this PR) on a MySQL 8 container, then timing downgrade + upgrade at each scale. Numbers below are MySQL on Docker on macOS — production Postgres on dedicated hardware will be at least as fast, likely faster:

dashboard_slices rows Total junction rows Downgrade Upgrade
100K ~111K 2s 1s
1M ~1.11M 11s 8s
5M ~5.55M 53s 45s
10M ~11.1M 1m 51s 1m 37s

Linear scaling at ~8–9 µs/row through 10M rows; no memory cliff observed. For typical large-enterprise self-hosted scale (1–5M dashboard_slices) the upgrade is tens of seconds. Multi-tenant-SaaS scale (50M+) extrapolates to ~7–8 min. Dirty-data overhead (5% duplicates injected on the 3 non-UNIQUE junctions) added < 1s — the dedupe phase is dominated by the table scan that runs whether duplicates exist or not.

Operators can self-size with the diagnostic queries in UPDATING.md ("Sizing the maintenance window on PostgreSQL / MySQL"): per-table row counts, on-disk size, aggregated duplicate roll-up, external-FK pre-flight check, lock-window estimate.

Operator runbook

Operators should run the pre-flight inventory queries from UPDATING.md ("Composite primary keys on many-to-many association tables") before applying — they show how many duplicate (fk1, fk2) rows and how many NULL-FK rows exist in their database. The migration deletes both classes (keeping MIN(id) per group for duplicates). The full operator-facing runbook is in UPDATING.md and includes:

  • The eight affected tables and their composite-PK columns
  • Impact on external readers (BI tools, backup scripts) that reference the surrogate id
  • Pre-flight inventory queries for assessing impact
  • Notice that duplicate and NULL-FK rows are not preserved
  • Workaround for restoring an old pg_dump against the new schema
  • Documentation of the FK-NOT-NULL downgrade asymmetry

ADDITIONAL INFORMATION

  • Has associated issue: no (lifts a precondition for the versioning SIP #39464)
  • Required feature flags: none
  • Changes UI: no
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible (modulo the documented FK-NOT-NULL downgrade asymmetry, which is intentional and operator-irrelevant)
    • Confirm DB migration upgrade and downgrade tested (round-trip + idempotency, 44 tests passing on SQLite locally; CI matrix covers Postgres + MySQL)
    • Runtime estimates and downtime expectations: the affected tables are small (M:N junctions for dashboards/datasets/charts/users/roles/RLS/reports — typically thousands to low-millions of rows even on the largest Superset deployments). The copy_from recreate path is O(n) table-rewrite for dashboard_slices and report_schedule_user; the other six are direct ALTER. Expected to complete in seconds-to-tens-of-seconds.
  • Introduces new feature or API: no
  • Removes existing feature or API: no

Continuum verification deferral

Verification of SQLAlchemy-Continuum's M:N restore behaviour against the new schema is the responsibility of the versioning epic (versioning SIP #39464) and is not part of this PR. This PR delivers the schema precondition; the versioning work picks up sqlalchemy-continuum as a dependency and verifies the restore behaviour there.

@github-actions github-actions Bot added the risk:db-migration PRs that require a DB migration label May 4, 2026
@dosubot dosubot Bot added change:backend Requires changing the backend risk:breaking-change Issues or PRs that will introduce breaking changes labels May 4, 2026
@mikebridge
mikebridge marked this pull request as draft May 4, 2026 16:02
@codecov

codecov Bot commented May 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.42%. Comparing base (2da2db6) to head (74cdde0).
⚠️ Report is 11 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #39859      +/-   ##
==========================================
- Coverage   64.42%   64.42%   -0.01%     
==========================================
  Files        2668     2668              
  Lines      147258   147266       +8     
  Branches    33965    33965              
==========================================
- Hits        94877    94875       -2     
- Misses      50654    50662       +8     
- Partials     1727     1729       +2     
Flag Coverage Δ
hive 39.07% <ø> (-0.01%) ⬇️
mysql 57.67% <ø> (-0.01%) ⬇️
postgres 57.73% <ø> (-0.01%) ⬇️
presto 40.62% <ø> (-0.01%) ⬇️
python 59.14% <ø> (-0.01%) ⬇️
sqlite 57.32% <ø> (-0.01%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py Outdated
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-105349-composite-association-pks branch from dd4d48d to 0e5380d Compare May 4, 2026 23:26
@netlify

netlify Bot commented May 5, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 827aefd
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fa1da6a40abe0007935615
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 5, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit d9924a5
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a453c7c3718c3000855d269
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 5, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-105349-composite-association-pks branch from 3870647 to 7ae35b4 Compare May 5, 2026 16:55
@michael-s-molina

Copy link
Copy Markdown
Member

Hi folks. I'm attending an onsite and don't have much availability this week. At Airbnb, we have tables like query, logs , and key_value with millions of rows. Migrations like the ones proposed in this PR are a significant problem for us due to the downtime required to upgrade. Is the current PK structure blocking anything or is it just an improvement to the schema?

@mikebridge

Copy link
Copy Markdown
Contributor Author

Hi folks. I'm attending an onsite and don't have much availability this week. At Airbnb, we have tables like query, logs , and key_value with millions of rows. Migrations like the ones proposed in this PR are a significant problem for us due to the downtime required to upgrade. Is the current PK structure blocking anything or is it just an improvement to the schema?

Hey @michael-s-molina! Yes, this will only touch the join tables that join to users/dashboards/charts/roles so the cardinality should be quite a bit lower than for something like logging. We can touch base when you have some time and maybe I can get some data from you that will help me gauge impact. My hypothesis is that the worst-case scenario is under two minutes.

One big side-issue here is fixing the data-integrity vulnerabilities with the missing UNIQUE and NOT NULL constraints.

Anyway, a subset of this R is to remove a blocker for Versioning, but it's not strictly necessary to fix every table in this iteration. Ideally I'd like to fix all eight tables at once though.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 7, 2026
…105349)

Add a "Sizing the maintenance window on PostgreSQL" sub-section to the
operator runbook. The simple per-table COUNT/duplicate/NULL queries
that were already there are dialect-portable but only count rows;
operators on PostgreSQL with large deployments need to characterize
the migration's runtime cost before scheduling it.

Adds four diagnostic queries:

- Per-table size, row count (from pg_class.reltuples), and which
  migration path each table will take (recreate-rewrite vs direct
  ALTER). Sizes the work concretely.
- Aggregated duplicate-row roll-up: dup_groups + total rows_dropped
  per table. Replaces eight separate per-table queries with one
  consolidated result for audit/dump-before-apply decisions.
- External-FK pre-flight check (the same one the migration runs at
  upgrade time and aborts on). Lets operators surface any blocking
  external reference ahead of the maintenance window. Should be
  empty on a stock install.
- Lock-window estimate for the two full-rewrite tables, using
  pg_relation_size and a conservative 100 MB/s rewrite throughput
  assumption. The other six use direct ALTER and are dominated by
  composite-index build time (seconds for low-millions-of-rows
  tables).

Prompted by reviewer feedback on apache#39859 from a large
deployment asking how to size the maintenance window. The original
pre-flight queries are kept for cross-dialect operators (MySQL,
SQLite) since the new queries use PostgreSQL-specific catalog views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge

Copy link
Copy Markdown
Contributor Author

This should help to assess the impact on postgres:

  -- =====================================================================                                                                                                                    
  -- 1. Row counts and on-disk size per affected table                                            
  --    Tells us how much work the migration will do per table.                                                                                                                              
  --    The two tables marked WITH UNIQUE go through the slower                                                                                                                               
  --    "full table rewrite" path; the other six are direct ALTER.                                  
  -- =====================================================================                                                                                                                    
                                                                                       
  WITH affected(name, has_unique) AS (                                                                                                                                                        
    VALUES                                                                                     
      ('dashboard_roles',       false),                                                                                                                                                       
      ('dashboard_slices',      true),   -- full rewrite                 
      ('dashboard_user',        false),                                                                                                                                                       
      ('report_schedule_user',  true),   -- full rewrite                                                                                                                                      
      ('rls_filter_roles',      false),                                                                                                                                                       
      ('rls_filter_tables',     false),                                                                                                                                                       
      ('slice_user',            false),                                                                                                                                                       
      ('sqlatable_user',        false)                                                         
  )                                                                                                                                                                                           
  SELECT                                                                                                                                                                                      
    a.name                                                AS table_name, 
    CASE WHEN a.has_unique THEN 'recreate (full rewrite)'                                                                                                                                     
         ELSE 'direct ALTER' END                          AS migration_path,           
    c.reltuples::bigint                                   AS estimated_rows,                                                                                                                  
    pg_size_pretty(pg_total_relation_size(c.oid))         AS total_size, 
    pg_size_pretty(pg_relation_size(c.oid))               AS heap_size,                                                                                                                       
    pg_size_pretty(pg_indexes_size(c.oid))                AS index_size                        
  FROM affected a                                                                                                                                                                             
  JOIN pg_class c ON c.relname = a.name AND c.relkind = 'r'                                    
  ORDER BY pg_total_relation_size(c.oid) DESC;                                                                                                                                                

... and these will give some more details on some of the other data integrity issues:

  -- =====================================================================                                                                                                                    
  -- 2. Exact duplicate-row counts per table.                                                                                                                                                 
  --    The migration deletes duplicates (keeping MIN(id) per group).                                                                                                                         
  --    If any of these counts are nonzero, audit-sensitive operators                                                                                                                         
  --    should dump the affected rows BEFORE applying the migration.                                                                                                                          
  -- =====================================================================                     
                                                                                                                                                                                              
  SELECT 'dashboard_roles'      AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
    FROM (SELECT COUNT(*) c FROM dashboard_roles      GROUP BY dashboard_id, role_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'dashboard_slices',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_slices     GROUP BY dashboard_id, slice_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_user       GROUP BY user_id, dashboard_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id      HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_roles',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_roles     GROUP BY role_id, rls_filter_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'rls_filter_tables',   COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_tables    GROUP BY table_id, rls_filter_id          HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'slice_user',          COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM slice_user           GROUP BY user_id, slice_id                HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'sqlatable_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM sqlatable_user       GROUP BY user_id, table_id                HAVING COUNT(*) > 1) g                                                                        
  ORDER BY rows_dropped DESC NULLS LAST;                                                                                                                                                      
  -- =====================================================================                                                                                                                    
  -- 3. NULL-FK row counts.                                                                    
  --    The migration deletes any row with NULL in either FK column                                                                                                                           
  --    (since PK columns must be NOT NULL). Should normally be zero;                          
  --    nonzero means application bugs or manual SQL produced bad rows.                                                                                                                       
  -- =====================================================================                                                                                                                    
                                                                                                                                                                                              
  SELECT 'dashboard_roles'       AS t, COUNT(*) FILTER (WHERE dashboard_id IS NULL OR role_id IS NULL)             AS null_fk_rows FROM dashboard_roles                                       
  UNION ALL SELECT 'dashboard_slices',     COUNT(*) FILTER (WHERE dashboard_id IS NULL OR slice_id IS NULL)        FROM dashboard_slices                                                      
  UNION ALL SELECT 'dashboard_user',       COUNT(*) FILTER (WHERE user_id IS NULL OR dashboard_id IS NULL)         FROM dashboard_user                                                        
  UNION ALL SELECT 'report_schedule_user', COUNT(*) FILTER (WHERE user_id IS NULL OR report_schedule_id IS NULL)   FROM report_schedule_user
  UNION ALL SELECT 'rls_filter_roles',     COUNT(*) FILTER (WHERE role_id IS NULL OR rls_filter_id IS NULL)        FROM rls_filter_roles                                                      
  UNION ALL SELECT 'rls_filter_tables',    COUNT(*) FILTER (WHERE table_id IS NULL OR rls_filter_id IS NULL)       FROM rls_filter_tables                                                     
  UNION ALL SELECT 'slice_user',           COUNT(*) FILTER (WHERE user_id IS NULL OR slice_id IS NULL)             FROM slice_user                                                            
  UNION ALL SELECT 'sqlatable_user',       COUNT(*) FILTER (WHERE user_id IS NULL OR table_id IS NULL)             FROM sqlatable_user                                                        
  ORDER BY null_fk_rows DESC;                                                                                                                                                                 

If this returns any rows it'd really be a bad thing:

  -- =====================================================================                                                                                                                    
  -- 4. External FK references to the soon-to-be-removed `id` columns.                                                                                                                        
  --    The migration runs this same check as a pre-flight assertion and                                                                                                                      
  --    aborts if anything is found. Run it ahead of time so you know                                                                                                                         
  --    what (if anything) needs to be migrated/dropped first. On a                                                                                                                           
  --    standard Superset deployment this should return zero rows.                                                                                                                            
  --    (Default schema only; multi-schema deployments need to broaden.)                                                                                                                      
  -- =====================================================================                                                                                                                    
                                                                                                                                                                                              
  SELECT                                                                                                                                                                                      
    rc.constraint_name,                                                                                                                                                                       
    kcu.table_schema || '.' || kcu.table_name AS referencing_table,                                                                                                                           
    kcu.column_name                           AS referencing_column,                                                                                                                          
    ccu.table_name                            AS referenced_table,                                                                                                                            
    ccu.column_name                           AS referenced_column                                                                                                                            
  FROM information_schema.referential_constraints rc                                                                                                                                          
  JOIN information_schema.key_column_usage      kcu                                                                                                                                           
    ON kcu.constraint_name = rc.constraint_name                                                                                                                                               
   AND kcu.constraint_schema = rc.constraint_schema                      
  JOIN information_schema.constraint_column_usage ccu                                                                                                                                         
    ON ccu.constraint_name = rc.constraint_name                
   AND ccu.constraint_schema = rc.constraint_schema                                                                                                                                           
  WHERE ccu.table_name IN (                                                                    
          'dashboard_roles','dashboard_slices','dashboard_user',                                                                                                                              
          'report_schedule_user','rls_filter_roles','rls_filter_tables', 
          'slice_user','sqlatable_user')                                                                                                                                                      
    AND ccu.column_name = 'id';                                                                                                                                                               
  -- =====================================================================                                                                                                                    
  -- 5. Lock-window sizing for the recreate path on the two heaviest                                                                                                                          
  --    tables. The "full table rewrite" path takes an ACCESS EXCLUSIVE                        
  --    lock on the table for the duration. Estimate the rewrite time                                                                                                                         
  --    by combining heap size with your hardware's sequential write                                                                                                                          
  --    rate (≈100–200 MB/s on commodity SSD; faster on NVMe).                                 
  -- =====================================================================                                                                                                                    
                                                                                               
  SELECT                                                                                                                                                                                      
    c.relname                                       AS table_name,                                                                                                                            
    pg_size_pretty(pg_relation_size(c.oid))         AS heap_size,                                                                                                                             
    pg_relation_size(c.oid) / 1024 / 1024           AS heap_size_mb,                                                                                                                          
    -- conservative estimate: 100 MB/s effective rewrite throughput                                                                                                                           
    ROUND(pg_relation_size(c.oid) / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs                                                                                                  
  FROM pg_class c                                                                                                                                                                             
  WHERE c.relname IN ('dashboard_slices', 'report_schedule_user');      

@mikebridge

Copy link
Copy Markdown
Contributor Author

Here's the mysql equivalent:

1. Per-table size, row count, and which migration path each takes. Note that on InnoDB, ADD/DROP PRIMARY KEY rebuilds the clustered index, so all eight tables undergo a full rebuild — not just the two that go through the explicit recreate="always" path:

SELECT                                                                                                                                                                                      
  TABLE_NAME AS table_name,                                                                        
  CASE WHEN TABLE_NAME IN ('dashboard_slices', 'report_schedule_user')                                                                                                                      
       THEN 'recreate (explicit, drops UNIQUE)'
       ELSE 'direct ALTER (still rebuilds InnoDB clustered index)'                                                                                                                          
  END AS migration_path,                                                             
  TABLE_ROWS                                                  AS estimated_rows,                                                                                                            
  CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1), ' MB') AS total_size,         
  CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB')          AS heap_size,                  
  CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 1), ' MB')         AS index_size                  
FROM information_schema.TABLES                                                       
WHERE TABLE_SCHEMA = DATABASE()                                                                                                                                                             
  AND TABLE_NAME IN (                                                                                                                                                                       
    'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                                                                                                                
    'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                                                                                                                        
    'slice_user', 'sqlatable_user'                                                                                                                                                          
  )                                                                                                                                                                                         
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;                                                                                                                                                 
  1. Aggregated duplicate-row roll-up. dup_groups is the number of (fk1, fk2) pairs that appear more than once; rows_dropped is the total number of rows the migration will delete during the
    dedupe pass (it keeps MIN(id) per group):
  SELECT 'dashboard_roles'      AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped                                                                                               
    FROM (SELECT COUNT(*) c FROM dashboard_roles      GROUP BY dashboard_id, role_id            HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_slices',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_slices     GROUP BY dashboard_id, slice_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_user       GROUP BY user_id, dashboard_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id      HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_roles',    COUNT(*), SUM(c) - COUNT(*)                          
    FROM (SELECT COUNT(*) c FROM rls_filter_roles     GROUP BY role_id, rls_filter_id           HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_tables',   COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_tables    GROUP BY table_id, rls_filter_id          HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'slice_user',          COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM slice_user           GROUP BY user_id, slice_id                HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'sqlatable_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM sqlatable_user       GROUP BY user_id, table_id                HAVING COUNT(*) > 1) g                                                                        
  ORDER BY rows_dropped DESC;                                                                                                                                                                 
  1. External-FK pre-flight check. The migration runs the equivalent at upgrade time and aborts if anything is found. Should return zero rows on a stock Superset install:
  SELECT                                                                                                                                                                                      
    CONSTRAINT_NAME,                                                     
    CONCAT(TABLE_SCHEMA, '.', TABLE_NAME)        AS referencing_table,                                                                                                                        
    COLUMN_NAME                                  AS referencing_column,                                                                                                                       
    REFERENCED_TABLE_NAME                        AS referenced_table,                                                                                                                         
    REFERENCED_COLUMN_NAME                       AS referenced_column                                                                                                                         
  FROM information_schema.KEY_COLUMN_USAGE                                                                                                                                                    
  WHERE TABLE_SCHEMA = DATABASE()                                                              
    AND REFERENCED_TABLE_NAME IN (                                                                                                                                                            
      'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                                                                                                                
      'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                         
      'slice_user', 'sqlatable_user'                                                                                                                                                          
    )                                                                                                                                                                                         
    AND REFERENCED_COLUMN_NAME = 'id';                                                                                                                                                        
  1. Lock-window estimate for all eight tables. ADD PRIMARY KEY is INPLACE but not LOCK=NONE — concurrent reads OK, writes blocked. Combine heap_size_mb with your effective rebuild
    throughput (~100–200 MB/s on commodity SSD; higher on NVMe) to size the maintenance window per table:
  SELECT                                                                                                                                                                                      
    TABLE_NAME                                            AS table_name,                       
    CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB')    AS heap_size,                                                                                                                       
    ROUND(DATA_LENGTH / 1024 / 1024, 1)                   AS heap_size_mb,
    ROUND(DATA_LENGTH / 1024 / 1024 / 100.0, 1)           AS est_rewrite_seconds_at_100mbs                                                                                                    
  FROM information_schema.TABLES                                                                                                                                                              
  WHERE TABLE_SCHEMA = DATABASE()                                                                                                                                                             
    AND TABLE_NAME IN (                                                                                                                                                                       
      'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                 
      'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                         
      'slice_user', 'sqlatable_user'                                                   
    )                                                                                                                                                                                         
  ORDER BY DATA_LENGTH DESC;

@github-actions github-actions Bot added the risk:ci-script PR modifies scripts that execute in CI (supply chain risk) label May 7, 2026
Mike Bridge and others added 13 commits July 1, 2026 10:11
Add ``scripts/seed_junction_load.py``, a backend-agnostic script that
bulk-inserts synthetic parent rows (dashboards, slices, users, roles,
tables, dbs) and many-to-many junction rows for the four largest
association tables targeted by the composite-PK migration:
``dashboard_slices``, ``slice_user``, ``dashboard_user``,
``dashboard_roles``.

Designed for measuring migration runtime at varying scales — run with
a series of size flags (100K / 1M / 5M / 10M for the target table)
and time the migration at each scale to verify the predicted
``O(N log N)`` extrapolation against real numbers.

Properties:
- **Reproducible**: deterministic cross-product walk through parent IDs
  produces a stable pair sequence; re-running is replayable.
- **Idempotent**: re-running with the same target is a no-op; with a
  higher target, only new rows are added.
- **Backend-agnostic**: connects via Superset's standard ``DATABASE_*``
  env vars (or ``SUPERSET__SQLALCHEMY_DATABASE_URI``). Branches on
  dialect for ``BINARY(16)`` vs ``UUID`` vs TEXT/BLOB UUID columns.
- **Batched**: bulk INSERT 10K rows per statement.
- **Per-phase timing**: logs elapsed wall time for the parents phase,
  the junctions phase as a whole, and per junction-table.
- **Avoidance set**: loads existing junction pairs into a Python set
  so re-runs on top of pre-existing data don't collide on the
  uniqueness constraint.

Usage (inside the Superset container):

    docker exec superset-superset-1 \\
        /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
        --dashboard-slices 1000000

Defaults target a "large multi-team install" shape: 1M
``dashboard_slices``, 100K each ``slice_user`` / ``dashboard_user``,
10K ``dashboard_roles``. Override per-table via flags.

Tested locally on MySQL (the user's current eval stack):
- 200/100/100/50 row mini-run produced expected counts.
- Re-running at the same target is a no-op (idempotent).
- ``--dry-run`` plans without writing.

Junction tables not yet covered (``sqlatable_user``, ``rls_filter_*``,
``report_schedule_user``) are typically small in production and
require additional parent seeding (RLS filters, report schedules)
that wasn't worth the scope here. Adding them is straightforward by
extending ``JUNCTIONS`` and writing the corresponding parent seeder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the stress-test seed script with an optional duplicate-row
injection step, used to measure the empirical cost of the migration's
``_dedupe_by_min_id`` phase.

Usage: after running the normal seed at a given scale, add
``--dirty-duplicates-pct 5`` (or any non-zero value) to inject that
percentage of duplicate ``(fk1, fk2)`` rows into each non-UNIQUE
junction (slice_user, dashboard_user, dashboard_roles —
dashboard_slices is skipped because its UNIQUE constraint, present
both pre- and post-migration, rejects duplicates).

Pre-condition: requires the DB to be at the pre-migration revision
(33d7e0e21daa). The post-migration composite PK rejects duplicates,
so attempting to inject on the upgraded schema errors out.

Empirical result on MySQL @ 10M dashboard_slices + ~2.1M other
junction rows + 105K injected duplicates (5% on the 3 non-UNIQUE
tables):
  Upgrade time: 1m 36s vs clean baseline 1m 37s
  → dedupe cost is within measurement noise; the table-scan that
    the migration already performs dominates whether or not
    duplicates exist.

This empirically confirms what the cost-model predicted: the
``_dedupe_by_min_id`` GROUP BY scan is the dominant cost of that
phase, and the actual per-duplicate DELETE is negligible.

NULL-FK injection deliberately skipped — would require altering the
six non-UNIQUE FK columns from NOT NULL back to nullable (the
migration's downgrade keeps them NOT NULL by design), which adds
per-backend ALTER complexity for a code path that's structurally
identical in cost shape (DELETE WHERE col IS NULL is the same scan
shape as the dedupe scan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…5349)

Justin Park (@justinpark) reported on apache#39859:

  MySQLdb.OperationalError: (1832, "Cannot change column 'dashboard_id':
  used in a foreign key constraint 'fk_dashboard_roles_dashboard_id_dashboards'")

Root cause: ``batch_op.alter_column(fk1, nullable=False)`` for the six
non-UNIQUE association tables emits ``ALTER COLUMN`` on a column that
participates in an FK constraint. MySQL 8 rejects this with ERROR 1832
when the table has data — even when the change is just ``NULL`` →
``NOT NULL`` and the column is already part of a freshly-added
composite primary key (which InnoDB has just made implicitly NOT NULL
anyway). The error fires on populated tables only; CI's ``test-mysql``
shard runs against empty tables and so didn't catch this, while a
real production-shaped install does.

The ``alter_column`` was only ever needed for SQLite, where composite
``PRIMARY KEY`` does not promote constituent columns to ``NOT NULL``
(a long-standing SQLite quirk — only ``INTEGER PRIMARY KEY`` does).
PostgreSQL and MySQL implicitly promote PK columns to ``NOT NULL`` as
part of ``ADD PRIMARY KEY``, so the explicit step is unnecessary on
both — and on MySQL it's actively broken on populated tables.

Fix: extract the ``alter_column`` pair into a helper
``_enforce_not_null_for_sqlite()`` that no-ops on Postgres and MySQL.
Both branches of the per-table upgrade (the ``recreate="always"`` path
for the two UNIQUE-bearing tables, and the direct-ALTER path for the
other six) now call the helper instead of inlining the
``alter_column``.

Verified end-to-end: downgrade-then-upgrade against MySQL with
~12M total junction rows (10M dashboard_slices + 1M each
slice_user/dashboard_user + 100K dashboard_roles) completes in
1m 39s with no ERROR 1832. The 44 in-memory SQLite tests still pass.

Considered Justin's alternative (drop FKs on MySQL across all eight
tables, unifying the two branches) but rejected as more invasive —
it would require capturing FK metadata and explicitly re-creating
the FKs for the six non-recreate tables, since they don't go through
the ``copy_from`` path that re-creates FKs automatically. The
SQLite-only approach is more targeted: it removes the operation that
MySQL rejects rather than working around the rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three improvements from @aminghadersohi's review on
apache#39859:

1. **`fk["name"]` unguarded in ``_downgrade_mysql_table`` re-add loop**

   The drop loop gates on ``if fk_name := fk.get("name"):`` but the
   re-add loop accessed ``fk["name"]`` unconditionally in an f-string.
   MySQL/InnoDB always assigns FK names, so this branch was defensive,
   but the asymmetry was confusing. Symmetrized via ``continue`` at the
   top of the re-add loop.

2. **``ondelete`` whitelist before raw-SQL interpolation**

   The value comes from MySQL's ``information_schema`` (not user
   input), but interpolating a reflected string into raw SQL without a
   guard left a "what if an unexpected value appears" footgun. Added
   ``_VALID_ONDELETE_ACTIONS`` (the four SQL-standard actions) and a
   ``RuntimeError`` when an unexpected value is reflected.

3. **Direct ALTER on PostgreSQL for tables with pre-existing UNIQUE**

   ``recreate="always"`` is dialect-agnostic — on PostgreSQL it
   triggers ``CREATE TABLE AS SELECT → DROP → RENAME`` holding
   ``ACCESS EXCLUSIVE`` for the full table-copy duration. For a
   multi-million-row ``dashboard_slices``, that lock window can be
   noticeable. The reflected UNIQUE constraint has a stable name on
   PostgreSQL (default ``<table>_<cols>_key`` convention), so dropping
   it directly and then running structural change as direct ALTER
   avoids the copy entirely.

   The reflected UNIQUE name is wrapped in a new
   ``_drop_redundant_unique_by_name()`` helper. Postgres takes the
   direct path; MySQL keeps ``recreate="always"`` because InnoDB binds
   FKs to the UNIQUE's underlying index for back-reference (``DROP
   CONSTRAINT`` on the UNIQUE there raises ``ERROR 1553``); SQLite
   keeps ``recreate="always"`` because unnamed UNIQUEs reflect with
   ``name=None`` and can't be dropped by name.

Verified end-to-end: downgrade-then-upgrade against MySQL with
~12M total junction rows seeded completes in ~1m 41s (within the
range of the prior measurements).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Belt-and-braces invariant: ``t.name`` is interpolated as a
backtick-quoted identifier into the ALTER statements emitted by
``_downgrade_mysql_table``. The values originate from
``AFFECTED_TABLES`` (a module-level literal), so SQL injection is
already structurally precluded at the call site. Adding an explicit
``allowed = {a.name for a in AFFECTED_TABLES}`` membership check
makes that invariant load-bearing rather than implicit — a future
refactor that loosens the call-site can't slip past review.

Surfaced during a downstream SQLAlchemy review on the entity-versioning
branch that stacks on top of this one; lifted onto sc-105349 because
the patch is properly scoped to this branch's composite-PK migration.
After rebasing onto master, 2bee73611e32 and master's 31dae2559c05 both revised 33d7e0e21daa, forking the alembic chain into two heads ('superset db upgrade' refuses to run). Re-point down_revision at 31dae2559c05 so the versioning chain extends the real head.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MySQL branch dropped the live FK constraints and then re-reflected them for the copy_from table — which only returned the pre-drop list via the Inspector's per-instance info_cache, an implementation detail. Capture the list before dropping and pass it through explicitly (the downgrade path already did this).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes from a 4-lens review pass:

- Resumability guard: on MySQL every DDL statement auto-commits, so a failure at table N of 8 left tables 1..N-1 converted with alembic_version un-stamped — re-running failed at table 1 (drop_column('id') on a converted table) and downgrade couldn't run either. Skip tables whose id column is already gone, making re-runs safe on every dialect.

- The down_revision re-point left two stale 33d7e0e21daa references: the migration docstring header, and — operationally worse — the seed script's --dirty-duplicates-pct help text, which instructed a downgrade that would unwind every migration since 2025-11. The help text now points at the migration's down_revision instead of hardcoding a hash.

Also: drop the never-called required_parent_count helper and trim report_schedule_user from JUNCTIONS_WITH_UNIQUE (the script never seeds that table; the entry implied coverage that doesn't exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The migration's riskiest half — _delete_null_fk_rows / _dedupe_by_min_id / _assert_no_duplicates — had zero coverage: the fixtures seeded no rows, and both test schema builders created FK columns NOT NULL, diverging from the real pre-migration shape (six of eight tables allowed NULLs), so test_fk_columns_not_null passed trivially.

- Build the pre-migration schema with historically-accurate nullable FKs (keyed on the migration's TABLES_WITH_NULLABLE_FKS, giving that documentation set a load-bearing consumer).
- Add test_upgrade_scrubs_null_fks_and_duplicates: seeds NULL-FK rows and duplicate pairs, runs upgrade, asserts exactly the distinct non-NULL pairs survive. Verified deletable-detectable: commenting out _dedupe_by_min_id makes it fail.
- Delete the permanently-skipped placeholder test and the captured-but-never-asserted pre_shape; replace spec-kit references (T034a/tasks.md/quickstart.md) with self-contained prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four corrections to the maintenance-window guidance:

- PostgreSQL takes the direct-ALTER path for ALL eight tables (the redundant UNIQUEs are dropped by name); the doc described a recreate='always' full rewrite the code deliberately avoids, and sized only those two tables. The lock-window query now covers all eight.
- State the cumulative-lock property: Alembic runs the upgrade in one transaction on Postgres, so ACCESS EXCLUSIVE locks are held until commit — total unavailability is the sum of per-table windows; quiesce the app.
- MySQL: DROP COLUMN and ADD PRIMARY KEY are separate ALTERs, so most tables pay the InnoDB clustered-index rebuild twice — budget ~2x the single-rebuild estimate.
- Downgrade is a comparable maintenance window in its own right, not a quick undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After rebasing onto current master, the migration root pointed at a stale
master revision, forking alembic into multiple heads in the PR-merge CI.
Re-point down_revision onto master's current head so the chain is linear.
Per review (@rusackas): the UPDATING.md entry was ~240 lines of inline
pre-flight SQL, lock-window estimates, and runbook detail. Cut it to a
concise "what changed + what you need to know before upgrading" note
(the affected tables, the not-preserved row classes, the downgrade
asymmetry) and point operators to the PR for the full runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… head

Rebased onto master; another migration (a7d3f1b9c2e4,
cleanup_stale_can_import_pvm) merged off the same parent (78a40c08b4be),
so re-point down_revision 78a40c08b4be -> a7d3f1b9c2e4 to keep a single
Alembic head. The two migrations are unrelated (m:n association PKs vs a
permission cleanup), so the ordering is a no-op beyond the head chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py
Comment thread scripts/seed_junction_load.py Outdated
Comment thread scripts/seed_junction_load.py Outdated
Comment thread docker/mysql-init/examples-init.sql Outdated
Comment thread tests/integration_tests/migrations/composite_pk_round_trip__tests.py Outdated
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py Outdated
Mike Bridge added 2 commits July 1, 2026 10:38
The docker-compose-mysql.yml override, docker/mysql-init/examples-init.sql,
and scripts/seed_junction_load.py were a local harness for timing the
composite-PK migration on MySQL at scale — not product code (and the
examples-init account carried dev-only credentials). Preserved outside the
repo for local reuse.
… docstring

- round-trip test: build the per-table shape diff with != instead of
  set-differencing .items() (values are unhashable dicts, so a real
  failure previously raised TypeError while formatting the message and
  hid the regression).
- association-tables test: correct the module docstring — the schema is
  built synthetically from the hardcoded AFFECTED_TABLES list, not the
  live ORM models.
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py
@rusackas

rusackas commented Jul 1, 2026

Copy link
Copy Markdown
Member

Obviously some bot comments to wrangle, but drawing attention to our codeowners (@michael-s-molina @mistercrunch @eschutho @sadpandajoe) for their review(s) and I think it might be wise to have @betodealmeida take a quick scan of it too.

Oh... @betodealmeida is codeowner too... the reviewer list is just in a funny order and I didn't spot it :P

On MySQL, DDL auto-commits (no transactional rollback), so a run killed
after the FK-drop loop but before the table recreate re-adds them leaves
a junction table FK-less. Both the upgrade and downgrade capture the FK
set by reflecting the live table, so a naive retry would rebuild with the
empty reflected set and silently strip the foreign keys. Add
_assert_fks_present() to both MySQL paths — an empty reflection now raises
with a restore-from-backup message instead of proceeding. Unit-tested.

Addresses review feedback on apache#39859.
Comment thread superset/reports/models.py
@mikebridge

Copy link
Copy Markdown
Contributor Author

Retested up/down on MySQL 8 locally ✅

Since the MySQL-specific paths (the InnoDB FK drop-and-recreate dance and the _dedupe_by_min_id phase against a live UNIQUE/composite-PK constraint) never execute on Postgres or SQLite, I re-ran the migration end-to-end on MySQL 8.0.44 against seeded data.

Setup — migrated to the parent revision, then seeded the junction tables with seed_junction_load.py (3k dashboard_slices, 3k slice_user, 3k dashboard_user, 1k dashboard_roles) and injected duplicate (fk1, fk2) pairs on the three non-UNIQUE tables. Seeding matters here: InnoDB defers/skips constraint validation on empty tables, so an empty-table pass would be a false positive. report_schedule_user was left empty on purpose to also cover the empty-UNIQUE-table path.

Phase Result
Up — dedup Removed exactly 150 / 450 / 450 rows (min-id kept) before the composite PK was added — _dedupe_by_min_id verified on real rows
Up — dashboard_slices composite PK (dashboard_id, slice_id); id dropped; redundant UNIQUE dropped; both FKs preserved through the InnoDB recreate
Up — report_schedule_user (empty) converted to composite PK, both FKs preserved
Down id restored (AUTO_INCREMENT) as PK; UNIQUE + FKs restored; dedup correctly not reversed
Second up/down cycle re-ran on the downgrade-drifted schema (renamed UNIQUE) — dynamic constraint-name reflection handled it; stable fixed point, no errors

The crash-safety guard added in 74cdde0ab4 (_assert_fks_present) ran on both InnoDB-dance tables and passed without a false positive (both retained their FK constraints, including the empty table).

@bito-code-review

bito-code-review Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f41010

Actionable Suggestions - 0
Additional Suggestions - 3
  • superset/migrations/versions/2026-05-01_23-36_2bee73611e32_composite_pk_association_tables.py - 3
    • Downgrade name asymmetry · Line 498-499
      The downgrade path creates a UNIQUE constraint with a deterministic new name (`uq___`) instead of recovering the original constraint name from inspection. This asymmetry with `upgrade()` (which inspects and uses the original name at line 295-297) means a downgrade-then-upgrade round-trip produces a differently-named constraint. If the original constraint had a dialect-specific auto-generated name (e.g., `dashboard_slices_dashboard_id_slice_id_key` on Postgres), operators or tooling that reference it by name would break.
    • CWE-243: case mismatch in ON DELETE · Line 595-595
      The `ondelete.upper()` validation at line 590 uses uppercase for comparison, but line 595 interpolates the raw `ondelete` value (possibly lowercase) directly into the SQL output. While MySQL is case-insensitive for the action keywords, using the validated uppercase form maintains consistency between validation and output.
    • Missing ON UPDATE preservation · Line 228-275
      The `_build_pre_upgrade_table` function preserves `ondelete` (line 270) but does not handle `onupdate`. If any FK in the affected tables carries an `ON UPDATE CASCADE` or similar action, it will be silently dropped during the table recreation in the upgrade path.
    • Review Details
      • Files reviewed - 8 · Commit Range: 05180f4..74cdde0
        • superset/connectors/sqla/models.py
        • superset/migrations/versions/2026-05-01_23-36_2bee73611e32_composite_pk_association_tables.py
        • superset/models/dashboard.py
        • superset/models/slice.py
        • superset/reports/models.py
        • tests/integration_tests/migrations/composite_pk_association_tables__tests.py
        • tests/integration_tests/migrations/composite_pk_round_trip__tests.py
        • tests/unit_tests/migrations/composite_pk_association_tables_test.py
      • Files skipped - 1
        • UPDATING.md - Reason: Filter setting
      • Tools
        • MyPy (Static Code Analysis) - ✔︎ Successful
        • Astral Ruff (Static Code Analysis) - ✔︎ Successful
        • Whispers (Secret Scanner) - ✔︎ Successful
        • Detect-secrets (Secret Scanner) - ✔︎ Successful

      Bito Usage Guide

      Commands

      Type the following command in the pull request comment and save the comment.

      • /review - Manually triggers a full AI review.

      • /pause - Pauses automatic reviews on this pull request.

      • /resume - Resumes automatic reviews.

      • /resolve - Marks all Bito-posted review comments as resolved.

      • /abort - Cancels all in-progress reviews.

      Refer to the documentation for additional commands.

      Configuration

      This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

      Documentation & Help

      AI Code Review powered by Bito Logo

@betodealmeida betodealmeida left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend review:draft risk:breaking-change Issues or PRs that will introduce breaking changes risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants