Skip to content

Commit ffcf205

Browse files
committed
sql: fix partial index data loss / phantom rows during update
This commit fixes a bug on tables with multiple column families where a concurrent update that does not overlap with a partial index's column family could cause the partial index to write a NULL instead of the actual data, or incorrectly add phantom rows to a temporary index during a schema change backfill. This bug was previously masked on default (single-column-family) tables because an update to any column causes the optimizer to conservatively fetch all columns in that family. However, with multiple column families, two normalization rules in the optimizer caused issues: 1. PruneMutationFetchCols: If an update does not change any column associated with an index, the optimizer avoids fetching those columns. This causes the execution layer to see NULLs for the unfetched columns. 2. SimplifyPartialIndexProjections: If an update does not change any column associated with a partial index, the optimizer simplifies the partial index predicate evaluation to FALSE. This causes the execution layer to skip writes to the index. During a schema change backfill, the execution layer's updater must unconditionally write complete index entries to temporary (mutating) indexes for any concurrent update, even if the index's columns are unchanged. This ensures the backfill merger has a complete snapshot to correctly reconcile the final index. If columns are pruned (Rule 1) or writes are simplified away (Rule 2), the temporary index receives incomplete entries (NULLs) or misses the update entirely. Furthermore, missing columns can lead to phantom rows if the partial index predicate evaluates to TRUE when given a NULL value (e.g., WHERE val IS NULL). This change ensures the optimizer always fetches the required columns and avoids simplifying predicate evaluation if the index is a mutation index, correctly propagating the full row state to the execution layer. Fixes: #166122 Release note (bug fix): Fixed a bug where concurrent updates to a table using multiple column families during a partial index creation could result in data loss, incorrect NULL values, or validation failures in the resulting index.
1 parent 002a7f0 commit ffcf205

4 files changed

Lines changed: 101 additions & 4 deletions

File tree

pkg/sql/mvcc_backfiller_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import (
4040
"github.com/cockroachdb/cockroach/pkg/testutils/testcluster"
4141
"github.com/cockroachdb/cockroach/pkg/upgrade/upgradebase"
4242
"github.com/cockroachdb/cockroach/pkg/util"
43+
"github.com/cockroachdb/cockroach/pkg/util/ctxgroup"
4344
"github.com/cockroachdb/cockroach/pkg/util/hlc"
4445
"github.com/cockroachdb/cockroach/pkg/util/json"
4546
"github.com/cockroachdb/cockroach/pkg/util/leaktest"
@@ -996,3 +997,83 @@ CREATE TABLE t.test (k INT PRIMARY KEY, v int);`
996997
tdb.Exec(t, "CREATE INDEX ON t.test (v)")
997998
require.True(t, called)
998999
}
1000+
1001+
// TestPartialIndexBackfillWithConcurrentFamilyUpdate validates that concurrent updates
1002+
// with multiple column families still propagate to secondary indexes being constructed.
1003+
func TestPartialIndexBackfillWithConcurrentFamilyUpdate(t *testing.T) {
1004+
defer leaktest.AfterTest(t)()
1005+
defer log.Scope(t).Close(t)
1006+
1007+
var (
1008+
s serverutils.TestServerInterface
1009+
sqlDB *gosql.DB
1010+
)
1011+
1012+
var mergeReady chan struct{}
1013+
var waitForMerge chan struct{}
1014+
1015+
params := base.TestServerArgs{}
1016+
params.Knobs = base.TestingKnobs{
1017+
SQLSchemaChanger: &sql.SchemaChangerTestingKnobs{
1018+
BackfillChunkSize: 100,
1019+
},
1020+
DistSQL: &execinfra.TestingKnobs{
1021+
IndexBackfillMergerTestingKnobs: &backfill.IndexBackfillMergerTestingKnobs{
1022+
RunBeforeScanChunk: func(startKey roachpb.Key) error {
1023+
mergeReady <- struct{}{}
1024+
<-waitForMerge
1025+
return nil
1026+
},
1027+
},
1028+
},
1029+
JobsTestingKnobs: jobs.NewTestingKnobsWithShortIntervals(),
1030+
}
1031+
1032+
s, sqlDB, _ = serverutils.StartServer(t, params)
1033+
defer s.Stopper().Stop(context.Background())
1034+
1035+
mergeReady = make(chan struct{})
1036+
waitForMerge = make(chan struct{})
1037+
runner := sqlutils.MakeSQLRunner(sqlDB)
1038+
runner.Exec(t, `
1039+
CREATE DATABASE t;
1040+
CREATE TABLE t.test (
1041+
id INT PRIMARY KEY,
1042+
val STRING,
1043+
other_val STRING,
1044+
FAMILY f1(id, val),
1045+
FAMILY f2(other_val)
1046+
);
1047+
`)
1048+
// Insert two rows that match predicate for the index we are creating below.
1049+
runner.Exec(t, `
1050+
INSERT INTO t.test VALUES
1051+
(1, 'BLAH', 'MEH'),
1052+
(2, 'BLAH', 'BLEH'),
1053+
(3, 'MEH', 'GLEE'),
1054+
(4, 'BLEH', 'GLAH');`)
1055+
1056+
// Run index creation in the background.
1057+
grp := ctxgroup.WithContext(context.Background())
1058+
grp.GoCtx(func(ctx context.Context) error {
1059+
// Create a partial index on values that match some predicate.
1060+
_, err := sqlDB.Exec("CREATE INDEX idx_test on t.test(val) WHERE val LIKE '%BLAH%'")
1061+
return err
1062+
})
1063+
// Wait for the merge step to start.
1064+
<-mergeReady
1065+
// Updates on an unrelated column should still inject updates the new partial index,
1066+
// while its mutating.
1067+
runner.Exec(t, `UPDATE t.test SET other_val = 'B' WHERE id IN (1, 2);`)
1068+
// Also, validate deletions behave correctly.
1069+
runner.Exec(t, `DELETE FROM t.test WHERE other_val='GLAH';`)
1070+
// Allow the merge to continue
1071+
close(waitForMerge)
1072+
// Wait for schema change to finish
1073+
require.NoError(t, grp.Wait())
1074+
// Confirm with a scan of the partial index that everything looks sound.
1075+
runner.CheckQueryResults(t, `SELECT id, val FROM t.test@idx_test WHERE val LIKE '%BLAH%' ORDER BY id`, [][]string{
1076+
{"1", "BLAH"},
1077+
{"2", "BLAH"},
1078+
})
1079+
}

pkg/sql/opt/cat/index.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ type Index interface {
210210
// definition, where i < PartitionCount.
211211
Partition(i int) Partition
212212

213-
// IsTemporaryIndexForBackfill returns true iff the index is an index being
214-
// used as the temporary index being used by an in-progress index backfill.
213+
// IsTemporaryIndexForBackfill returns true if the index is a temporary index
214+
// for an in-progress index backfill.
215215
IsTemporaryIndexForBackfill() bool
216216
}
217217

@@ -232,6 +232,12 @@ func IsMutationIndex(table Table, ord IndexOrdinal) bool {
232232
return ord >= table.IndexCount()
233233
}
234234

235+
// IsTemporaryMutationIndex is a convenience function that returns true if the index at
236+
// the given ordinal position is a mutation index and is temporary.
237+
func IsTemporaryMutationIndex(table Table, ord IndexOrdinal) bool {
238+
return IsMutationIndex(table, ord) && table.Index(ord).IsTemporaryIndexForBackfill()
239+
}
240+
235241
// Partition is an interface to a PARTITION BY LIST partition of an index. The
236242
// intended use is to support planning of scans or lookup joins that will use
237243
// locality optimized search. Locality optimized search can be planned when the

pkg/sql/opt/norm/mutation_funcs.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010

1111
"github.com/cockroachdb/cockroach/pkg/sql/opt"
12+
"github.com/cockroachdb/cockroach/pkg/sql/opt/cat"
1213
"github.com/cockroachdb/cockroach/pkg/sql/opt/memo"
1314
"github.com/cockroachdb/cockroach/pkg/sql/types"
1415
)
@@ -96,14 +97,19 @@ func (c *CustomFuncs) SimplifiablePartialIndexProjectCols(
9697
// required. Therefore, the partial index PUT and DEL columns cannot be
9798
// simplified.
9899
//
100+
// If a secondary index is being built, then there will be points where
101+
// all values will be forced with a Put. As a result, runtime expects mutated
102+
// temporary indexes to always have values available for writes during index construction,
103+
// even if the mutation does not touch those columns.
104+
//
99105
// Note that we use the set of index columns where the virtual
100106
// columns have been mapped to their source columns. Virtual columns
101107
// are never part of the updated columns. Updates to source columns
102108
// trigger index changes.
103109
predFilters := *pred.(*memo.FiltersExpr)
104110
indexAndPredCols := tabMeta.IndexColumnsMapInverted(i)
105111
indexAndPredCols.UnionWith(predFilters.OuterCols())
106-
if indexAndPredCols.Intersects(updateCols) {
112+
if indexAndPredCols.Intersects(updateCols) || cat.IsTemporaryMutationIndex(tabMeta.Table, i) {
107113
continue
108114
}
109115

pkg/sql/opt/norm/prune_cols_funcs.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ func (c *CustomFuncs) NeededMutationFetchCols(
182182
predFilters := *pred.(*memo.FiltersExpr)
183183
indexAndPredCols.UnionWith(predFilters.OuterCols())
184184
}
185-
if !indexAndPredCols.Intersects(updateCols) {
185+
// If a secondary index is being built, then there will be points where
186+
// all values will be forced with a Put. As a result, runtime expects temporary
187+
// mutated indexes to always have values available for writes during index
188+
// construction, even if the mutation does not touch those columns.
189+
if !indexAndPredCols.Intersects(updateCols) && !cat.IsTemporaryMutationIndex(tabMeta.Table, i) {
186190
continue
187191
}
188192

0 commit comments

Comments
 (0)