Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions pkg/clickhouse/querier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions pkg/clickhouse/querier_test.go
Original file line number Diff line number Diff line change
@@ -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")
}