diff --git a/collector/info_schema_auto_increment.go b/collector/info_schema_auto_increment.go index d659765f..531b20ae 100644 --- a/collector/info_schema_auto_increment.go +++ b/collector/info_schema_auto_increment.go @@ -31,7 +31,7 @@ const infoSchemaAutoIncrementQuery = ` when 'mediumint' then 23 when 'int' then 31 when 'bigint' then 63 - end+(column_type like '% unsigned'))-1 as max_int + end+(column_type like '%unsigned%'))-1 as max_int FROM information_schema.columns c STRAIGHT_JOIN information_schema.tables t ON (BINARY c.table_schema=t.table_schema AND BINARY c.table_name=t.table_name) WHERE c.extra = 'auto_increment' AND t.auto_increment IS NOT NULL diff --git a/collector/info_schema_auto_increment_live_test.go b/collector/info_schema_auto_increment_live_test.go new file mode 100644 index 00000000..f10bd131 --- /dev/null +++ b/collector/info_schema_auto_increment_live_test.go @@ -0,0 +1,116 @@ +// Copyright 2018 The Prometheus Authors, 2026 Percona LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "database/sql" + "fmt" + "math" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" +) + +// TestScrapeAutoIncrementColumnsMaxValue evaluates the max_int expression on the +// server started by docker-compose, so every flavor of the CI matrix checks it. +// ZEROFILL appends another attribute after "unsigned" in column_type, which an +// anchored LIKE '% unsigned' misses: such columns reported the signed maximum +// and so looked twice as full as they were. +func TestScrapeAutoIncrementColumnsMaxValue(t *testing.T) { + if testing.Short() { + t.Skip("-short is passed, skipping test") + } + + db, err := sql.Open("mysql", "root@tcp(127.0.0.1:3306)/") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + const dbName = "test_auto_increment_db" + + if _, err := db.Exec("CREATE DATABASE IF NOT EXISTS " + dbName); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := db.Exec("DROP DATABASE " + dbName); err != nil { + t.Fatal(err) + } + }() + + cases := []struct { + name string + column string + wantMax float64 + }{ + {"signed_int", "int not null auto_increment", math.Pow(2, 31) - 1}, + {"unsigned_int", "int unsigned not null auto_increment", math.Pow(2, 32) - 1}, + {"zerofill_int", "int unsigned zerofill not null auto_increment", math.Pow(2, 32) - 1}, + {"unsigned_smallint", "smallint unsigned not null auto_increment", math.Pow(2, 16) - 1}, + {"unsigned_bigint", "bigint unsigned not null auto_increment", math.Pow(2, 64) - 1}, + } + + ctx := t.Context() + + for _, c := range cases { + if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s.%s (id %s, PRIMARY KEY (id))", dbName, c.name, c.column)); err != nil { + t.Fatalf("creating table %s: %v", c.name, err) + } + + // The scraper skips tables whose information_schema.tables.auto_increment + // is NULL, which is what a never-used counter reports. ANALYZE TABLE then + // refreshes the value the dictionary cache would otherwise serve for + // information_schema_stats_expiry seconds. + if _, err := db.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s.%s () VALUES ()", dbName, c.name)); err != nil { + t.Fatalf("seeding table %s: %v", c.name, err) + } + if _, err := db.ExecContext(ctx, fmt.Sprintf("ANALYZE TABLE %s.%s", dbName, c.name)); err != nil { + t.Fatalf("analyzing table %s: %v", c.name, err) + } + } + + ch := make(chan prometheus.Metric) + scrapeErr := make(chan error, 1) + go func() { + scrapeErr <- (ScrapeAutoIncrementColumns{}).Scrape(ctx, &instance{db: db}, ch, promslog.NewNopLogger()) + close(ch) + }() + + got := make(map[string]float64) + for metric := range ch { + if metric.Desc() != globalInfoSchemaAutoIncrementMaxDesc { + continue + } + result := readMetric(metric) + if result.labels["schema"] != dbName { + continue + } + got[result.labels["table"]] = result.value + } + if err := <-scrapeErr; err != nil { + t.Fatalf("scrape failed: %v", err) + } + + for _, c := range cases { + gotMax, ok := got[c.name] + if !ok { + t.Errorf("no max metric for table %s; collected: %v", c.name, got) + continue + } + if gotMax != c.wantMax { + t.Errorf("table %s (%s): max = %.0f, want %.0f", c.name, c.column, gotMax, c.wantMax) + } + } +} diff --git a/collector/info_schema_auto_increment_test.go b/collector/info_schema_auto_increment_test.go new file mode 100644 index 00000000..ab656f14 --- /dev/null +++ b/collector/info_schema_auto_increment_test.go @@ -0,0 +1,77 @@ +// Copyright 2018 The Prometheus Authors, 2026 Percona LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "context" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/promslog" +) + +// The mock returns max_int verbatim, so this only covers turning rows into +// metrics. The unsigned detection lives in the SQL itself and is covered by +// TestScrapeAutoIncrementColumnsMaxValue against a real server. +func TestScrapeAutoIncrementColumns(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Fatalf("error opening a stub database connection: %s", err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"table_schema", "table_name", "column_name", "auto_increment", "max_int"}). + AddRow("test", "signed", "id", 20_000, 32_767). + AddRow("test", "unsigned", "id", 47_000, 65_535) + mock.ExpectQuery(infoSchemaAutoIncrementQuery).WillReturnRows(rows) + + ch := make(chan prometheus.Metric) + go func() { + if err := (ScrapeAutoIncrementColumns{}).Scrape( + context.Background(), + &instance{db: db}, + ch, + promslog.NewNopLogger(), + ); err != nil { + t.Errorf("error calling function on test: %s", err) + } + close(ch) + }() + + expected := []MetricResult{ + {labels: labelMap{"schema": "test", "table": "signed", "column": "id"}, value: 20_000, metricType: dto.MetricType_GAUGE}, + {labels: labelMap{"schema": "test", "table": "signed", "column": "id"}, value: 32_767, metricType: dto.MetricType_GAUGE}, + {labels: labelMap{"schema": "test", "table": "unsigned", "column": "id"}, value: 47_000, metricType: dto.MetricType_GAUGE}, + {labels: labelMap{"schema": "test", "table": "unsigned", "column": "id"}, value: 65_535, metricType: dto.MetricType_GAUGE}, + } + for i, want := range expected { + metric, ok := <-ch + if !ok { + t.Fatalf("channel closed after %d metrics, want %d", i, len(expected)) + } + if got := readMetric(metric); !reflect.DeepEqual(got, want) { + t.Errorf("metric %d mismatch:\ngot %#v\nwant %#v", i, got, want) + } + } + if extra, ok := <-ch; ok { + t.Errorf("unexpected extra metric: %#v", readMetric(extra)) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %s", err) + } +}