@@ -683,6 +683,269 @@ func TestModifyColumnWithSkipReorg(t *testing.T) {
683683 require .Equal (t , model .ModifyTypeNoReorgWithCheck , gotTp )
684684}
685685
686+ // TestModifyColumnIndexReorgRollbackOnReorgPanic verifies a reorg-phase
687+ // failure (errorMockPanic -> ErrReorgPanic) on the ModifyTypeIndexReorg path
688+ // rolls back cleanly.
689+ func TestModifyColumnIndexReorgRollbackOnReorgPanic (t * testing.T ) {
690+ store := testkit .CreateMockStore (t )
691+ tk := testkit .NewTestKit (t , store )
692+ tk .MustExec ("use test" )
693+
694+ // Cap the retry count so the panic loop terminates quickly.
695+ tk .MustExec ("set @@global.tidb_ddl_error_count_limit = 3" )
696+ defer tk .MustExec ("set @@global.tidb_ddl_error_count_limit = default" )
697+
698+ // char(N) collate utf8mb4_bin with index -> varchar(M) collate utf8mb4_bin
699+ // routes to ModifyTypeIndexReorg because types.NeedRestoredData differs
700+ // between CHAR+bin (false) and VARCHAR+bin (true). See pkg/types/etc.go:143.
701+ tk .MustExec (`create table t (
702+ a char(20) collate utf8mb4_bin,
703+ key i1(a)
704+ ) default charset=utf8mb4 default collate=utf8mb4_bin` )
705+ // All values fit varchar(10) (LENGTH <= 10) so the StateDeleteOnly
706+ // pre-check passes and the job reaches the reorg phase, where the
707+ // injected panic will fire.
708+ tk .MustExec ("insert into t values ('short'), ('medlen_abc'), ('abc')" )
709+
710+ // Capture which modify-column type is selected so the test asserts we are
711+ // actually exercising ModifyTypeIndexReorg rather than a sibling path.
712+ var gotTp byte
713+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType" , func (tp byte ) {
714+ gotTp = tp
715+ })
716+
717+ // Force the reorg phase to panic on every batch. util.Recover in
718+ // pkg/ddl/backfilling.go converts the panic to dbterror.ErrReorgPanic,
719+ // which terminates the reorg and triggers rollback.
720+ require .NoError (t , failpoint .Enable ("github.com/pingcap/tidb/pkg/ddl/errorMockPanic" , `return(true)` ))
721+ defer func () {
722+ require .NoError (t , failpoint .Disable ("github.com/pingcap/tidb/pkg/ddl/errorMockPanic" ))
723+ }()
724+
725+ err := tk .ExecToErr ("alter table t modify column a varchar(10) collate utf8mb4_bin" )
726+ require .Error (t , err , "expected ALTER to fail due to injected reorg panic" )
727+ t .Logf ("observed error: %v" , err )
728+
729+ require .Equalf (t , model .ModifyTypeIndexReorg , gotTp ,
730+ "expected the modify-column job to be classified as ModifyTypeIndexReorg; got %d" , gotTp )
731+
732+ // Rollback must leave the column unchanged.
733+ tblInfo := external .GetTableByName (t , tk , "test" , "t" ).Meta ()
734+ col := tblInfo .FindPublicColumnByName ("a" )
735+ require .NotNil (t , col , "column a should still exist after rollback" )
736+ require .Equalf (t , mysql .TypeString , col .GetType (),
737+ "expected column a to still be CHAR (TypeString); got type=%d" , col .GetType ())
738+ require .Equal (t , 20 , col .GetFlen (), "expected column a to still have flen=20" )
739+ require .Nil (t , col .ChangingFieldType , "ChangingFieldType should be cleared after rollback" )
740+ require .False (t , mysql .HasPreventNullInsertFlag (col .GetFlag ()),
741+ "PreventNullInsertFlag should be cleared after rollback" )
742+
743+ // No lingering "changing" temp indexes.
744+ for _ , idx := range tblInfo .Indices {
745+ for _ , idxCol := range idx .Columns {
746+ require .Falsef (t , idxCol .UseChangingType ,
747+ "lingering changing index column detected: index=%s col=%s" , idx .Name .L , idxCol .Name .L )
748+ }
749+ }
750+
751+ // Physical consistency check on KV + indexes.
752+ tk .MustExec ("admin check table t" )
753+
754+ // Original data must remain readable and complete.
755+ tk .MustQuery ("select a from t order by a" ).
756+ Check (testkit .Rows ("abc" , "medlen_abc" , "short" ))
757+ }
758+
759+ // TestModifyColumnIndexReorgRangeViolation: narrowing with an oversized row
760+ // falls through to the pre-check (dataValuesFitNewType=false), which catches
761+ // the violation and rolls back.
762+ func TestModifyColumnIndexReorgRangeViolation (t * testing.T ) {
763+ store := testkit .CreateMockStore (t )
764+ tk := testkit .NewTestKit (t , store )
765+ tk .MustExec ("use test" )
766+
767+ tk .MustExec (`create table t (
768+ a char(20) collate utf8mb4_bin,
769+ key i1(a)
770+ ) default charset=utf8mb4 default collate=utf8mb4_bin` )
771+ // 'too_long_to_fit_varchar_10' is 26 bytes, over the target varchar(10)
772+ // limit. The cast in castIndexValuesToChangingTypes must reject it.
773+ tk .MustExec ("insert into t values ('ok'), ('too_long_to_fit_varchar_10')" )
774+
775+ var gotTp byte
776+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType" , func (tp byte ) {
777+ gotTp = tp
778+ })
779+
780+ err := tk .ExecToErr ("alter table t modify column a varchar(10) collate utf8mb4_bin" )
781+ require .Error (t , err , "expected ALTER to fail due to over-length row" )
782+ t .Logf ("observed error: %v" , err )
783+
784+ require .Equalf (t , model .ModifyTypeIndexReorg , gotTp ,
785+ "expected ModifyTypeIndexReorg; got %d" , gotTp )
786+
787+ tblInfo := external .GetTableByName (t , tk , "test" , "t" ).Meta ()
788+ col := tblInfo .FindPublicColumnByName ("a" )
789+ require .NotNil (t , col )
790+ require .Equal (t , mysql .TypeString , col .GetType (), "column should still be CHAR after rollback" )
791+ require .Equal (t , 20 , col .GetFlen (), "column flen should be restored to 20" )
792+ require .Nil (t , col .ChangingFieldType , "ChangingFieldType should be cleared" )
793+ for _ , idx := range tblInfo .Indices {
794+ for _ , idxCol := range idx .Columns {
795+ require .Falsef (t , idxCol .UseChangingType ,
796+ "lingering changing index column: index=%s col=%s" , idx .Name .L , idxCol .Name .L )
797+ }
798+ }
799+ tk .MustExec ("admin check table t" )
800+ tk .MustQuery ("select a from t order by a" ).
801+ Check (testkit .Rows ("ok" , "too_long_to_fit_varchar_10" ))
802+ }
803+
804+ // TestModifyColumnIndexReorgNullToNotNull: narrowing + NULL -> NOT NULL with
805+ // an existing NULL row. Pre-check runs (flen shrink) and catches it; the
806+ // widening counterpart is TestModifyColumnIndexReorgWideningCatchesNullInReorg.
807+ func TestModifyColumnIndexReorgNullToNotNull (t * testing.T ) {
808+ store := testkit .CreateMockStore (t )
809+ tk := testkit .NewTestKit (t , store )
810+ tk .MustExec ("use test" )
811+
812+ // char(20) nullable + index -> varchar(10) NOT NULL triggers
813+ // ModifyTypeIndexReorg (CHAR/VARCHAR NeedRestoredData mismatch) and
814+ // also crosses the NULL -> NOT NULL boundary.
815+ tk .MustExec (`create table t (
816+ a char(20) collate utf8mb4_bin null,
817+ key i1(a)
818+ ) default charset=utf8mb4 default collate=utf8mb4_bin` )
819+ tk .MustExec ("insert into t values ('ok'), (NULL)" )
820+
821+ var gotTp byte
822+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType" , func (tp byte ) {
823+ gotTp = tp
824+ })
825+
826+ err := tk .ExecToErr ("alter table t modify column a varchar(10) collate utf8mb4_bin not null" )
827+ require .Error (t , err , "expected ALTER to fail due to NULL row on NOT NULL target" )
828+ t .Logf ("observed error: %v" , err )
829+
830+ require .Equalf (t , model .ModifyTypeIndexReorg , gotTp ,
831+ "expected ModifyTypeIndexReorg; got %d" , gotTp )
832+
833+ tblInfo := external .GetTableByName (t , tk , "test" , "t" ).Meta ()
834+ col := tblInfo .FindPublicColumnByName ("a" )
835+ require .NotNil (t , col )
836+ require .Equal (t , mysql .TypeString , col .GetType (), "column should still be CHAR after rollback" )
837+ require .Equal (t , 20 , col .GetFlen (), "column flen should be restored" )
838+ require .False (t , mysql .HasNotNullFlag (col .GetFlag ()), "NotNullFlag should be cleared on rollback" )
839+ require .Nil (t , col .ChangingFieldType , "ChangingFieldType should be cleared" )
840+ require .False (t , mysql .HasPreventNullInsertFlag (col .GetFlag ()),
841+ "PreventNullInsertFlag should be cleared" )
842+ for _ , idx := range tblInfo .Indices {
843+ for _ , idxCol := range idx .Columns {
844+ require .Falsef (t , idxCol .UseChangingType ,
845+ "lingering changing index column: index=%s col=%s" , idx .Name .L , idxCol .Name .L )
846+ }
847+ }
848+ tk .MustExec ("admin check table t" )
849+ // The NULL row must still be present (ALTER rolled back, data intact).
850+ tk .MustQuery ("select count(*) from t where a is null" ).Check (testkit .Rows ("1" ))
851+ }
852+
853+ // TestModifyColumnIndexReorgWideningCatchesNullInReorg: widening + NULL ->
854+ // NOT NULL. Pre-check is skipped (dataValuesFitNewType=true); the NotNullFlag
855+ // guard in CastColumnValueWithStrictMode catches the NULL row during reorg.
856+ func TestModifyColumnIndexReorgWideningCatchesNullInReorg (t * testing.T ) {
857+ store := testkit .CreateMockStore (t )
858+ tk := testkit .NewTestKit (t , store )
859+ tk .MustExec ("use test" )
860+
861+ tk .MustExec (`create table t (
862+ a char(60) collate utf8mb4_bin null,
863+ key i1(a)
864+ ) default charset=utf8mb4 default collate=utf8mb4_bin` )
865+ tk .MustExec ("insert into t values ('ok'), (NULL)" )
866+
867+ var gotTp byte
868+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType" , func (tp byte ) {
869+ gotTp = tp
870+ })
871+ precheckInvoked := false
872+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/checkModifyColumnDataEntry" , func (string ) {
873+ precheckInvoked = true
874+ })
875+
876+ err := tk .ExecToErr ("alter table t modify column a varchar(70) collate utf8mb4_bin not null" )
877+ require .Error (t , err , "expected ALTER to fail due to NULL row on NOT NULL target" )
878+ t .Logf ("observed error: %v" , err )
879+
880+ require .Equalf (t , model .ModifyTypeIndexReorg , gotTp ,
881+ "expected ModifyTypeIndexReorg; got %d" , gotTp )
882+ require .False (t , precheckInvoked ,
883+ "expected the pre-check SELECT to be skipped (widening case); reorg cast should catch the NULL" )
884+
885+ tblInfo := external .GetTableByName (t , tk , "test" , "t" ).Meta ()
886+ col := tblInfo .FindPublicColumnByName ("a" )
887+ require .NotNil (t , col )
888+ require .Equal (t , mysql .TypeString , col .GetType (), "column should still be CHAR after rollback" )
889+ require .Equal (t , 60 , col .GetFlen (), "column flen should be restored" )
890+ require .False (t , mysql .HasNotNullFlag (col .GetFlag ()), "NotNullFlag should be cleared on rollback" )
891+ require .Nil (t , col .ChangingFieldType , "ChangingFieldType should be cleared" )
892+ require .False (t , mysql .HasPreventNullInsertFlag (col .GetFlag ()),
893+ "PreventNullInsertFlag should be cleared" )
894+ for _ , idx := range tblInfo .Indices {
895+ for _ , idxCol := range idx .Columns {
896+ require .Falsef (t , idxCol .UseChangingType ,
897+ "lingering changing index column: index=%s col=%s" , idx .Name .L , idxCol .Name .L )
898+ }
899+ }
900+ tk .MustExec ("admin check table t" )
901+ tk .MustQuery ("select count(*) from t where a is null" ).Check (testkit .Rows ("1" ))
902+ }
903+
904+ // TestModifyColumnIndexReorgWideningSkipsPrecheck: strictly-widening change
905+ // (char(60) -> varchar(70)) skips the pre-check and the ALTER succeeds.
906+ func TestModifyColumnIndexReorgWideningSkipsPrecheck (t * testing.T ) {
907+ store := testkit .CreateMockStore (t )
908+ tk := testkit .NewTestKit (t , store )
909+ tk .MustExec ("use test" )
910+
911+ tk .MustExec (`create table t (
912+ a char(60) collate utf8mb4_bin,
913+ key i1(a)
914+ ) default charset=utf8mb4 default collate=utf8mb4_bin` )
915+ tk .MustExec ("insert into t values ('short'), ('medium length value'), ('something else')" )
916+
917+ var gotTp byte
918+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType" , func (tp byte ) {
919+ gotTp = tp
920+ })
921+
922+ // checkModifyColumnDataEntry fires at the top of checkModifyColumnData.
923+ // For a widening modify column we expect it to never fire.
924+ precheckInvoked := false
925+ testfailpoint .EnableCall (t , "github.com/pingcap/tidb/pkg/ddl/checkModifyColumnDataEntry" , func (colName string ) {
926+ precheckInvoked = true
927+ t .Logf ("pre-check entered for column %q" , colName )
928+ })
929+
930+ tk .MustExec ("alter table t modify column a varchar(70) collate utf8mb4_bin" )
931+
932+ require .Equalf (t , model .ModifyTypeIndexReorg , gotTp ,
933+ "expected ModifyTypeIndexReorg; got %d" , gotTp )
934+ require .False (t , precheckInvoked ,
935+ "expected the pre-check SELECT to be skipped for widening char -> varchar" )
936+
937+ meta := external .GetTableByName (t , tk , "test" , "t" ).Meta ()
938+ col := meta .FindPublicColumnByName ("a" )
939+ require .NotNil (t , col )
940+ require .Equal (t , mysql .TypeVarchar , col .GetType (), "column should now be VARCHAR" )
941+ require .Equal (t , 70 , col .GetFlen (), "column flen should be 70" )
942+ require .Nil (t , col .ChangingFieldType , "ChangingFieldType should be cleared after completion" )
943+
944+ tk .MustExec ("admin check table t" )
945+ tk .MustQuery ("select a from t order by a" ).
946+ Check (testkit .Rows ("medium length value" , "short" , "something else" ))
947+ }
948+
686949func TestGetModifyColumnType (t * testing.T ) {
687950 type testCase struct {
688951 beforeType string
0 commit comments