Skip to content

Commit 71a21e6

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. getModifyColumnType only routes isCharChange cases into ModifyTypeIndexReorg (integer signedness flips go to ModifyTypeReorg, same-signedness integer changes resolve to noReorgDataStrict or NoReorgWithCheck; decimal, temporal, enum/set are similarly handled before this path). So the only widening case that reaches this handler is char/varchar flen widening, and for that case no existing row can possibly violate the new type: the pre-check is pure overhead. Gate the pre-check in doModifyColumnIndexReorg's StateDeleteOnly branch on `isCharChange(oldCol, newCol) && newCol.GetFlen() >= oldCol.GetFlen()`; otherwise keep the existing pre-check. 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 this guard is what protects the race window between the statement-build NULL check (GetModifiableColumnJob calls checkForNullValue) and the DDL job's StateNone handler setting PreventNullInsertFlag. A NULL row that lands in that window would otherwise slip through the reorg silently. 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. - TestModifyColumnIndexReorgRangeViolation preserves the narrowing failure path (pre-check still runs and catches the oversized row). - 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 71a21e6

3 files changed

Lines changed: 185 additions & 8 deletions

File tree

pkg/ddl/modify_column.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,7 @@ func checkModifyColumnData(
811811
oldCol, changingCol *model.ColumnInfo,
812812
checkValueRange bool,
813813
) (checked bool, err error) {
814+
failpoint.InjectCall("checkModifyColumnDataEntry", oldCol.Name.L)
814815
// Get sessionctx from context resource pool.
815816
var sctx sessionctx.Context
816817
sctx, err = w.sessPool.Get()
@@ -1199,15 +1200,23 @@ func (w *worker) doModifyColumnIndexReorg(
11991200
failpoint.InjectCall("modifyColumnTypeWithData", job, args)
12001201
job.FillArgs(args)
12011202
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
1203+
// Skip the pre-check SELECT for char/varchar flen widening: the
1204+
// ModifyTypeIndexReorg path is only reachable via isCharChange in
1205+
// getModifyColumnType, so this is the one case where no existing
1206+
// row can violate. NULL -> NOT NULL is caught by the NotNullFlag
1207+
// guard in castIndexValuesToChangingTypes during reorg.
1208+
widensFlen := isCharChange(oldCol, args.Column) && args.Column.GetFlen() >= oldCol.GetFlen()
1209+
if !widensFlen {
1210+
checked, err := checkModifyColumnData(
1211+
jobCtx.stepCtx, w,
1212+
dbInfo.Name, tblInfo.Name,
1213+
oldCol, args.Column, true)
1214+
if err != nil {
1215+
if checked {
1216+
job.State = model.JobStateRollingback
1217+
}
1218+
return ver, errors.Trace(err)
12091219
}
1210-
return ver, errors.Trace(err)
12111220
}
12121221

12131222
// delete only -> write only

pkg/ddl/modify_column_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,169 @@ 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+
// still runs the pre-check (not a widening), which catches the violation and
761+
// 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+
// 'abcdefghijklmno' is 15 bytes: fits char(20) on insert, but violates
772+
// the target varchar(10) limit during the pre-check / reorg cast.
773+
tk.MustExec("insert into t values ('ok'), ('abcdefghijklmno')")
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("abcdefghijklmno", "ok"))
802+
}
803+
804+
// TestModifyColumnIndexReorgWideningSkipsPrecheck: strictly-widening change
805+
// (char(60) -> varchar(70)) skips the pre-check and the ALTER succeeds.
806+
func TestModifyColumnIndexReorgWideningSkipsPrecheck(t *testing.T) {
807+
store := testkit.CreateMockStore(t)
808+
tk := testkit.NewTestKit(t, store)
809+
tk.MustExec("use test")
810+
811+
tk.MustExec(`create table t (
812+
a char(60) collate utf8mb4_bin,
813+
key i1(a)
814+
) default charset=utf8mb4 default collate=utf8mb4_bin`)
815+
tk.MustExec("insert into t values ('short'), ('medium length value'), ('something else')")
816+
817+
var gotTp byte
818+
testfailpoint.EnableCall(t, "github.com/pingcap/tidb/pkg/ddl/getModifyColumnType", func(tp byte) {
819+
gotTp = tp
820+
})
821+
822+
// checkModifyColumnDataEntry fires at the top of checkModifyColumnData.
823+
// For a widening modify column we expect it to never fire.
824+
precheckInvoked := false
825+
testfailpoint.EnableCall(t, "github.com/pingcap/tidb/pkg/ddl/checkModifyColumnDataEntry", func(colName string) {
826+
precheckInvoked = true
827+
t.Logf("pre-check entered for column %q", colName)
828+
})
829+
830+
tk.MustExec("alter table t modify column a varchar(70) collate utf8mb4_bin")
831+
832+
require.Equalf(t, model.ModifyTypeIndexReorg, gotTp,
833+
"expected ModifyTypeIndexReorg; got %d", gotTp)
834+
require.False(t, precheckInvoked,
835+
"expected the pre-check SELECT to be skipped for widening char -> varchar")
836+
837+
meta := external.GetTableByName(t, tk, "test", "t").Meta()
838+
col := meta.FindPublicColumnByName("a")
839+
require.NotNil(t, col)
840+
require.Equal(t, mysql.TypeVarchar, col.GetType(), "column should now be VARCHAR")
841+
require.Equal(t, 70, col.GetFlen(), "column flen should be 70")
842+
require.Nil(t, col.ChangingFieldType, "ChangingFieldType should be cleared after completion")
843+
844+
tk.MustExec("admin check table t")
845+
tk.MustQuery("select a from t order by a").
846+
Check(testkit.Rows("medium length value", "short", "something else"))
847+
}
848+
686849
func TestGetModifyColumnType(t *testing.T) {
687850
type testCase struct {
688851
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)