From 72dbe2a472ba40836e0fc239d1bbaa20c70f46ff Mon Sep 17 00:00:00 2001 From: Thomas Guettler Date: Sat, 3 Oct 2026 12:02:36 +0200 Subject: [PATCH] pkg/clickhouse: answer HasProfileData with one row, not a scan HasProfileData asks whether the table holds any data at all. It answered by calling ProfileTypes(UnixMilli(0), UnixMilli(0)), and ProfileTypes applies its time filter only when both bounds are non-zero -- so the cheapest question in the API ran an unbounded SELECT DISTINCT over six columns, a full scan of a table that grows without bound. Measured on a server with 72 hours of retention: 108,591,120 rows and 7.9 GB read to return 9 rows, 1.3 seconds warm and 61 seconds cold. The UI calls this to decide whether to show its empty state, so the cost was paid on page loads rather than on anything a user asked for. Existence needs one row. The query is a small function so a test can pin its shape without a server: this needs a live ClickHouse to execute, while the regression is entirely visible in the statement. --- pkg/clickhouse/querier.go | 29 +++++++++++++++++++--- pkg/clickhouse/querier_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 pkg/clickhouse/querier_test.go diff --git a/pkg/clickhouse/querier.go b/pkg/clickhouse/querier.go index 96716c7b235..a4063233851 100644 --- a/pkg/clickhouse/querier.go +++ b/pkg/clickhouse/querier.go @@ -264,11 +264,34 @@ func (q *Querier) ProfileTypes( // HasProfileData checks if there is any profile data in the store. func (q *Querier) HasProfileData(ctx context.Context) (bool, error) { - types, err := q.ProfileTypes(ctx, time.UnixMilli(0), time.UnixMilli(0)) + ctx, span := q.tracer.Start(ctx, "ClickHouse/HasProfileData") + defer span.End() + + // This asks whether the table holds anything at all, so it wants one row, + // not every distinct profile type. It used to call + // ProfileTypes(UnixMilli(0), UnixMilli(0)), whose time filter is skipped + // when both bounds are zero -- so the question "is there any data?" was + // answered with an unbounded SELECT DISTINCT over six columns, a full scan + // of a table that grows without bound. Measured on a server with 72h of + // retention: 108,591,120 rows and 7.9 GB read to return 9 rows, 1.3s warm + // and 61s cold, every time the UI asked. + rows, err := q.client.Query(ctx, hasProfileDataQuery(q.client.FullTableName())) if err != nil { - return false, err + return false, fmt.Errorf("failed to query for profile data: %w", err) } - return len(types) > 0, nil + defer rows.Close() + + if rows.Next() { + return true, nil + } + return false, rows.Err() +} + +// hasProfileDataQuery is the statement HasProfileData runs. It is a separate +// function so a test can pin its shape without a server: existence needs one +// row, and must not become a scan again. +func hasProfileDataQuery(table string) string { + return fmt.Sprintf("SELECT 1 FROM %s LIMIT 1", table) } // QueryRange executes a range query and returns time series data. diff --git a/pkg/clickhouse/querier_test.go b/pkg/clickhouse/querier_test.go new file mode 100644 index 00000000000..fe0b2280e00 --- /dev/null +++ b/pkg/clickhouse/querier_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Parca Authors +// 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 clickhouse + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// HasProfileData asks whether the table holds anything at all, so it must read +// one row rather than compute every distinct profile type. +// +// It used to answer by calling ProfileTypes(UnixMilli(0), UnixMilli(0)), and +// ProfileTypes only applies its time filter when both bounds are non-zero -- +// so the cheapest question in the API ran an unbounded SELECT DISTINCT over six +// columns, scanning a table that grows without bound. On a server with 72h of +// retention that read 108,591,120 rows and 7.9 GB to return 9 rows, every time +// the UI asked. +// +// The shape is asserted rather than the result because this query needs a +// server to run, while the regression is entirely visible in the statement. +func TestHasProfileDataQueryShape(t *testing.T) { + q := hasProfileDataQuery("parca.stacktraces") + + require.Equal(t, "SELECT 1 FROM parca.stacktraces LIMIT 1", q) + + upper := strings.ToUpper(q) + require.Contains(t, upper, "LIMIT 1", "existence needs one row") + require.NotContains(t, upper, "DISTINCT", "existence must not be answered with a distinct scan") + require.NotContains(t, upper, "GROUP BY") + require.NotContains(t, upper, "ORDER BY") +}