Skip to content

Commit 0af40d6

Browse files
joechenrhclaude
andcommitted
ddl, table: skip pre-check SELECT for widening modify-column changes
When ALTER TABLE ... MODIFY COLUMN is classified as ModifyTypeIndexReorg (for example char(N) -> varchar(M) with utf8mb4_bin + a secondary index), the StateDeleteOnly handler runs a synchronous SELECT ... FROM t WHERE LENGTH(col) > N OR col IS NULL LIMIT 1 through the internal session pool. On large tables this SELECT has no timeout, pins the cluster GC safepoint for its entire duration, blocks subsequent DDL on the same table, and is invisible to SHOW PROCESSLIST. For a strictly-widening modification (integer range expansion with the same signedness; char/varchar flen increase) no existing row can possibly violate the new type. In that case the pre-check is pure overhead and can be skipped. Nullability tightening (NULL -> NOT NULL) is also safe to skip because the NotNullFlag guard added to castIndexValuesToChangingTypes catches existing NULL rows during reorg. Add dataValuesFitNewType in pkg/ddl/modify_column.go and gate the pre-check on it. The helper covers the two safe-widening cases; all other shape changes (length shrink, decimal, temporal, enum/set, ...) fall through to the conservative default and keep the pre-check. Collation and signedness mismatches are already routed to ModifyTypeReorg by getModifyColumnType before this handler runs. In pkg/table/tables/index.go, extend castIndexValuesToChangingTypes with a NULL guard right after the strict cast: the underlying Datum.ConvertTo converts NULL to NULL without error, so when the new column type carries NotNullFlag the guard is what makes the reorg reject existing NULL rows and drive rollback. Add failpoint hook checkModifyColumnDataEntry at the top of checkModifyColumnData so tests can observe whether the pre-check was reached. Tests: - TestModifyColumnIndexReorgWideningSkipsPrecheck covers the widening happy path; checkModifyColumnData is never entered and the ALTER succeeds. - TestModifyColumnIndexReorgWideningCatchesNullInReorg covers the widening + NULL -> NOT NULL case; the reorg NULL guard rejects the NULL row after the pre-check is skipped. - TestModifyColumnIndexReorgRangeViolation preserves the narrowing failure path (pre-check still runs and catches the oversized row). - TestModifyColumnIndexReorgNullToNotNull preserves the narrowing + nullability-tightening failure path. - TestModifyColumnIndexReorgRollbackOnReorgPanic pins the reorg-phase rollback path end-to-end via errorMockPanic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 52d01c6 commit 0af40d6

3 files changed

Lines changed: 298 additions & 8 deletions

File tree

pkg/ddl/modify_column.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ func isIntegerChange(from, to *model.ColumnInfo) bool {
6565
return mysql.IsIntegerType(from.GetType()) && mysql.IsIntegerType(to.GetType())
6666
}
6767

68+
// dataValuesFitNewType reports whether every stored value of oldCol is
69+
// guaranteed to fit newCol so the pre-check SQL can be skipped on the
70+
// ModifyTypeIndexReorg path. Only integer and char/varchar flen widening
71+
// qualify; NULL -> NOT NULL is still safe because the NotNullFlag guard in
72+
// CastColumnValueWithStrictMode catches existing NULLs during reorg.
73+
func dataValuesFitNewType(oldCol, newCol *model.ColumnInfo) bool {
74+
if isIntegerChange(oldCol, newCol) {
75+
oldDefLen, _ := mysql.GetDefaultFieldLengthAndDecimal(oldCol.GetType())
76+
newDefLen, _ := mysql.GetDefaultFieldLengthAndDecimal(newCol.GetType())
77+
return newDefLen >= oldDefLen
78+
}
79+
if isCharChange(oldCol, newCol) {
80+
return newCol.GetFlen() >= oldCol.GetFlen()
81+
}
82+
return false
83+
}
84+
6885
func isCharChange(from, to *model.ColumnInfo) bool {
6986
return types.IsTypeChar(from.GetType()) && types.IsTypeChar(to.GetType())
7087
}
@@ -811,6 +828,7 @@ func checkModifyColumnData(
811828
oldCol, changingCol *model.ColumnInfo,
812829
checkValueRange bool,
813830
) (checked bool, err error) {
831+
failpoint.InjectCall("checkModifyColumnDataEntry", oldCol.Name.L)
814832
// Get sessionctx from context resource pool.
815833
var sctx sessionctx.Context
816834
sctx, err = w.sessPool.Get()
@@ -1199,15 +1217,19 @@ func (w *worker) doModifyColumnIndexReorg(
11991217
failpoint.InjectCall("modifyColumnTypeWithData", job, args)
12001218
job.FillArgs(args)
12011219
case model.StateDeleteOnly:
1202-
checked, err := checkModifyColumnData(
1203-
jobCtx.stepCtx, w,
1204-
dbInfo.Name, tblInfo.Name,
1205-
oldCol, args.Column, true)
1206-
if err != nil {
1207-
if checked {
1208-
job.State = model.JobStateRollingback
1220+
// Skip the pre-check SELECT when the new type strictly widens the
1221+
// old one; reorg-time cast covers NULL -> NOT NULL.
1222+
if !dataValuesFitNewType(oldCol, args.Column) {
1223+
checked, err := checkModifyColumnData(
1224+
jobCtx.stepCtx, w,
1225+
dbInfo.Name, tblInfo.Name,
1226+
oldCol, args.Column, true)
1227+
if err != nil {
1228+
if checked {
1229+
job.State = model.JobStateRollingback
1230+
}
1231+
return ver, errors.Trace(err)
12091232
}
1210-
return ver, errors.Trace(err)
12111233
}
12121234

12131235
// delete only -> write only

pkg/ddl/modify_column_test.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
686949
func TestGetModifyColumnType(t *testing.T) {
687950
type testCase struct {
688951
beforeType string

pkg/table/tables/index.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ func (c *index) castIndexValuesToChangingTypes(indexedValues []types.Datum) erro
137137
if err != nil {
138138
return err
139139
}
140+
// Strict cast passes NULL through; reject it here when the new type
141+
// carries NotNullFlag so reorg surfaces existing NULL rows.
142+
if indexedValues[i].IsNull() && mysql.HasNotNullFlag(tblCol.ChangingFieldType.GetFlag()) {
143+
return table.ErrColumnCantNull.FastGenByArgs(tblCol.Name)
144+
}
140145
}
141146
return nil
142147
}

0 commit comments

Comments
 (0)