diff --git a/pkg/sql/catalog/catpb/catalog.proto b/pkg/sql/catalog/catpb/catalog.proto index cdf495308b78..48c6b5aef152 100644 --- a/pkg/sql/catalog/catpb/catalog.proto +++ b/pkg/sql/catalog/catpb/catalog.proto @@ -214,3 +214,33 @@ message RowLevelTTL { // rows on table) during row level TTL. If zero, no statistics are reported. optional int64 row_stats_poll_interval = 9 [(gogoproto.nullable)=false, (gogoproto.casttype)="time.Duration"]; } + +// Create separate messages for setting values so we may access the +// values directly via SQL. +message Bool { + option (gogoproto.equal) = true; + optional bool value = 1 [(gogoproto.nullable)=false]; +} +message Int { + option (gogoproto.equal) = true; + optional int64 value = 1 [(gogoproto.nullable)=false]; +} +// Protobuf's double is float64 in Go. +message Float { + option (gogoproto.equal) = true; + optional double value = 1 [(gogoproto.nullable)=false]; +} +message String { + option (gogoproto.equal) = true; + optional string value = 1 [(gogoproto.nullable)=false]; +} + +// TableLevelSettings represents cluster or session settings specified at the +// table level. Each SettingValue is nullable so queries of the descriptor in +// JSON form only list values which have been set. +message TableLevelSettings { + option (gogoproto.equal) = true; + optional Bool sql_stats_automatic_collection_enabled = 1; + optional Int sql_stats_automatic_collection_min_stale_rows = 2; + optional Float sql_stats_automatic_collection_fraction_stale_rows = 3; +} diff --git a/pkg/sql/catalog/descpb/structured.go b/pkg/sql/catalog/descpb/structured.go index 7d8cfac634ca..2a1086bf13b4 100644 --- a/pkg/sql/catalog/descpb/structured.go +++ b/pkg/sql/catalog/descpb/structured.go @@ -273,6 +273,49 @@ func (desc *TableDescriptor) Persistence() tree.Persistence { return tree.PersistencePermanent } +// AutoStatsCollectionEnabled indicates if automatic statistics collection is +// explicitly enabled or disabled for this table. If ok is true, then +// enabled==false means auto stats collection is off for this table, and if +// true, auto stats are on for this table. If ok is false, there is no setting +// for this table. +func (desc *TableDescriptor) AutoStatsCollectionEnabled() (enabled bool, ok bool) { + if desc.TableLevelSettings == nil { + return false, false + } + if desc.TableLevelSettings.SqlStatsAutomaticCollectionEnabled == nil { + return false, false + } + return desc.TableLevelSettings.SqlStatsAutomaticCollectionEnabled.Value, true +} + +// AutoStatsMinStaleRows indicates the setting of +// sql.stats.automatic_collection.min_stale_rows for this table. +// If ok is true, then the minStaleRows value is valid, otherwise this has not +// been set at the table level. +func (desc *TableDescriptor) AutoStatsMinStaleRows() (minStaleRows int64, ok bool) { + if desc.TableLevelSettings == nil { + return 0, false + } + if desc.TableLevelSettings.SqlStatsAutomaticCollectionMinStaleRows == nil { + return 0, false + } + return desc.TableLevelSettings.SqlStatsAutomaticCollectionMinStaleRows.Value, true +} + +// AutoStatsFractionStaleRows indicates the setting of +// sql.stats.automatic_collection.fraction_stale_rows for this table. +// If ok is true, then the fractionStaleRows value is valid, otherwise this has +// not been set at the table level. +func (desc *TableDescriptor) AutoStatsFractionStaleRows() (fractionStaleRows float64, ok bool) { + if desc.TableLevelSettings == nil { + return 0, false + } + if desc.TableLevelSettings.SqlStatsAutomaticCollectionFractionStaleRows == nil { + return 0, false + } + return desc.TableLevelSettings.SqlStatsAutomaticCollectionFractionStaleRows.Value, true +} + // IsVirtualTable returns true if the TableDescriptor describes a // virtual Table (like the information_schema tables) and thus doesn't // need to be physically stored. diff --git a/pkg/sql/catalog/descpb/structured.proto b/pkg/sql/catalog/descpb/structured.proto index a55418849ddb..5861ee2cdcbc 100644 --- a/pkg/sql/catalog/descpb/structured.proto +++ b/pkg/sql/catalog/descpb/structured.proto @@ -1198,7 +1198,9 @@ message TableDescriptor { optional uint32 next_constraint_id = 49 [(gogoproto.nullable) = false, (gogoproto.customname) = "NextConstraintID", (gogoproto.casttype) = "ConstraintID"]; - // Next ID: 51 + // TableLevelSettings are cluster or session settings specified at the table level. + optional cockroach.sql.catalog.catpb.TableLevelSettings table_level_setting = 51 [(gogoproto.customname)="TableLevelSettings"]; + } // SurvivalGoal is the survival goal for a database. diff --git a/pkg/sql/catalog/tabledesc/structured.go b/pkg/sql/catalog/tabledesc/structured.go index 3b1f786d856d..6353b2848cae 100644 --- a/pkg/sql/catalog/tabledesc/structured.go +++ b/pkg/sql/catalog/tabledesc/structured.go @@ -14,6 +14,7 @@ import ( "context" "fmt" "sort" + "strconv" "strings" "github.com/cockroachdb/cockroach/pkg/clusterversion" @@ -2533,6 +2534,12 @@ func (desc *wrapper) GetStorageParams(spaceBetweenEqual bool) []string { appendStorageParam := func(key, value string) { storageParams = append(storageParams, key+spacing+`=`+spacing+value) } + boolAsString := func(boolVal bool) string { + if boolVal { + return "true" + } + return "false" + } if ttl := desc.GetRowLevelTTL(); ttl != nil { appendStorageParam(`ttl`, `'on'`) appendStorageParam(`ttl_automatic_column`, `'on'`) @@ -2562,6 +2569,25 @@ func (desc *wrapper) GetStorageParams(spaceBetweenEqual bool) []string { if exclude := desc.GetExcludeDataFromBackup(); exclude { appendStorageParam(`exclude_data_from_backup`, `true`) } + if desc.TableLevelSettings != nil { + settings := desc.TableLevelSettings + // These need to be wrapped in double-quotes because they contain '.' chars. + if settings.SqlStatsAutomaticCollectionEnabled != nil { + value := settings.SqlStatsAutomaticCollectionEnabled.Value + appendStorageParam(`"sql.stats.automatic_collection.enabled"`, + boolAsString(value)) + } + if settings.SqlStatsAutomaticCollectionMinStaleRows != nil { + value := settings.SqlStatsAutomaticCollectionMinStaleRows.Value + appendStorageParam(`"sql.stats.automatic_collection.min_stale_rows"`, + strconv.FormatInt(value, 10)) + } + if settings.SqlStatsAutomaticCollectionFractionStaleRows != nil { + value := settings.SqlStatsAutomaticCollectionFractionStaleRows.Value + appendStorageParam(`"sql.stats.automatic_collection.fraction_stale_rows"`, + fmt.Sprintf("%g", value)) //strconv.FormatFloat(value, 10)) + } + } return storageParams } diff --git a/pkg/sql/catalog/tabledesc/validate.go b/pkg/sql/catalog/tabledesc/validate.go index 599d96482bcb..47054e1196d4 100644 --- a/pkg/sql/catalog/tabledesc/validate.go +++ b/pkg/sql/catalog/tabledesc/validate.go @@ -15,6 +15,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/keys" "github.com/cockroachdb/cockroach/pkg/roachpb" + "github.com/cockroachdb/cockroach/pkg/settings" "github.com/cockroachdb/cockroach/pkg/sql/catalog" "github.com/cockroachdb/cockroach/pkg/sql/catalog/catpb" "github.com/cockroachdb/cockroach/pkg/sql/catalog/catprivilege" @@ -713,6 +714,8 @@ func (desc *wrapper) ValidateSelf(vea catalog.ValidationErrorAccumulator) { // ON UPDATE expression. This check is made to ensure that we know which ON // UPDATE action to perform when a FK UPDATE happens. ValidateOnUpdate(desc, vea.Report) + + vea.Report(desc.validateTableLevelSettings()) } // ValidateOnUpdate returns an error if there is a column with both a foreign @@ -1501,3 +1504,46 @@ func (desc *wrapper) validatePartitioning() error { ) }) } + +// validateTableLevelSettings validates that any new table-level settings hold +// a valid value. +func (desc *wrapper) validateTableLevelSettings() error { + if desc.TableLevelSettings != nil { + if desc.TableLevelSettings.SqlStatsAutomaticCollectionEnabled != nil { + setting := "sql.stats.automatic_collection.enabled" + if desc.IsVirtualTable() { + return errors.Newf("Setting %s may not be set on virtual table", setting) + } + if !desc.IsTable() { + return errors.Newf("Setting %s may not be set on a view or sequence", setting) + } + } + if desc.TableLevelSettings.SqlStatsAutomaticCollectionMinStaleRows != nil { + setting := "sql.stats.automatic_collection.min_stale_rows" + if desc.IsVirtualTable() { + return errors.Newf("Setting %s may not be set on virtual table", setting) + } + if !desc.IsTable() { + return errors.Newf("Setting %s may not be set on a view or sequence", setting) + } + if err := settings. + NonNegativeInt(desc.TableLevelSettings.SqlStatsAutomaticCollectionMinStaleRows.Value); err != nil { + return errors.Wrapf(err, "invalid value for %s", setting) + } + } + if desc.TableLevelSettings.SqlStatsAutomaticCollectionFractionStaleRows != nil { + setting := "sql.stats.automatic_collection.fraction_stale_rows" + if desc.IsVirtualTable() { + return errors.Newf("Setting %s may not be set on virtual table", setting) + } + if !desc.IsTable() { + return errors.Newf("Setting %s may not be set on a view or sequence", setting) + } + if err := settings. + NonNegativeFloat(desc.TableLevelSettings.SqlStatsAutomaticCollectionFractionStaleRows.Value); err != nil { + return errors.Wrapf(err, "invalid value for %s", setting) + } + } + } + return nil +} diff --git a/pkg/sql/catalog/tabledesc/validate_test.go b/pkg/sql/catalog/tabledesc/validate_test.go index ab8c710bb31d..87b5c0749611 100644 --- a/pkg/sql/catalog/tabledesc/validate_test.go +++ b/pkg/sql/catalog/tabledesc/validate_test.go @@ -131,6 +131,7 @@ var validationMap = []struct { "ExcludeDataFromBackup": {status: thisFieldReferencesNoObjects}, "NextConstraintID": {status: iSolemnlySwearThisFieldIsValidated}, "DeclarativeSchemaChangerState": {status: iSolemnlySwearThisFieldIsValidated}, + "TableLevelSettings": {status: iSolemnlySwearThisFieldIsValidated}, }, }, { @@ -289,6 +290,14 @@ var validationMap = []struct { "DeclarativeSchemaChangerState": {status: thisFieldReferencesNoObjects}, }, }, + { + obj: catpb.TableLevelSettings{}, + fieldMap: map[string]validationStatusInfo{ + "SqlStatsAutomaticCollectionEnabled": {status: iSolemnlySwearThisFieldIsValidated}, + "SqlStatsAutomaticCollectionMinStaleRows": {status: iSolemnlySwearThisFieldIsValidated}, + "SqlStatsAutomaticCollectionFractionStaleRows": {status: iSolemnlySwearThisFieldIsValidated}, + }, + }, } type validationStatusInfo struct { diff --git a/pkg/sql/distsql_plan_stats.go b/pkg/sql/distsql_plan_stats.go index fb097b34c726..6fcc49d9daba 100644 --- a/pkg/sql/distsql_plan_stats.go +++ b/pkg/sql/distsql_plan_stats.go @@ -212,6 +212,9 @@ func (dsp *DistSQLPlanner) createStatsPlan( var rowsExpected uint64 if len(tableStats) > 0 { overhead := stats.AutomaticStatisticsFractionStaleRows.Get(&dsp.st.SV) + if autoStatsFractionStaleRowsForTable, ok := desc.TableDesc().AutoStatsFractionStaleRows(); ok { + overhead = autoStatsFractionStaleRowsForTable + } // Convert to a signed integer first to make the linter happy. rowsExpected = uint64(int64( // The total expected number of rows is the same number that was measured diff --git a/pkg/sql/logictest/testdata/logic_test/alter_table b/pkg/sql/logictest/testdata/logic_test/alter_table index 64374d7cd197..cf852e6f5e50 100644 --- a/pkg/sql/logictest/testdata/logic_test/alter_table +++ b/pkg/sql/logictest/testdata/logic_test/alter_table @@ -2330,3 +2330,103 @@ COMMIT; statement ok ROLLBACK; + +subtest table_settings + +statement ok +CREATE TABLE t5 (a int) + +# Turn on automatic stats collection +statement ok +ALTER TABLE t5 SET ("sql.stats.automatic_collection.enabled" = true) + +# Verify automatic collection is enabled. +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't5' + AND tbl.drop_time IS NULL +---- +{"sqlStatsAutomaticCollectionEnabled": {"value": true}} + +# Strings in settings should be converted to the proper data type. +statement ok +ALTER TABLE t5 SET ("sql.stats.automatic_collection.enabled" = 'false') + +# Verify automatic collection is disabled. +# TODO(msirek): Fix pb_to_json so it displays the false boolean value. +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't5' + AND tbl.drop_time IS NULL + AND NOT crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' + -> 'sqlStatsAutomaticCollectionEnabled' ? 'value' +---- +{"sqlStatsAutomaticCollectionEnabled": {}} + +# SHOW CREATE TABLE displays the value properly. +query T +SELECT create_statement FROM [SHOW CREATE TABLE t5] +---- +CREATE TABLE public.t5 ( + a INT8 NULL, + rowid INT8 NOT VISIBLE NOT NULL DEFAULT unique_rowid(), + CONSTRAINT t5_pkey PRIMARY KEY (rowid ASC) +) WITH ("sql.stats.automatic_collection.enabled" = false) + +statement error pq: parameter "sql.stats.automatic_collection.enabled" requires a Boolean value +ALTER TABLE t5 SET ("sql.stats.automatic_collection.enabled" = 123) + +statement ok +ALTER TABLE t5 RESET ("sql.stats.automatic_collection.enabled") + +# Verify the automatic collection setting is removed. +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't5' + AND tbl.drop_time IS NULL +---- +{} + +statement error pq: invalid value for sql.stats.automatic_collection.fraction_stale_rows: could not parse "hello" as type float: strconv.ParseFloat: parsing "hello": invalid syntax +ALTER TABLE t5 SET ("sql.stats.automatic_collection.fraction_stale_rows" = 'hello') + +statement error pq: invalid value for sql.stats.automatic_collection.min_stale_rows: could not parse "world" as type int: strconv.ParseInt: parsing "world": invalid syntax +ALTER TABLE t5 SET ("sql.stats.automatic_collection.min_stale_rows" = 'world') + +# Verify strings can be converted to proper setting values. +statement ok +ALTER TABLE t5 SET ("sql.stats.automatic_collection.fraction_stale_rows" = '0.15', + "sql.stats.automatic_collection.min_stale_rows" = '1234') + +# Verify settings +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't5' + AND tbl.drop_time IS NULL +---- +{"sqlStatsAutomaticCollectionFractionStaleRows": {"value": 0.15}, "sqlStatsAutomaticCollectionMinStaleRows": {"value": "1234"}} diff --git a/pkg/sql/logictest/testdata/logic_test/create_table b/pkg/sql/logictest/testdata/logic_test/create_table index 9e499e3d2d40..9a5ab88f312d 100644 --- a/pkg/sql/logictest/testdata/logic_test/create_table +++ b/pkg/sql/logictest/testdata/logic_test/create_table @@ -867,3 +867,67 @@ CREATE TABLE public.t_good_hash_indexes_2 ( crdb_internal_a_shard_5 INT8 NOT VISIBLE NOT NULL AS (mod(fnv32(crdb_internal.datums_to_bytes(a)), 5:::INT8)) VIRTUAL, CONSTRAINT t_good_hash_indexes_2_pkey PRIMARY KEY (a ASC) USING HASH WITH (bucket_count=5) ) + +subtest table_settings + +statement ok +CREATE TABLE t1 (a int) WITH ("sql.stats.automatic_collection.enabled" = true) + +# Verify automatic collection is enabled. +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't1' + AND tbl.drop_time IS NULL +---- +{"sqlStatsAutomaticCollectionEnabled": {"value": true}} + +statement ok +DROP TABLE t1 + +statement ok +CREATE TABLE t1 (a int) WITH ("sql.stats.automatic_collection.fraction_stale_rows" = 0.5, + "sql.stats.automatic_collection.min_stale_rows" = 4000) + +# Verify settings +query T +SELECT + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id +WHERE + tbl.name = 't1' + AND tbl.drop_time IS NULL +---- +{"sqlStatsAutomaticCollectionFractionStaleRows": {"value": 0.5}, "sqlStatsAutomaticCollectionMinStaleRows": {"value": "4000"}} + +query T +SELECT create_statement FROM [SHOW CREATE TABLE t1] +---- +CREATE TABLE public.t1 ( + a INT8 NULL, + rowid INT8 NOT VISIBLE NOT NULL DEFAULT unique_rowid(), + CONSTRAINT t1_pkey PRIMARY KEY (rowid ASC) +) WITH ("sql.stats.automatic_collection.min_stale_rows" = 4000, "sql.stats.automatic_collection.fraction_stale_rows" = 0.5) + +statement ok +CREATE TABLE t11 (a int) WITH ("sql.stats.automatic_collection.enabled" = true, + "sql.stats.automatic_collection.fraction_stale_rows" = 1.797693134862315708145274237317043567981e+308, + "sql.stats.automatic_collection.min_stale_rows" = 9223372036854775807) + +# Using max values for auto stats table settings +query T +SELECT create_statement FROM [SHOW CREATE TABLE t11] +---- +CREATE TABLE public.t11 ( + a INT8 NULL, + rowid INT8 NOT VISIBLE NOT NULL DEFAULT unique_rowid(), + CONSTRAINT t11_pkey PRIMARY KEY (rowid ASC) +) WITH ("sql.stats.automatic_collection.enabled" = true, "sql.stats.automatic_collection.min_stale_rows" = 9223372036854775807, "sql.stats.automatic_collection.fraction_stale_rows" = 1.7976931348623157e+308) diff --git a/pkg/sql/paramparse/BUILD.bazel b/pkg/sql/paramparse/BUILD.bazel index 7faf6d4c7020..d8e9299ae4d9 100644 --- a/pkg/sql/paramparse/BUILD.bazel +++ b/pkg/sql/paramparse/BUILD.bazel @@ -12,6 +12,7 @@ go_library( deps = [ "//pkg/geo/geoindex", "//pkg/server/telemetry", + "//pkg/settings", "//pkg/sql/catalog/catpb", "//pkg/sql/catalog/descpb", "//pkg/sql/catalog/tabledesc", @@ -20,6 +21,7 @@ go_library( "//pkg/sql/pgwire/pgnotice", "//pkg/sql/sem/tree", "//pkg/sql/sqltelemetry", + "//pkg/sql/stats", "//pkg/sql/types", "//pkg/util/duration", "//pkg/util/errorutil/unimplemented", diff --git a/pkg/sql/paramparse/paramobserver.go b/pkg/sql/paramparse/paramobserver.go index b3c6efb1b209..0d5d57bdd55c 100644 --- a/pkg/sql/paramparse/paramobserver.go +++ b/pkg/sql/paramparse/paramobserver.go @@ -16,6 +16,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/geo/geoindex" "github.com/cockroachdb/cockroach/pkg/server/telemetry" + "github.com/cockroachdb/cockroach/pkg/settings" "github.com/cockroachdb/cockroach/pkg/sql/catalog/catpb" "github.com/cockroachdb/cockroach/pkg/sql/catalog/descpb" "github.com/cockroachdb/cockroach/pkg/sql/catalog/tabledesc" @@ -24,6 +25,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgnotice" "github.com/cockroachdb/cockroach/pkg/sql/sem/tree" "github.com/cockroachdb/cockroach/pkg/sql/sqltelemetry" + "github.com/cockroachdb/cockroach/pkg/sql/stats" "github.com/cockroachdb/cockroach/pkg/sql/types" "github.com/cockroachdb/cockroach/pkg/util/duration" "github.com/cockroachdb/cockroach/pkg/util/errorutil/unimplemented" @@ -139,6 +141,34 @@ func boolFromDatum(evalCtx *tree.EvalContext, key string, datum tree.Datum) (boo return bool(*s), nil } +func intFromDatum(evalCtx *tree.EvalContext, key string, datum tree.Datum) (int64, error) { + intDatum := datum + if stringVal, err := DatumAsString(evalCtx, key, datum); err == nil { + if intDatum, err = tree.ParseDInt(stringVal); err != nil { + return 0, errors.Wrapf(err, "invalid value for %s", key) + } + } + s, err := DatumAsInt(evalCtx, key, intDatum) + if err != nil { + return 0, err + } + return s, nil +} + +func floatFromDatum(evalCtx *tree.EvalContext, key string, datum tree.Datum) (float64, error) { + floatDatum := datum + if stringVal, err := DatumAsString(evalCtx, key, datum); err == nil { + if floatDatum, err = tree.ParseDFloat(stringVal); err != nil { + return 0, errors.Wrapf(err, "invalid value for %s", key) + } + } + s, err := DatumAsFloat(evalCtx, key, floatDatum) + if err != nil { + return 0, err + } + return s, nil +} + type tableParam struct { onSet func(ctx context.Context, po *TableStorageParamObserver, semaCtx *tree.SemaContext, evalCtx *tree.EvalContext, key string, datum tree.Datum) error onReset func(po *TableStorageParamObserver, evalCtx *tree.EvalContext, key string) error @@ -448,6 +478,18 @@ var tableParams = map[string]tableParam{ return nil }, }, + stats.AutoStatsClusterSettingName: { + onSet: boolTableSettingFunc, + onReset: tableSettingResetFunc(boolSetting), + }, + `sql.stats.automatic_collection.min_stale_rows`: { + onSet: intTableSettingFunc(settings.NonNegativeInt), + onReset: tableSettingResetFunc(intSetting), + }, + `sql.stats.automatic_collection.fraction_stale_rows`: { + onSet: floatTableSettingFunc(settings.NonNegativeFloat), + onReset: tableSettingResetFunc(floatSetting), + }, } func init() { @@ -491,6 +533,194 @@ func init() { } } +type settingDataType int + +const ( + boolSetting = iota + intSetting + floatSetting +) + +func boolTableSettingFunc( + ctx context.Context, + po *TableStorageParamObserver, + semaCtx *tree.SemaContext, + evalCtx *tree.EvalContext, + key string, + datum tree.Datum, +) error { + boolVal, err := boolFromDatum(evalCtx, key, datum) + if err != nil { + return err + } + if po.tableDesc.TableLevelSettings == nil { + po.tableDesc.TableLevelSettings = &catpb.TableLevelSettings{} + } + var settingPtr **catpb.Bool + var ok bool + if settingPtr, ok = settingValuePointer(key, po.tableDesc.TableLevelSettings).(**catpb.Bool); !ok { + return errors.Newf("table setting %s has unexpected type", key) + } + if settingPtr == nil { + return errors.Newf("unable to set table setting %s", key) + } + setting := *settingPtr + if setting == nil { + *settingPtr = &catpb.Bool{Value: boolVal} + return nil + } else if setting.Value == boolVal { + return nil + } + setting.Value = boolVal + return nil +} + +func intTableSettingFunc( + validateFunc func(v int64) error, +) func(ctx context.Context, po *TableStorageParamObserver, semaCtx *tree.SemaContext, + evalCtx *tree.EvalContext, key string, datum tree.Datum) error { + return func(ctx context.Context, po *TableStorageParamObserver, semaCtx *tree.SemaContext, + evalCtx *tree.EvalContext, key string, datum tree.Datum) error { + intVal, err := intFromDatum(evalCtx, key, datum) + if err != nil { + return err + } + if po.tableDesc.TableLevelSettings == nil { + po.tableDesc.TableLevelSettings = &catpb.TableLevelSettings{} + } + var settingPtr **catpb.Int + var ok bool + if settingPtr, ok = settingValuePointer(key, po.tableDesc.TableLevelSettings).(**catpb.Int); !ok { + return errors.Newf("table setting %s has unexpected type", key) + } + if settingPtr == nil { + return errors.Newf("unable to set table setting %s", key) + } + if err = validateFunc(intVal); err != nil { + return errors.Wrapf(err, "invalid value for %s", key) + } + setting := *settingPtr + if setting == nil { + *settingPtr = &catpb.Int{Value: intVal} + return nil + } else if setting.Value == intVal { + return nil + } + setting.Value = intVal + return nil + } +} + +func floatTableSettingFunc( + validateFunc func(v float64) error, +) func(ctx context.Context, po *TableStorageParamObserver, semaCtx *tree.SemaContext, + evalCtx *tree.EvalContext, key string, datum tree.Datum) error { + return func(ctx context.Context, po *TableStorageParamObserver, semaCtx *tree.SemaContext, + evalCtx *tree.EvalContext, key string, datum tree.Datum) error { + floatVal, err := floatFromDatum(evalCtx, key, datum) + if err != nil { + return err + } + if po.tableDesc.TableLevelSettings == nil { + po.tableDesc.TableLevelSettings = &catpb.TableLevelSettings{} + } + var settingPtr **catpb.Float + var ok bool + if settingPtr, ok = settingValuePointer(key, po.tableDesc.TableLevelSettings).(**catpb.Float); !ok { + return errors.Newf("table setting %s has unexpected type", key) + } + if settingPtr == nil { + return errors.Newf("unable to set table setting %s", key) + } + if err = validateFunc(floatVal); err != nil { + return errors.Wrapf(err, "invalid value for %s", key) + } + setting := *settingPtr + if setting == nil { + *settingPtr = &catpb.Float{Value: floatVal} + return nil + } else if setting.Value == floatVal { + return nil + } + setting.Value = floatVal + return nil + } +} + +func tableSettingResetFunc( + settingDataType settingDataType, +) func(po *TableStorageParamObserver, evalCtx *tree.EvalContext, key string) error { + return func(po *TableStorageParamObserver, evalCtx *tree.EvalContext, key string) error { + if po.tableDesc.TableLevelSettings == nil { + return nil + } + settingPtr := settingValuePointer(key, po.tableDesc.TableLevelSettings) + if settingPtr == nil { + return errors.Newf("unable to reset table setting %s", key) + } + var ok bool + switch settingDataType { + case boolSetting: + var setting **catpb.Bool + if setting, ok = settingPtr.(**catpb.Bool); !ok { + return errors.Newf("unable to reset table setting %s", key) + } + if *setting == nil { + // This setting is unset or has already been reset. + return nil + } + *setting = nil + case intSetting: + var setting **catpb.Int + if setting, ok = settingPtr.(**catpb.Int); !ok { + return errors.Newf("unable to reset table setting %s", key) + } + if *setting == nil { + // This setting is unset or has already been reset. + return nil + } + *setting = nil + case floatSetting: + var setting **catpb.Float + if setting, ok = settingPtr.(**catpb.Float); !ok { + return errors.Newf("unable to reset table setting %s", key) + } + if *setting == nil { + // This setting is unset or has already been reset. + return nil + } + *setting = nil + default: + return errors.Newf("unable to reset table setting %s", key) + } + return nil + } +} + +// tableSettingsDict provides the switch case to use in settingValuePointer for +// finding the tableSettings element to mutate. +var tableSettingsDict = map[string]int{ + `sql.stats.automatic_collection.enabled`: 1, + `sql.stats.automatic_collection.min_stale_rows`: 2, + `sql.stats.automatic_collection.fraction_stale_rows`: 3, +} + +func settingValuePointer(settingName string, tableSettings *catpb.TableLevelSettings) interface{} { + if idx, ok := tableSettingsDict[settingName]; ok { + switch idx { + case 1: + return &tableSettings.SqlStatsAutomaticCollectionEnabled + case 2: + return &tableSettings.SqlStatsAutomaticCollectionMinStaleRows + case 3: + return &tableSettings.SqlStatsAutomaticCollectionFractionStaleRows + default: + return nil + } + } + return nil +} + // onSet implements the StorageParamObserver interface. func (po *TableStorageParamObserver) onSet( ctx context.Context, diff --git a/pkg/sql/stats/automatic_stats.go b/pkg/sql/stats/automatic_stats.go index 36c9093d2210..7387bc111354 100644 --- a/pkg/sql/stats/automatic_stats.go +++ b/pkg/sql/stats/automatic_stats.go @@ -212,6 +212,18 @@ type Refresher struct { // mutationCounts contains aggregated mutation counts for each table that // have yet to be processed by the refresher. mutationCounts map[descpb.ID]int64 + + // autoStatsEnabled is true if auto stats collection is explicitly enabled for + // the given table, which may override the cluster setting. + autoStatsEnabled map[descpb.ID]bool + + // autoStatsFractionStaleRows maps TableID to the table-level setting of + // sql.stats.automatic_collection.fraction_stale_rows, if it's been set. + autoStatsFractionStaleRows map[descpb.ID]float64 + + // autoStatsMinStaleRows maps TableID to the table-level setting of + // sql.stats.automatic_collection.min_stale_rows, if it's been set. + autoStatsMinStaleRows map[descpb.ID]int64 } // mutation contains metadata about a SQL mutation and is the message passed to @@ -219,6 +231,10 @@ type Refresher struct { type mutation struct { tableID descpb.ID rowsAffected int + // Table-level settings which may override cluster settings + autoStatsEnabled *bool + autoStatsFractionStaleRows *float64 + autoStatsMinStaleRows *int64 } // MakeRefresher creates a new Refresher. @@ -232,15 +248,18 @@ func MakeRefresher( randSource := rand.NewSource(rand.Int63()) return &Refresher{ - AmbientContext: ambientCtx, - st: st, - ex: ex, - cache: cache, - randGen: makeAutoStatsRand(randSource), - mutations: make(chan mutation, refreshChanBufferLen), - asOfTime: asOfTime, - extraTime: time.Duration(rand.Int63n(int64(time.Hour))), - mutationCounts: make(map[descpb.ID]int64, 16), + AmbientContext: ambientCtx, + st: st, + ex: ex, + cache: cache, + randGen: makeAutoStatsRand(randSource), + mutations: make(chan mutation, refreshChanBufferLen), + asOfTime: asOfTime, + extraTime: time.Duration(rand.Int63n(int64(time.Hour))), + mutationCounts: make(map[descpb.ID]int64, 16), + autoStatsEnabled: make(map[descpb.ID]bool), + autoStatsFractionStaleRows: make(map[descpb.ID]float64), + autoStatsMinStaleRows: make(map[descpb.ID]int64), } } @@ -274,6 +293,9 @@ func (r *Refresher) Start( case <-timer.C: mutationCounts := r.mutationCounts + autoStatsEnabledForTable := r.autoStatsEnabled + autoStatsFractionStaleRows := r.autoStatsFractionStaleRows + autoStatsMinStaleRows := r.autoStatsMinStaleRows if err := stopper.RunAsyncTask( ctx, "stats.Refresher: maybeRefreshStats", func(ctx context.Context) { // Wait so that the latest changes will be reflected according to the @@ -288,13 +310,29 @@ func (r *Refresher) Start( } for tableID, rowsAffected := range mutationCounts { - // Check the cluster setting before each refresh in case it was - // disabled recently. - if !AutomaticStatisticsClusterMode.Get(&r.st.SV) { + // Check the cluster setting and table setting before each refresh + // in case it was disabled recently. + var enabledForTable bool + if enabled, ok := autoStatsEnabledForTable[tableID]; ok { + if !enabled { + break + } + enabledForTable = true + } else if !AutomaticStatisticsClusterMode.Get(&r.st.SV) { break } + var fractionStaleRowsForTable *float64 + var minStaleRowsForTable *int64 + + if fractionStaleRows, ok := autoStatsFractionStaleRows[tableID]; ok { + fractionStaleRowsForTable = &fractionStaleRows + } + if minStaleRows, ok := autoStatsMinStaleRows[tableID]; ok { + minStaleRowsForTable = &minStaleRows + } - r.maybeRefreshStats(ctx, stopper, tableID, rowsAffected, r.asOfTime) + r.maybeRefreshStats(ctx, stopper, tableID, rowsAffected, r.asOfTime, + enabledForTable, fractionStaleRowsForTable, minStaleRowsForTable) select { case <-stopper.ShouldQuiesce(): @@ -308,10 +346,25 @@ func (r *Refresher) Start( }); err != nil { log.Errorf(ctx, "failed to refresh stats: %v", err) } + // This clears out any tables that may have been added to the + // mutationCounts map by ensureAllTables. This is by design. We don't + // want to constantly refresh tables that are read-only. r.mutationCounts = make(map[descpb.ID]int64, len(r.mutationCounts)) + r.autoStatsEnabled = make(map[descpb.ID]bool) + r.autoStatsFractionStaleRows = make(map[descpb.ID]float64) + r.autoStatsMinStaleRows = make(map[descpb.ID]int64) case mut := <-r.mutations: r.mutationCounts[mut.tableID] += int64(mut.rowsAffected) + if mut.autoStatsEnabled != nil { + r.autoStatsEnabled[mut.tableID] = *mut.autoStatsEnabled + } + if mut.autoStatsFractionStaleRows != nil { + r.autoStatsFractionStaleRows[mut.tableID] = *mut.autoStatsFractionStaleRows + } + if mut.autoStatsMinStaleRows != nil { + r.autoStatsMinStaleRows[mut.tableID] = *mut.autoStatsMinStaleRows + } case <-stopper.ShouldQuiesce(): return @@ -326,12 +379,67 @@ func (r *Refresher) Start( func (r *Refresher) ensureAllTables( ctx context.Context, settings *settings.Values, initialTableCollectionDelay time.Duration, ) { + if !AutomaticStatisticsClusterMode.Get(settings) { - // Automatic stats are disabled. + // Use a historical read so as to disable txn contention resolution. + // A table-level setting of sql.stats.automatic_collection.enabled=true is + // checked and only those tables are included in this scan. + getTablesWithAutoStatsExplicitlyEnabledQuery := fmt.Sprintf( + ` +SELECT + tbl.table_id +FROM + crdb_internal.tables AS tbl + INNER JOIN system.descriptor AS d ON d.id = tbl.table_id + AS OF SYSTEM TIME '-%s' +WHERE + tbl.database_name IS NOT NULL + AND tbl.database_name <> '%s' + AND tbl.drop_time IS NULL + AND ( + crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', d.descriptor, false)->'table'->>'viewQuery' + ) IS NULL + AND + (crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' -> 'sqlStatsAutomaticCollectionEnabled' ? 'value' + );`, + initialTableCollectionDelay, + systemschema.SystemDatabaseName, + ) + + it, err := r.ex.QueryIterator( + ctx, + "get-tables-with-autostats-explicitly-enabled", + nil, /* txn */ + getTablesWithAutoStatsExplicitlyEnabledQuery, + ) + if err == nil { + var ok bool + for ok, err = it.Next(ctx); ok; ok, err = it.Next(ctx) { + row := it.Cur() + tableID := descpb.ID(*row[0].(*tree.DInt)) + // Don't create statistics for virtual tables. + // The query already excludes views and system tables. + if !descpb.IsVirtualTable(tableID) { + r.mutationCounts[tableID] += 0 + r.autoStatsEnabled[tableID] = true + } + } + } + if err != nil { + // Note that it is ok if the iterator returned partial results before + // encountering an error - in that case we added entries to + // r.mutationCounts for some of the tables and operation of adding an + // entry is idempotent (i.e. we didn't mess up anything for the next + // call to this method). + log.Errorf(ctx, "failed to get tables for automatic stats: %v", err) + } return } // Use a historical read so as to disable txn contention resolution. + // A table-level setting of sql.stats.automatic_collection.enabled of null, + // meaning not set, or true qualifies rows we're interested in. getAllTablesQuery := fmt.Sprintf( ` SELECT @@ -346,7 +454,13 @@ WHERE AND tbl.drop_time IS NULL AND ( crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', d.descriptor, false)->'table'->>'viewQuery' - ) IS NULL;`, + ) IS NULL + AND + (crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting'->'sqlStatsAutomaticCollectionEnabled' IS NULL + OR crdb_internal.pb_to_json('cockroach.sql.sqlbase.Descriptor', + d.descriptor, false)->'table'->'tableLevelSetting' -> 'sqlStatsAutomaticCollectionEnabled' ? 'value' + );`, initialTableCollectionDelay, systemschema.SystemDatabaseName, ) @@ -385,7 +499,16 @@ WHERE // successful insert, update, upsert or delete. rowsAffected refers to the // number of rows written as part of the mutation operation. func (r *Refresher) NotifyMutation(table catalog.TableDescriptor, rowsAffected int) { - if !AutomaticStatisticsClusterMode.Get(&r.st.SV) { + // The table-level setting of sql.stats.automatic_collection.enabled takes + // precedence over the cluster setting. + var enabledAtTableLevel *bool + + if autoStatsCollectionEnabled, ok := table.TableDesc().AutoStatsCollectionEnabled(); ok { + if !autoStatsCollectionEnabled { + return + } + enabledAtTableLevel = &autoStatsCollectionEnabled + } else if !AutomaticStatisticsClusterMode.Get(&r.st.SV) { // Automatic stats are disabled. return } @@ -393,11 +516,26 @@ func (r *Refresher) NotifyMutation(table catalog.TableDescriptor, rowsAffected i // Don't collect stats for this kind of table: system, virtual, view, etc. return } + var fractionStaleRowsForTable *float64 + var minStaleRowsForTable *int64 + + if minStaleRows, ok := table.TableDesc().AutoStatsMinStaleRows(); ok { + minStaleRowsForTable = &minStaleRows + } + if fractionStaleRows, ok := table.TableDesc().AutoStatsFractionStaleRows(); ok { + fractionStaleRowsForTable = &fractionStaleRows + } // Send mutation info to the refresher thread to avoid adding latency to // the calling transaction. select { - case r.mutations <- mutation{tableID: table.GetID(), rowsAffected: rowsAffected}: + case r.mutations <- mutation{ + tableID: table.GetID(), + rowsAffected: rowsAffected, + autoStatsEnabled: enabledAtTableLevel, + autoStatsFractionStaleRows: fractionStaleRowsForTable, + autoStatsMinStaleRows: minStaleRowsForTable, + }: default: // Don't block if there is no room in the buffered channel. if bufferedChanFullLogLimiter.ShouldLog() { @@ -410,12 +548,24 @@ func (r *Refresher) NotifyMutation(table catalog.TableDescriptor, rowsAffected i // maybeRefreshStats implements the core logic described in the comment for // Refresher. It is called by the background Refresher thread. +// +// enabledForTable, if true, indicates that auto stats is explicitly enabled at +// the table level and any mutation struct built by maybeRefreshStats should +// include that information. +// +// fractionStaleRowsForTable and minStaleRowsForTable, if non-nil, indicate +// table-level settings for sql.stats.automatic_collection.fraction_stale_rows +// and sql.stats.automatic_collection.min_stale_rows to be used in place of the +// cluster settings of the same name. func (r *Refresher) maybeRefreshStats( ctx context.Context, stopper *stop.Stopper, tableID descpb.ID, rowsAffected int64, asOf time.Duration, + enabledForTable bool, + fractionStaleRowsForTable *float64, + minStaleRowsForTable *int64, ) { tableStats, err := r.cache.getTableStatsFromCache(ctx, tableID) if err != nil { @@ -453,15 +603,31 @@ func (r *Refresher) maybeRefreshStats( mustRefresh = true } - targetRows := int64(rowCount*AutomaticStatisticsFractionStaleRows.Get(&r.st.SV)) + - AutomaticStatisticsMinStaleRows.Get(&r.st.SV) - if !mustRefresh && rowsAffected < math.MaxInt32 && r.randGen.randInt(targetRows) >= rowsAffected { + statsFractionStaleRows := AutomaticStatisticsFractionStaleRows.Get(&r.st.SV) + if fractionStaleRowsForTable != nil { + statsFractionStaleRows = *fractionStaleRowsForTable + } + statsMinStaleRows := AutomaticStatisticsMinStaleRows.Get(&r.st.SV) + if minStaleRowsForTable != nil { + statsMinStaleRows = *minStaleRowsForTable + } + targetRows := int64(rowCount*statsFractionStaleRows) + statsMinStaleRows + // randInt will panic if we pass it a value of 0. + randomTargetRows := int64(0) + if targetRows > 0 { + randomTargetRows = r.randGen.randInt(targetRows) + } + if !mustRefresh && rowsAffected < math.MaxInt32 && randomTargetRows >= rowsAffected { // No refresh is happening this time. return } if err := r.refreshStats(ctx, tableID, asOf); err != nil { if errors.Is(err, ConcurrentCreateStatsError) { + var enabledAtTableLevel *bool + if enabledForTable { + enabledAtTableLevel = &enabledForTable + } // Another stats job was already running. Attempt to reschedule this // refresh. if mustRefresh { @@ -471,14 +637,22 @@ func (r *Refresher) maybeRefreshStats( // cycle so that we have another chance to trigger a refresh. We pass // rowsAffected=0 so that we don't force a refresh if another node has // already done it. - r.mutations <- mutation{tableID: tableID, rowsAffected: 0} + r.mutations <- mutation{tableID: tableID, rowsAffected: 0, + autoStatsEnabled: enabledAtTableLevel, + autoStatsFractionStaleRows: fractionStaleRowsForTable, + autoStatsMinStaleRows: minStaleRowsForTable, + } } else { // If this refresh was caused by a "dice roll", we want to make sure // that the refresh is rescheduled so that we adhere to the // AutomaticStatisticsFractionStaleRows statistical ideal. We // ensure that the refresh is triggered during the next cycle by // passing a very large number for rowsAffected. - r.mutations <- mutation{tableID: tableID, rowsAffected: math.MaxInt32} + r.mutations <- mutation{tableID: tableID, rowsAffected: math.MaxInt32, + autoStatsEnabled: enabledAtTableLevel, + autoStatsFractionStaleRows: fractionStaleRowsForTable, + autoStatsMinStaleRows: minStaleRowsForTable, + } } return } diff --git a/pkg/sql/stats/automatic_stats_test.go b/pkg/sql/stats/automatic_stats_test.go index 592c6d7c5cc5..fc8b04a10fd9 100644 --- a/pkg/sql/stats/automatic_stats_test.go +++ b/pkg/sql/stats/automatic_stats_test.go @@ -84,6 +84,8 @@ func TestMaybeRefreshStats(t *testing.T) { // even though rowsAffected=0. refresher.maybeRefreshStats( ctx, s.Stopper(), descA.GetID(), 0 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) if err := checkStatsCount(ctx, cache, descA, 1 /* expected */); err != nil { t.Fatal(err) @@ -93,6 +95,31 @@ func TestMaybeRefreshStats(t *testing.T) { // is 0, so refreshing will not succeed. refresher.maybeRefreshStats( ctx, s.Stopper(), descA.GetID(), 0 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ + ) + if err := checkStatsCount(ctx, cache, descA, 1 /* expected */); err != nil { + t.Fatal(err) + } + + // Setting minStaleRows for the table prevents refreshing from occurring. + minStaleRowsForTable := int64(100000000) + refresher.maybeRefreshStats( + ctx, s.Stopper(), descA.GetID(), 10 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, &minStaleRowsForTable, /* minStaleRowsForTable */ + ) + if err := checkStatsCount(ctx, cache, descA, 1 /* expected */); err != nil { + t.Fatal(err) + } + + // Setting fractionStaleRows for the table can also prevent refreshing from + // occurring, though this is a not a typical value for this setting. + fractionStaleRowsForTable := float64(100000000) + refresher.maybeRefreshStats( + ctx, s.Stopper(), descA.GetID(), 10 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + &fractionStaleRowsForTable /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) if err := checkStatsCount(ctx, cache, descA, 1 /* expected */); err != nil { t.Fatal(err) @@ -102,6 +129,8 @@ func TestMaybeRefreshStats(t *testing.T) { // updated than exist in the table, the probability of a refresh is 100%. refresher.maybeRefreshStats( ctx, s.Stopper(), descA.GetID(), 10 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) if err := checkStatsCount(ctx, cache, descA, 2 /* expected */); err != nil { t.Fatal(err) @@ -113,6 +142,8 @@ func TestMaybeRefreshStats(t *testing.T) { descVW := desctestutils.TestingGetPublicTableDescriptor(s.DB(), keys.SystemSQLCodec, "t", "vw") refresher.maybeRefreshStats( ctx, s.Stopper(), descVW.GetID(), 0 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) select { case <-refresher.mutations: @@ -312,6 +343,8 @@ func TestAverageRefreshTime(t *testing.T) { // is 0. refresher.maybeRefreshStats( ctx, s.Stopper(), table.GetID(), 0 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) if err := checkStatsCount(ctx, cache, table, 20 /* expected */); err != nil { t.Fatal(err) @@ -362,6 +395,8 @@ func TestAverageRefreshTime(t *testing.T) { // were deleted. refresher.maybeRefreshStats( ctx, s.Stopper(), table.GetID(), 0 /* rowsAffected */, time.Microsecond, /* asOf */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) if err := checkStatsCount(ctx, cache, table, 15 /* expected */); err != nil { t.Fatal(err) @@ -455,6 +490,8 @@ func TestNoRetryOnFailure(t *testing.T) { // Try to refresh stats on a table that doesn't exist. r.maybeRefreshStats( ctx, s.Stopper(), 100 /* tableID */, math.MaxInt32, time.Microsecond, /* asOfTime */ + false, /* enabledForTable */ + nil /* fractionStaleRowsForTable */, nil, /* minStaleRowsForTable */ ) // Ensure that we will not try to refresh tableID 100 again.