Skip to content

Commit 11bdb6f

Browse files
committed
sql/inspect: detect dangling secondary index entries in empty spans
INSPECT previously skipped index consistency checks when the primary index span had no rows, preventing detection of dangling secondary index entries in empty tables. This change ensures INSPECT always scans secondary indexes, even if the primary is empty, by using an empty predicate to trigger a full scan. Any entries found are considered dangling. Adds a test (TestDanglingIndexEntryInEmptyTable) migrated from pkg/sql/scrub_test.go. Informs #155485 Epic: CRDB-55075 Release note (bug fix): INSPECT now detects dangling secondary index entries even when the primary index spans contain no data.
1 parent 9ea3046 commit 11bdb6f

File tree

5 files changed

+107
-113
lines changed

5 files changed

+107
-113
lines changed

pkg/sql/inspect/index_consistency_check.go

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -165,41 +165,46 @@ func (c *indexConsistencyCheck) Start(
165165
return errors.Wrap(err, "converting span to query bounds")
166166
}
167167

168-
// Nothing to do if no rows exist in the span.
168+
// If no rows exist in the primary index span, we still need to check for dangling
169+
// secondary index entries. We run the check with an empty predicate, which will
170+
// scan the entire secondary index within the span. Any secondary index entries found
171+
// will be dangling since there are no corresponding primary index rows.
169172
if !hasRows {
170-
return nil
171-
}
172-
173-
if len(bounds.Start) == 0 || len(bounds.End) == 0 {
174-
return errors.AssertionFailedf("query bounds from span didn't produce start or end: %+v", bounds)
175-
}
173+
// Use empty predicate and no query arguments
174+
predicate = ""
175+
queryArgs = []interface{}{}
176+
} else {
177+
if len(bounds.Start) == 0 || len(bounds.End) == 0 {
178+
return errors.AssertionFailedf("query bounds from span didn't produce start or end: %+v", bounds)
179+
}
176180

177-
// Generate SQL predicate from the bounds
178-
pkColNames := colNames(pkColumns)
179-
// Encode column names for SQL usage
180-
encodedPkColNames := make([]string, len(pkColNames))
181-
for i, colName := range pkColNames {
182-
encodedPkColNames[i] = encodeColumnName(colName)
183-
}
184-
predicate, err = spanutils.RenderQueryBounds(
185-
encodedPkColNames, pkColDirs, pkColTypes,
186-
len(bounds.Start), len(bounds.End), true, 1,
187-
)
188-
if err != nil {
189-
return errors.Wrap(err, "rendering query bounds")
190-
}
181+
// Generate SQL predicate from the bounds
182+
pkColNames := colNames(pkColumns)
183+
// Encode column names for SQL usage
184+
encodedPkColNames := make([]string, len(pkColNames))
185+
for i, colName := range pkColNames {
186+
encodedPkColNames[i] = encodeColumnName(colName)
187+
}
188+
predicate, err = spanutils.RenderQueryBounds(
189+
encodedPkColNames, pkColDirs, pkColTypes,
190+
len(bounds.Start), len(bounds.End), true, 1,
191+
)
192+
if err != nil {
193+
return errors.Wrap(err, "rendering query bounds")
194+
}
191195

192-
if strings.TrimSpace(predicate) == "" {
193-
return errors.AssertionFailedf("query bounds from span didn't produce predicate: %+v", bounds)
194-
}
196+
if strings.TrimSpace(predicate) == "" {
197+
return errors.AssertionFailedf("query bounds from span didn't produce predicate: %+v", bounds)
198+
}
195199

196-
// Prepare query arguments: end bounds first, then start bounds
197-
queryArgs = make([]interface{}, 0, len(bounds.End)+len(bounds.Start))
198-
for _, datum := range bounds.End {
199-
queryArgs = append(queryArgs, datum)
200-
}
201-
for _, datum := range bounds.Start {
202-
queryArgs = append(queryArgs, datum)
200+
// Prepare query arguments: end bounds first, then start bounds
201+
queryArgs = make([]interface{}, 0, len(bounds.End)+len(bounds.Start))
202+
for _, datum := range bounds.End {
203+
queryArgs = append(queryArgs, datum)
204+
}
205+
for _, datum := range bounds.Start {
206+
queryArgs = append(queryArgs, datum)
207+
}
203208
}
204209

205210
checkQuery := c.createIndexCheckQuery(

pkg/sql/inspect/index_consistency_check_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,69 @@ func TestDetectIndexConsistencyErrors(t *testing.T) {
552552
}
553553
}
554554

555+
// TestDanglingIndexEntryInEmptyTable verifies that INSPECT can detect dangling
556+
// secondary index entries even when the primary index is completely empty.
557+
func TestDanglingIndexEntryInEmptyTable(t *testing.T) {
558+
defer leaktest.AfterTest(t)()
559+
defer log.Scope(t).Close(t)
560+
ctx := context.Background()
561+
s, db, kvDB := serverutils.StartServer(t, base.TestServerArgs{})
562+
defer s.Stopper().Stop(ctx)
563+
codec := s.ApplicationLayer().Codec()
564+
565+
// Create an empty table with a secondary index.
566+
_, err := db.Exec(`
567+
CREATE DATABASE t;
568+
CREATE TABLE t.test (k INT PRIMARY KEY, v INT);
569+
CREATE INDEX secondary ON t.test (v);
570+
`)
571+
require.NoError(t, err)
572+
573+
tableDesc := desctestutils.TestingGetPublicTableDescriptor(kvDB, codec, "t", "test")
574+
secondaryIndex := tableDesc.PublicNonPrimaryIndexes()[0]
575+
576+
// Manually insert a dangling secondary index entry for a non-existent primary key.
577+
// This creates corruption: a secondary index entry pointing to primary key (10)
578+
// which doesn't exist in the primary index.
579+
values := []tree.Datum{tree.NewDInt(10), tree.NewDInt(314)}
580+
err = insertSecondaryIndexEntry(ctx, codec, values, kvDB, tableDesc, secondaryIndex)
581+
require.NoError(t, err)
582+
583+
// Enable INSPECT command.
584+
_, err = db.Exec(`SET enable_inspect_command = true`)
585+
require.NoError(t, err)
586+
587+
// Run INSPECT and expect it to find the dangling index entry.
588+
// INSPECT should return an error when inconsistencies are found.
589+
_, err = db.Query(`INSPECT TABLE t.test AS OF SYSTEM TIME '-1us' WITH OPTIONS INDEX ALL`)
590+
require.Error(t, err)
591+
require.Contains(t, err.Error(), "INSPECT found inconsistencies")
592+
593+
// Verify the error details using SHOW INSPECT ERRORS.
594+
rows, err := db.Query(`SHOW INSPECT ERRORS FOR TABLE t.test WITH DETAILS`)
595+
require.NoError(t, err)
596+
defer rows.Close()
597+
598+
var errorType, dbName, schemaName, tableName, primaryKey, aost, details string
599+
var jobID int64
600+
found := false
601+
for rows.Next() {
602+
err = rows.Scan(&errorType, &dbName, &schemaName, &tableName, &primaryKey, &jobID, &aost, &details)
603+
require.NoError(t, err)
604+
found = true
605+
606+
// Verify the error is a dangling secondary index entry.
607+
require.Equal(t, "dangling_secondary_index_entry", errorType)
608+
require.Equal(t, "t", dbName)
609+
require.Equal(t, "test", tableName)
610+
require.Equal(t, "'(10)'", primaryKey)
611+
// Verify details contain the expected column value.
612+
require.Contains(t, details, `"v": "314"`)
613+
}
614+
615+
require.True(t, found, "expected to find at least one inspect error, but found none")
616+
}
617+
555618
func TestIndexConsistencyWithReservedWordColumns(t *testing.T) {
556619
defer leaktest.AfterTest(t)()
557620
defer log.Scope(t).Close(t)

pkg/sql/inspect_job.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ func InspectChecksForTable(
132132
) ([]*jobspb.InspectDetails_Check, error) {
133133
checks := []*jobspb.InspectDetails_Check{}
134134

135+
// Skip virtual tables since they don't have physical storage to inspect.
136+
if table.IsVirtualTable() {
137+
return checks, nil
138+
}
139+
135140
for _, index := range table.PublicNonPrimaryIndexes() {
136141
if isUnsupportedIndexForIndexConsistencyCheck(index, table) {
137142
if p != nil {

pkg/sql/logictest/testdata/logic_test/inspect

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ SELECT * FROM last_inspect_job;
6161
----
6262
succeeded 1
6363

64+
# TODO(155483): use a really old timestamp from before 'foo' was created.
6465
statement ok
65-
INSPECT TABLE foo AS OF SYSTEM TIME 1 WITH OPTIONS INDEX (idx_c1,idx_c2);
66+
INSPECT TABLE foo AS OF SYSTEM TIME '-50ms' WITH OPTIONS INDEX (idx_c1,idx_c2);
6667

6768
query TI
6869
SELECT * FROM last_inspect_job;
@@ -86,7 +87,7 @@ INSPECT DATABASE test;
8687
query TI
8788
SELECT * FROM last_inspect_job;
8889
----
89-
succeeded 28
90+
succeeded 2
9091

9192
statement error index "idx_c3" does not exist
9293
INSPECT DATABASE test WITH OPTIONS INDEX (idx_c1, idx_c3);

pkg/sql/scrub_test.go

Lines changed: 0 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -261,86 +261,6 @@ func addIndexEntryForDatums(
261261
return kvDB.Put(context.Background(), entry.Key, &entry.Value)
262262
}
263263

264-
// TestScrubIndexDanglingIndexReference tests that
265-
// `SCRUB TABLE ... INDEX“ will find dangling index references, which
266-
// are index entries that have no corresponding primary k/v. To test
267-
// this an index entry is generated and inserted. This creates a
268-
// dangling index error as the corresponding primary k/v is not equal.
269-
func TestScrubIndexDanglingIndexReference(t *testing.T) {
270-
defer leaktest.AfterTest(t)()
271-
defer log.Scope(t).Close(t)
272-
s, db, kvDB := serverutils.StartServer(t, base.TestServerArgs{})
273-
defer s.Stopper().Stop(context.Background())
274-
275-
// Create the table and the row entry.
276-
if _, err := db.Exec(`
277-
CREATE DATABASE t;
278-
CREATE TABLE t.test (k INT PRIMARY KEY, v INT);
279-
CREATE INDEX secondary ON t.test (v);
280-
`); err != nil {
281-
t.Fatalf("unexpected error: %s", err)
282-
}
283-
284-
tableDesc := desctestutils.TestingGetPublicTableDescriptor(kvDB, keys.SystemSQLCodec, "t", "test")
285-
secondaryIndex := tableDesc.PublicNonPrimaryIndexes()[0]
286-
// Construct datums and secondary k/v for our row values (k, v).
287-
values := []tree.Datum{tree.NewDInt(10), tree.NewDInt(314)}
288-
289-
// Put the new secondary k/v into the database.
290-
if err := addIndexEntryForDatums(values, kvDB, tableDesc, secondaryIndex); err != nil {
291-
t.Fatalf("unexpected error: %s", err)
292-
}
293-
294-
// Run SCRUB and find the index errors we created.
295-
rows, err := db.Query(`EXPERIMENTAL SCRUB TABLE t.test WITH OPTIONS INDEX ALL`)
296-
if err != nil {
297-
t.Fatalf("unexpected error: %s", err)
298-
}
299-
defer rows.Close()
300-
301-
results, err := sqlutils.GetInspectResultRows(rows)
302-
if err != nil {
303-
t.Fatalf("unexpected error: %s", err)
304-
}
305-
306-
if len(results) != 1 {
307-
t.Fatalf("expected 1 result, got %d. got %#v", len(results), results)
308-
}
309-
if result := results[0]; result.ErrorType != scrub.DanglingIndexReferenceError {
310-
t.Fatalf("expected %q error, instead got: %s",
311-
scrub.DanglingIndexReferenceError, result.ErrorType)
312-
} else if result.Database != "t" {
313-
t.Fatalf("expected database %q, got %q", "t", result.Database)
314-
} else if result.Table != "test" {
315-
t.Fatalf("expected table %q, got %q", "test", result.Table)
316-
} else if result.PrimaryKey != "(10)" {
317-
t.Fatalf("expected primaryKey %q, got %q", "(10)", result.PrimaryKey)
318-
} else if result.Repaired {
319-
t.Fatalf("expected repaired %v, got %v", false, result.Repaired)
320-
} else if !strings.Contains(result.Details, `"v": "314"`) {
321-
t.Fatalf("expected error details to contain `%s`, got %s", `"v": "314"`, result.Details)
322-
}
323-
324-
// Run SCRUB DATABASE to make sure it also catches the problem.
325-
rows, err = db.Query(`EXPERIMENTAL SCRUB DATABASE t`)
326-
if err != nil {
327-
t.Fatalf("unexpected error: %+v", err)
328-
}
329-
defer rows.Close()
330-
scrubDatabaseResults, err := sqlutils.GetInspectResultRows(rows)
331-
if err != nil {
332-
t.Fatalf("unexpected error: %s", err)
333-
} else if len(scrubDatabaseResults) != 1 {
334-
t.Fatalf("expected 1 result, got %d. got %#v", len(scrubDatabaseResults), scrubDatabaseResults)
335-
} else if !(scrubDatabaseResults[0].ErrorType == results[0].ErrorType &&
336-
scrubDatabaseResults[0].Database == results[0].Database &&
337-
scrubDatabaseResults[0].Table == results[0].Table &&
338-
scrubDatabaseResults[0].Details == results[0].Details) {
339-
t.Fatalf("expected results to be equal, SCRUB TABLE got %v. SCRUB DATABASE got %v",
340-
results, scrubDatabaseResults)
341-
}
342-
}
343-
344264
// TestScrubIndexCatchesStoringMismatch tests that
345265
// `SCRUB TABLE ... INDEX ALL` will fail if an index entry only differs
346266
// by its STORING values. To test this, a row's underlying secondary

0 commit comments

Comments
 (0)