github.com/grafana/pyroscope@v1.18.0/pkg/querybackend/query_time_series_test.go (about)

     1  package querybackend
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"os"
     7  	"path/filepath"
     8  	"slices"
     9  	"testing"
    10  	"time"
    11  
    12  	"github.com/stretchr/testify/assert"
    13  	"github.com/stretchr/testify/require"
    14  	"google.golang.org/grpc/codes"
    15  	"google.golang.org/grpc/status"
    16  
    17  	metastorev1 "github.com/grafana/pyroscope/api/gen/proto/go/metastore/v1"
    18  	queryv1 "github.com/grafana/pyroscope/api/gen/proto/go/query/v1"
    19  	typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
    20  	"github.com/grafana/pyroscope/pkg/block"
    21  	"github.com/grafana/pyroscope/pkg/block/metadata"
    22  	"github.com/grafana/pyroscope/pkg/objstore"
    23  	"github.com/grafana/pyroscope/pkg/objstore/providers/memory"
    24  	"github.com/grafana/pyroscope/pkg/querybackend/queryplan"
    25  	"github.com/grafana/pyroscope/pkg/test"
    26  )
    27  
    28  func TestValidateExemplarType(t *testing.T) {
    29  	tests := []struct {
    30  		name             string
    31  		exemplarType     typesv1.ExemplarType
    32  		expectedInclude  bool
    33  		expectedErrorMsg string
    34  		expectedCode     codes.Code
    35  	}{
    36  		{
    37  			name:            "UNSPECIFIED returns false, no error",
    38  			exemplarType:    typesv1.ExemplarType_EXEMPLAR_TYPE_UNSPECIFIED,
    39  			expectedInclude: false,
    40  		},
    41  		{
    42  			name:            "NONE returns false, no error",
    43  			exemplarType:    typesv1.ExemplarType_EXEMPLAR_TYPE_NONE,
    44  			expectedInclude: false,
    45  		},
    46  		{
    47  			name:            "INDIVIDUAL returns true, no error",
    48  			exemplarType:    typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL,
    49  			expectedInclude: true,
    50  		},
    51  		{
    52  			name:             "SPAN returns error with Unimplemented code",
    53  			exemplarType:     typesv1.ExemplarType_EXEMPLAR_TYPE_SPAN,
    54  			expectedInclude:  false,
    55  			expectedErrorMsg: "exemplar type span is not implemented",
    56  			expectedCode:     codes.Unimplemented,
    57  		},
    58  		{
    59  			name:             "Unknown type returns error with InvalidArgument code",
    60  			exemplarType:     typesv1.ExemplarType(999),
    61  			expectedInclude:  false,
    62  			expectedErrorMsg: "unknown exemplar type",
    63  			expectedCode:     codes.InvalidArgument,
    64  		},
    65  	}
    66  
    67  	for _, tt := range tests {
    68  		t.Run(tt.name, func(t *testing.T) {
    69  			include, err := validateExemplarType(tt.exemplarType)
    70  			if tt.expectedErrorMsg != "" {
    71  				require.Error(t, err)
    72  				assert.Contains(t, err.Error(), tt.expectedErrorMsg)
    73  				st, ok := status.FromError(err)
    74  				require.True(t, ok)
    75  				assert.Equal(t, tt.expectedCode, st.Code())
    76  			} else {
    77  				require.NoError(t, err)
    78  				assert.Equal(t, tt.expectedInclude, include)
    79  			}
    80  		})
    81  	}
    82  }
    83  
    84  type benchmarkFixture struct {
    85  	ctx       context.Context
    86  	reader    *BlockReader
    87  	plan      *queryv1.QueryPlan
    88  	tenant    []string
    89  	startTime time.Time
    90  }
    91  
    92  // setupBenchmarkFixture creates a benchmark fixture with real block data.
    93  func setupBenchmarkFixture(b *testing.B) *benchmarkFixture {
    94  	b.Helper()
    95  
    96  	bucket := memory.NewInMemBucket()
    97  	var blocks []*metastorev1.BlockMeta
    98  
    99  	err := filepath.WalkDir("testdata/samples", func(path string, e os.DirEntry, err error) error {
   100  		if err != nil || e.IsDir() {
   101  			return err
   102  		}
   103  		data, err := os.ReadFile(path)
   104  		if err != nil {
   105  			return err
   106  		}
   107  		var md metastorev1.BlockMeta
   108  		if err = metadata.Decode(data, &md); err != nil {
   109  			return err
   110  		}
   111  		md.Size = uint64(len(data))
   112  		blocks = append(blocks, &md)
   113  		return bucket.Upload(context.Background(), block.ObjectPath(&md), bytes.NewReader(data))
   114  	})
   115  	if err != nil {
   116  		b.Fatalf("failed to load test data: %v", err)
   117  	}
   118  
   119  	logger := test.NewTestingLogger(b)
   120  	reader := NewBlockReader(logger, &objstore.ReaderAtBucket{Bucket: bucket}, nil)
   121  
   122  	meta := make([]*metastorev1.BlockMeta, len(blocks))
   123  	for i, block := range blocks {
   124  		meta[i] = block.CloneVT()
   125  	}
   126  	sanitizeMetadata(meta)
   127  
   128  	plan := queryplan.Build(meta, 10, 10)
   129  
   130  	var tenant []string
   131  	for _, b := range plan.Root.Blocks {
   132  		for _, d := range b.Datasets {
   133  			tenant = append(tenant, b.StringTable[d.Tenant])
   134  		}
   135  	}
   136  
   137  	// Extract start time from blocks
   138  	var minTime int64 = -1
   139  	for _, block := range blocks {
   140  		if minTime == -1 || block.MinTime < minTime {
   141  			minTime = block.MinTime
   142  		}
   143  	}
   144  	startTime := time.UnixMilli(minTime)
   145  
   146  	return &benchmarkFixture{
   147  		ctx:       context.Background(),
   148  		reader:    reader,
   149  		plan:      plan,
   150  		tenant:    tenant,
   151  		startTime: startTime,
   152  	}
   153  }
   154  
   155  // sanitizeMetadata removes duplicate datasets (logic from testSuite.sanitizeMetadata)
   156  func sanitizeMetadata(meta []*metastorev1.BlockMeta) {
   157  	for _, m := range meta {
   158  		for _, d := range m.Datasets {
   159  			if block.DatasetFormat(d.Format) == block.DatasetFormat1 {
   160  				m.Datasets = slices.DeleteFunc(m.Datasets, func(x *metastorev1.Dataset) bool {
   161  					return x.Format == 0
   162  				})
   163  				break
   164  			}
   165  		}
   166  	}
   167  }
   168  
   169  // runTimeSeriesQuery executes a timeseries query with the given parameters.
   170  func (f *benchmarkFixture) runTimeSeriesQuery(b *testing.B, req *queryv1.InvokeRequest) {
   171  	b.Helper()
   172  	resp, err := f.reader.Invoke(f.ctx, req)
   173  	if err != nil {
   174  		b.Fatalf("query failed: %v", err)
   175  	}
   176  	for _, r := range resp.Reports {
   177  		if r.ReportType != queryv1.ReportType_REPORT_TIME_SERIES {
   178  			continue
   179  		}
   180  		for _, s := range r.TimeSeries.TimeSeries {
   181  			for _, p := range s.Points {
   182  				if p.Value > 0 {
   183  					return
   184  				}
   185  			}
   186  		}
   187  	}
   188  	panic("no data found")
   189  }
   190  
   191  // makeTimeSeriesRequest creates a timeseries query request with the given parameters.
   192  func (f *benchmarkFixture) makeTimeSeriesRequest(
   193  	startTime, endTime time.Time,
   194  	labelSelector string,
   195  	groupBy []string,
   196  	exemplarType typesv1.ExemplarType,
   197  ) *queryv1.InvokeRequest {
   198  	return &queryv1.InvokeRequest{
   199  		StartTime:     startTime.UnixMilli(),
   200  		EndTime:       endTime.UnixMilli(),
   201  		LabelSelector: labelSelector,
   202  		QueryPlan:     f.plan,
   203  		Query: []*queryv1.Query{{
   204  			QueryType: queryv1.QueryType_QUERY_TIME_SERIES,
   205  			TimeSeries: &queryv1.TimeSeriesQuery{
   206  				Step:         60.0, // 1 minute resolution
   207  				GroupBy:      groupBy,
   208  				ExemplarType: exemplarType,
   209  			},
   210  		}},
   211  		Tenant: f.tenant,
   212  	}
   213  }
   214  
   215  // BenchmarkTimeSeriesQuery measures the performance impact of exemplar collection.
   216  //
   217  //	go test -bench=BenchmarkTimeSeriesQuery$ -benchmem ./pkg/querybackend/
   218  //
   219  // Expected results: Exemplar overhead should be < 30% for typical queries.
   220  func BenchmarkTimeSeriesQuery(b *testing.B) {
   221  	fixture := setupBenchmarkFixture(b)
   222  
   223  	benchmarks := []struct {
   224  		name         string
   225  		exemplarType typesv1.ExemplarType
   226  	}{
   227  		{"NoExemplars", typesv1.ExemplarType_EXEMPLAR_TYPE_NONE},
   228  		{"WithExemplars", typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL},
   229  	}
   230  
   231  	for _, bm := range benchmarks {
   232  		b.Run(bm.name, func(b *testing.B) {
   233  			req := fixture.makeTimeSeriesRequest(
   234  				fixture.startTime, fixture.startTime.Add(time.Hour),
   235  				"{}",
   236  				[]string{"service_name"},
   237  				bm.exemplarType,
   238  			)
   239  
   240  			b.ResetTimer()
   241  			for i := 0; i < b.N; i++ {
   242  				fixture.runTimeSeriesQuery(b, req)
   243  			}
   244  		})
   245  	}
   246  }
   247  
   248  // BenchmarkTimeSeriesQuery_TimeRange measures how performance scales with time range.
   249  //
   250  // This tests whether exemplar overhead grows linearly or non-linearly with data size.
   251  // Run with:
   252  //
   253  //	go test -bench=BenchmarkTimeSeriesQuery_TimeRange -benchmem ./pkg/querybackend/
   254  //
   255  // Expected results: Overhead ratio should remain constant across time ranges.
   256  func BenchmarkTimeSeriesQuery_TimeRange(b *testing.B) {
   257  	fixture := setupBenchmarkFixture(b)
   258  
   259  	timeRanges := []struct {
   260  		name     string
   261  		duration time.Duration
   262  	}{
   263  		{"1Minute", 1 * time.Minute},
   264  		{"5Minutes", 5 * time.Minute},
   265  		{"15Minutes", 15 * time.Minute},
   266  		{"1Hour", 1 * time.Hour},
   267  	}
   268  
   269  	exemplarTypes := []struct {
   270  		name string
   271  		typ  typesv1.ExemplarType
   272  	}{
   273  		{"NoExemplars", typesv1.ExemplarType_EXEMPLAR_TYPE_NONE},
   274  		{"WithExemplars", typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL},
   275  	}
   276  
   277  	for _, tr := range timeRanges {
   278  		b.Run(tr.name, func(b *testing.B) {
   279  			for _, et := range exemplarTypes {
   280  				b.Run(et.name, func(b *testing.B) {
   281  					req := fixture.makeTimeSeriesRequest(
   282  						fixture.startTime, fixture.startTime.Add(tr.duration),
   283  						"{}",
   284  						[]string{"service_name"},
   285  						et.typ,
   286  					)
   287  
   288  					b.ResetTimer()
   289  					for i := 0; i < b.N; i++ {
   290  						fixture.runTimeSeriesQuery(b, req)
   291  					}
   292  				})
   293  			}
   294  		})
   295  	}
   296  }