From 42038e1145dd43b3910055c364fb720a8a152d61 Mon Sep 17 00:00:00 2001 From: Thirunarayanan Balathandayuthapani Date: Thu, 3 Sep 2026 19:19:08 +0530 Subject: [PATCH] MDEV-26057 Assertion `!vcol->v_indexes.empty() in trx_undo_log_v_idx Problem: ======== - Rollback of an INPLACE ALTER TABLE is executed while holding only a shared metadata lock on the table, so DML can run concurrently. rollback_inplace_alter_table() resets dict_col_t::ord_part in a critical section of its own, after row_merge_drop_indexes() already removed the aborted indexes from the dictionary cache and emptied dict_v_col_t::v_indexes. During this time, DML statement can see a virtual column with ord_part set and an empty v_indexes, which makes assert failure in trx_undo_report_insert_virtual(). Solution: ======== row_merge_reset_ord_part(): Added a function to reset dict_col_t::ord_part for the columns that are no longer a field of any index remaining in the dictionary cache. For virtual columns the decision is based on dict_v_col_t::v_indexes being empty, and no element is ever removed from that list. row_merge_drop_indexes(): Added a call to row_merge_reset_ord_part() in the branch that removes the indexes from the cache, in the same dict_sys.latch critical section. That branch is taken only when MDL_EXCLUSIVE is held or when this is the only handle to the table, so no concurrent DML can observe the intermediate state. In the lazy drop branch the indexes and their v_indexes entries stay in the cache and nothing is reset; that is done later, when the indexes are dropped while holding MDL_EXCLUSIVE. check_col_exists_in_indexes(): Removed the only_committed parameter, which no longer has any caller. row_quiesce_col_ord_part(): Added a function to get dict_col_t::ord_part and dict_col_t::max_prefix of a column from the committed indexes that are present in the dictionary cache. row_quiesce_write_table(): Write the row_quiesce_col_ord_part() return values to the .cfg file instead of the cached dict_col_t fields, because a rolled back ADD INDEX leaves ord_part set until the aborted index is removed by a later DDL, and max_prefix is never reset when an index is dropped, which makes IMPORT TABLESPACE reject the tablespace with a bogus schema mismatch. --- .../innodb/r/import_tablespace_race.result | 2 +- .../r/innodb-virtual-columns-debug.result | 25 ++++++++ .../innodb/t/import_tablespace_race.test | 2 +- .../t/innodb-virtual-columns-debug.test | 25 ++++++++ storage/innobase/handler/handler0alter.cc | 25 +------- storage/innobase/row/row0merge.cc | 45 +++++++++++++++ storage/innobase/row/row0quiesce.cc | 57 ++++++++++++++++++- 7 files changed, 155 insertions(+), 26 deletions(-) diff --git a/mysql-test/suite/innodb/r/import_tablespace_race.result b/mysql-test/suite/innodb/r/import_tablespace_race.result index 6c8b2e3a26e50..90c8c92241b21 100644 --- a/mysql-test/suite/innodb/r/import_tablespace_race.result +++ b/mysql-test/suite/innodb/r/import_tablespace_race.result @@ -16,7 +16,7 @@ END REPEAT; END $ connection default; -ALTER TABLE t NOWAIT ADD INDEX (c); +ALTER TABLE t NOWAIT ADD INDEX (c(20)); connection con1; connection default; FLUSH TABLE t FOR EXPORT; diff --git a/mysql-test/suite/innodb/r/innodb-virtual-columns-debug.result b/mysql-test/suite/innodb/r/innodb-virtual-columns-debug.result index df6bf83d24f6f..21f4ee390930e 100644 --- a/mysql-test/suite/innodb/r/innodb-virtual-columns-debug.result +++ b/mysql-test/suite/innodb/r/innodb-virtual-columns-debug.result @@ -16,3 +16,28 @@ disconnect con1; connection default; SET debug_sync= "RESET"; DROP TABLE t1; +# +# MDEV-26057 Assertion `!vcol->v_indexes.empty() in trx_undo_log_v_idx +# +CREATE TABLE t1(f1 INT NOT NULL, f2 INT NOT NULL, +f3 INT AS (f2) VIRTUAL) ENGINE=InnoDB; +INSERT INTO t1(f1, f2) VALUES(1, 1); +SET DEBUG_SYNC="alter_rollback_complete SIGNAL dml_start WAIT_FOR default_resume"; +set STATEMENT DEBUG_DBUG="d,innodb_OOM_inplace_alter" FOR alter table t1 add index(f3), algorithm=inplace; +connect con1,localhost,root,,,; +SET DEBUG_SYNC="now WAIT_FOR dml_start"; +INSERT INTO t1(f1, f2) VALUES(2, 2); +SET DEBUG_SYNC="now SIGNAL default_resume"; +connection default; +ERROR HY000: Out of memory. +connection default; +disconnect con1; +SET DEBUG_SYNC="RESET"; +SELECT * FROM t1; +f1 f2 f3 +1 1 1 +2 2 2 +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +DROP TABLE t1; diff --git a/mysql-test/suite/innodb/t/import_tablespace_race.test b/mysql-test/suite/innodb/t/import_tablespace_race.test index aca0b5df06d03..8f6a1a431d975 100644 --- a/mysql-test/suite/innodb/t/import_tablespace_race.test +++ b/mysql-test/suite/innodb/t/import_tablespace_race.test @@ -26,7 +26,7 @@ $ --connection default --error 0,ER_LOCK_WAIT_TIMEOUT -ALTER TABLE t NOWAIT ADD INDEX (c); +ALTER TABLE t NOWAIT ADD INDEX (c(20)); --connection con1 --reap diff --git a/mysql-test/suite/innodb/t/innodb-virtual-columns-debug.test b/mysql-test/suite/innodb/t/innodb-virtual-columns-debug.test index d4947ca4d8b5f..ed160b977a4e7 100644 --- a/mysql-test/suite/innodb/t/innodb-virtual-columns-debug.test +++ b/mysql-test/suite/innodb/t/innodb-virtual-columns-debug.test @@ -25,3 +25,28 @@ SET debug_sync= "now SIGNAL another_open"; --connection default SET debug_sync= "RESET"; DROP TABLE t1; + +--echo # +--echo # MDEV-26057 Assertion `!vcol->v_indexes.empty() in trx_undo_log_v_idx +--echo # +CREATE TABLE t1(f1 INT NOT NULL, f2 INT NOT NULL, + f3 INT AS (f2) VIRTUAL) ENGINE=InnoDB; +INSERT INTO t1(f1, f2) VALUES(1, 1); + +SET DEBUG_SYNC="alter_rollback_complete SIGNAL dml_start WAIT_FOR default_resume"; +send set STATEMENT DEBUG_DBUG="d,innodb_OOM_inplace_alter" FOR alter table t1 add index(f3), algorithm=inplace; + +connect(con1,localhost,root,,,); +SET DEBUG_SYNC="now WAIT_FOR dml_start"; +INSERT INTO t1(f1, f2) VALUES(2, 2); +SET DEBUG_SYNC="now SIGNAL default_resume"; + +connection default; +--error ER_OUT_OF_RESOURCES +--reap +connection default; +disconnect con1; +SET DEBUG_SYNC="RESET"; +SELECT * FROM t1; +CHECK TABLE t1; +DROP TABLE t1; diff --git a/storage/innobase/handler/handler0alter.cc b/storage/innobase/handler/handler0alter.cc index dfd7b261eead2..b3d94d55956af 100644 --- a/storage/innobase/handler/handler0alter.cc +++ b/storage/innobase/handler/handler0alter.cc @@ -9108,7 +9108,6 @@ argument passed. @param[in] table table @param[in] col_no column number @param[in] is_v if this is a virtual column -@param[in] only_committed whether to consider only committed indexes @retval true column exists @retval false column does not exist, true if column is system column or it is in the index. */ @@ -9117,8 +9116,7 @@ bool check_col_exists_in_indexes( const dict_table_t* table, ulint col_no, - bool is_v, - bool only_committed = false) + bool is_v) { /* This function does not check system columns */ if (!is_v && dict_table_get_nth_col(table, col_no)->mtype == DATA_SYS) { @@ -9129,9 +9127,7 @@ check_col_exists_in_indexes( index; index = dict_table_get_next_index(index)) { - if (only_committed - ? !index->is_committed() - : index->to_be_dropped) { + if (index->to_be_dropped) { continue; } @@ -9342,6 +9338,7 @@ inline bool rollback_inplace_alter_table(Alter_inplace_info *ha_alter_info, ctx->trx->free(); ctx->trx= nullptr; + DEBUG_SYNC_C("alter_rollback_complete"); dict_sys.lock(SRW_LOCK_CALL); if (ctx->add_vcol) @@ -9369,22 +9366,6 @@ inline bool rollback_inplace_alter_table(Alter_inplace_info *ha_alter_info, DBUG_ASSERT(prebuilt->table->indexes.start->online_status == ONLINE_INDEX_COMPLETE); - /* Reset dict_col_t::ord_part for unindexed columns */ - for (ulint i= 0; i < dict_table_get_n_cols(prebuilt->table); i++) - { - dict_col_t &col= prebuilt->table->cols[i]; - if (col.ord_part && !check_col_exists_in_indexes(prebuilt->table, i, false, - true)) - col.ord_part= 0; - } - - for (ulint i = 0; i < dict_table_get_n_v_cols(prebuilt->table); i++) - { - dict_col_t &col = prebuilt->table->v_cols[i].m_col; - if (col.ord_part && !check_col_exists_in_indexes(prebuilt->table, i, true, - true)) - col.ord_part= 0; - } dict_sys.unlock(); trx_commit_for_mysql(prebuilt->trx); prebuilt->trx_id = 0; diff --git a/storage/innobase/row/row0merge.cc b/storage/innobase/row/row0merge.cc index c60032a8d5ec3..e2bec8e5d3653 100644 --- a/storage/innobase/row/row0merge.cc +++ b/storage/innobase/row/row0merge.cc @@ -4014,6 +4014,43 @@ static void row_merge_drop_fulltext_indexes(trx_t *trx, dict_table_t *table) DICT_TF2_FLAG_UNSET(table, DICT_TF2_FTS); } +/** @param table table +@param col a stored column of the table +@return whether the column is a field of any index of the table */ +static bool row_merge_col_is_indexed(const dict_table_t &table, + const dict_col_t &col) +{ + for (const dict_index_t *index= dict_table_get_first_index(&table); index; + index= dict_table_get_next_index(index)) + for (unsigned f= 0; f < index->n_uniq; f++) + if (dict_index_get_nth_col(index, f) == &col) + return true; + return false; +} + +/** Reset dict_col_t::ord_part for the columns that are no +longer a field of any index that remains in the data dictionary cache. +@param table table whose columns are to be adjusted */ +static void row_merge_reset_ord_part(dict_table_t *table) +{ + ut_ad(dict_sys.locked()); + + for (unsigned c= 0; c < table->n_v_def; c++) + { + dict_v_col_t &v= table->v_cols[c]; + if (v.m_col.ord_part && v.v_indexes.empty()) + v.m_col.ord_part= 0; + } + + for (unsigned c= 0; c < table->n_cols; c++) + { + dict_col_t &col= table->cols[c]; + if (col.ord_part && col.mtype != DATA_SYS && + !row_merge_col_is_indexed(*table, col)) + col.ord_part= 0; + } +} + /** Drop indexes that were created before an error occurred. The data dictionary must have been locked exclusively by the caller, because the transaction will not be committed. @@ -4188,6 +4225,14 @@ row_merge_drop_indexes( } row_merge_drop_fulltext_indexes(trx, table); + /* The aborted indexes are gone from the cache. + Adjust dict_col_t::ord_part while still holding the + same dict_sys.latch, so that DML will never observe a + column with ord_part set and an empty dict_v_col_t::v_indexes. + In the lazy drop above, the indexes remain in the cache, + and nothing must be reset; */ + ut_ad(locked || table->get_ref_count() == 1); + row_merge_reset_ord_part(table); table->drop_aborted = FALSE; ut_d(dict_table_check_for_dup_indexes(table, CHECK_ALL_COMPLETE)); } diff --git a/storage/innobase/row/row0quiesce.cc b/storage/innobase/row/row0quiesce.cc index 36d7e5a9c450a..68b5f9e8e2439 100644 --- a/storage/innobase/row/row0quiesce.cc +++ b/storage/innobase/row/row0quiesce.cc @@ -210,6 +210,54 @@ row_quiesce_write_indexes( return(err); } +/** Compute dict_col_t::ord_part and dict_col_t::max_prefix +of a column from the committed indexes that are present in +the data dictionary cache, in the same way as +dict_index_add_to_cache() would compute them when the table +definition is being loaded from SYS_INDEXES. + +The cached values can be stale. A rolled back ADD INDEX +can leave dict_col_t::ord_part set until the aborted index +is removed from the dictionary cache by a subsequent DDL +operation, and dict_col_t::max_prefix is never reset when an +index is being dropped. +@param table table +@param col a column of the table +@param max_prefix computed dict_col_t::max_prefix +@return computed dict_col_t::ord_part */ +static bool row_quiesce_col_ord_part(const dict_table_t &table, + const dict_col_t &col, + ulint &max_prefix) +{ + bool ord_part= false; + max_prefix= 0; + + for (const dict_index_t* index= UT_LIST_GET_FIRST(table.indexes); + index; index= UT_LIST_GET_NEXT(indexes, index)) + { + if (!index->is_committed()) + continue; + + for (unsigned i= 0; i < index->n_uniq; i++) + { + const dict_field_t& field= index->fields[i]; + if (field.col != &col) + continue; + + if (!field.prefix_len) + { + max_prefix= 0; + return true; + } + + if (field.prefix_len > max_prefix) + max_prefix= field.prefix_len; + ord_part= true; + } + } + return ord_part; +} + /*********************************************************************//** Write the meta data (table columns) config file. Serialise the contents of dict_col_t structure, along with the column name. All fields are serialized @@ -251,10 +299,15 @@ row_quiesce_write_table( mach_write_to_4(ptr, col->ind); ptr += sizeof(ib_uint32_t); - mach_write_to_4(ptr, col->ord_part); + /* Derive the values from the indexes rather than using + the cached dict_col_t fields, which can be stale. */ + ulint max_prefix; + + mach_write_to_4(ptr, row_quiesce_col_ord_part(*table, *col, + max_prefix)); ptr += sizeof(ib_uint32_t); - mach_write_to_4(ptr, col->max_prefix); + mach_write_to_4(ptr, max_prefix); DBUG_EXECUTE_IF("ib_export_io_write_failure_2", close(fileno(file)););