prometheus/tsdb/ooo_head_read_test.go
Oleg Zaytsev ed172a6667
Optimize label values with matchers by taking shortcuts (#13426)
Don't calculate postings beforehand: we may not need them. If all
matchers are for the requested label, we can just filter its values.

Also, if there are no values at all, no need to run any kind of
logic.

Also add more labelValuesWithMatchers benchmarks

Signed-off-by: Oleg Zaytsev <mail@olegzaytsev.com>
2024-01-23 11:40:21 +01:00

1210 lines
44 KiB
Go

// Copyright 2022 The Prometheus 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 tsdb
import (
"context"
"fmt"
"math"
"sort"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/tsdb/chunkenc"
"github.com/prometheus/prometheus/tsdb/chunks"
"github.com/prometheus/prometheus/tsdb/wlog"
)
type chunkInterval struct {
// because we permutate the order of chunks, we cannot determine at test declaration time which chunkRefs we expect in the Output.
// This ID matches expected output chunks against test input chunks, the test runner will assert the chunkRef for the matching chunk
ID int
mint int64
maxt int64
}
// permutateChunkIntervals returns all possible orders of the given chunkIntervals.
func permutateChunkIntervals(in []chunkInterval, out [][]chunkInterval, left, right int) [][]chunkInterval {
if left == right {
inCopy := make([]chunkInterval, len(in))
copy(inCopy, in)
return append(out, inCopy)
}
for i := left; i <= right; i++ {
in[left], in[i] = in[i], in[left]
out = permutateChunkIntervals(in, out, left+1, right)
in[left], in[i] = in[i], in[left]
}
return out
}
// TestOOOHeadIndexReader_Series tests that the Series method works as expected.
// However it does so by creating chunks and memory mapping them unlike other
// tests of the head where samples are appended and we let the head memory map.
// We do this because the ingestion path and the appender for out of order
// samples are not ready yet.
func TestOOOHeadIndexReader_Series(t *testing.T) {
tests := []struct {
name string
queryMinT int64
queryMaxT int64
inputChunkIntervals []chunkInterval
expChunks []chunkInterval
}{
{
name: "Empty result and no error when head is empty",
queryMinT: 0,
queryMaxT: 100,
expChunks: nil,
},
{
name: "If query interval is bigger than the existing chunks nothing is returned",
queryMinT: 500,
queryMaxT: 700,
inputChunkIntervals: []chunkInterval{
{0, 100, 400},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [---------------------------------------]
// Chunk 0 [-----------------------------------------------------------]
expChunks: nil,
},
{
name: "If query interval is smaller than the existing chunks nothing is returned",
queryMinT: 100,
queryMaxT: 400,
inputChunkIntervals: []chunkInterval{
{0, 500, 700},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [-----------------------------------------------------------]
// Chunk 0: [---------------------------------------]
expChunks: nil,
},
{
name: "If query interval exceeds the existing chunk, it is returned",
queryMinT: 100,
queryMaxT: 400,
inputChunkIntervals: []chunkInterval{
{0, 150, 350},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [-----------------------------------------------------------]
// Chunk 0: [---------------------------------------]
expChunks: []chunkInterval{
{0, 150, 350},
},
},
{
name: "If chunk exceeds the query interval, it is returned",
queryMinT: 150,
queryMaxT: 350,
inputChunkIntervals: []chunkInterval{
{0, 100, 400},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval: [---------------------------------------]
// Chunk 0: [-----------------------------------------------------------]
expChunks: []chunkInterval{
{0, 100, 400},
},
},
{
name: "Pairwise overlaps should return the references of the first of each pair",
queryMinT: 0,
queryMaxT: 700,
inputChunkIntervals: []chunkInterval{
{0, 100, 200},
{1, 500, 600},
{2, 150, 250},
{3, 550, 650},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [---------------------------------------------------------------------------------------------------------------------------------]
// Chunk 0: [-------------------]
// Chunk 1: [-------------------]
// Chunk 2: [-------------------]
// Chunk 3: [-------------------]
// Output Graphically [-----------------------------] [-----------------------------]
expChunks: []chunkInterval{
{0, 100, 250},
{1, 500, 650},
},
},
{
name: "If all chunks overlap, single big chunk is returned",
queryMinT: 0,
queryMaxT: 700,
inputChunkIntervals: []chunkInterval{
{0, 100, 200},
{1, 200, 300},
{2, 300, 400},
{3, 400, 500},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [---------------------------------------------------------------------------------------------------------------------------------]
// Chunk 0: [-------------------]
// Chunk 1: [-------------------]
// Chunk 2: [-------------------]
// Chunk 3: [------------------]
// Output Graphically [------------------------------------------------------------------------------]
expChunks: []chunkInterval{
{0, 100, 500},
},
},
{
name: "If no chunks overlap, all chunks are returned",
queryMinT: 0,
queryMaxT: 700,
inputChunkIntervals: []chunkInterval{
{0, 100, 199},
{1, 200, 299},
{2, 300, 399},
{3, 400, 499},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [---------------------------------------------------------------------------------------------------------------------------------]
// Chunk 0: [------------------]
// Chunk 1: [------------------]
// Chunk 2: [------------------]
// Chunk 3: [------------------]
// Output Graphically [------------------][------------------][------------------][------------------]
expChunks: []chunkInterval{
{0, 100, 199},
{1, 200, 299},
{2, 300, 399},
{3, 400, 499},
},
},
{
name: "Triplet with pairwise overlaps, query range covers all, and distractor extra chunk",
queryMinT: 0,
queryMaxT: 400,
inputChunkIntervals: []chunkInterval{
{0, 100, 200},
{1, 150, 300},
{2, 250, 350},
{3, 450, 550},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [--------------------------------------------------------------------]
// Chunk 0: [------------------]
// Chunk 1: [-----------------------------]
// Chunk 2: [------------------]
// Chunk 3: [------------------]
// Output Graphically [-----------------------------------------------]
expChunks: []chunkInterval{
{0, 100, 350},
},
},
{
name: "Query interval partially overlaps some chunks",
queryMinT: 100,
queryMaxT: 400,
inputChunkIntervals: []chunkInterval{
{0, 250, 500},
{1, 0, 200},
{2, 150, 300},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700
// Query Interval [------------------------------------------------------------]
// Chunk 0: [-------------------------------------------------]
// Chunk 1: [-----------------------------]
// Chunk 2: [------------------------------]
// Output Graphically [-----------------------------------------------------------------------------------------]
expChunks: []chunkInterval{
{1, 0, 500},
},
},
{
name: "A full overlap pair and disjointed triplet",
queryMinT: 0,
queryMaxT: 900,
inputChunkIntervals: []chunkInterval{
{0, 100, 300},
{1, 770, 850},
{2, 150, 250},
{3, 650, 750},
{4, 600, 800},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700 750 800 850
// Query Interval [---------------------------------------------------------------------------------------------------------------------------------------------------------------]
// Chunk 0: [---------------------------------------]
// Chunk 1: [--------------]
// Chunk 2: [-------------------]
// Chunk 3: [-------------------]
// Chunk 4: [---------------------------------------]
// Output Graphically [---------------------------------------] [------------------------------------------------]
expChunks: []chunkInterval{
{0, 100, 300},
{4, 600, 850},
},
},
{
name: "Query range covers 3 disjoint chunks",
queryMinT: 0,
queryMaxT: 650,
inputChunkIntervals: []chunkInterval{
{0, 100, 150},
{1, 300, 350},
{2, 200, 250},
},
// ts 0 100 150 200 250 300 350 400 450 500 550 600 650 700 750 800 850
// Query Interval [----------------------------------------------------------------------------------------------------------------------]
// Chunk 0: [-------]
// Chunk 1: [----------]
// Chunk 2: [--------]
// Output Graphically [-------] [--------] [----------]
expChunks: []chunkInterval{
{0, 100, 150},
{1, 300, 350},
{2, 200, 250},
},
},
}
s1Lset := labels.FromStrings("foo", "bar")
s1ID := uint64(1)
for _, tc := range tests {
var permutations [][]chunkInterval
if len(tc.inputChunkIntervals) == 0 {
// handle special case
permutations = [][]chunkInterval{
nil,
}
} else {
permutations = permutateChunkIntervals(tc.inputChunkIntervals, nil, 0, len(tc.inputChunkIntervals)-1)
}
for perm, intervals := range permutations {
for _, headChunk := range []bool{false, true} {
t.Run(fmt.Sprintf("name=%s, permutation=%d, headChunk=%t", tc.name, perm, headChunk), func(t *testing.T) {
h, _ := newTestHead(t, 1000, wlog.CompressionNone, true)
defer func() {
require.NoError(t, h.Close())
}()
require.NoError(t, h.Init(0))
s1, _, _ := h.getOrCreate(s1ID, s1Lset)
s1.ooo = &memSeriesOOOFields{}
var lastChunk chunkInterval
var lastChunkPos int
// the marker should be set based on whichever is the last chunk/interval that overlaps with the query range
for i, interv := range intervals {
if overlapsClosedInterval(interv.mint, interv.maxt, tc.queryMinT, tc.queryMaxT) {
lastChunk = interv
lastChunkPos = i
}
}
lastChunkRef := chunks.ChunkRef(chunks.NewHeadChunkRef(1, chunks.HeadChunkID(uint64(lastChunkPos))))
// define our expected chunks, by looking at the expected ChunkIntervals and setting...
var expChunks []chunks.Meta
for _, e := range tc.expChunks {
meta := chunks.Meta{
Chunk: chunkenc.Chunk(nil),
MinTime: e.mint,
MaxTime: e.maxt,
// markers based on the last chunk we found above
OOOLastMinTime: lastChunk.mint,
OOOLastMaxTime: lastChunk.maxt,
OOOLastRef: lastChunkRef,
}
// Ref to whatever Ref the chunk has, that we refer to by ID
for ref, c := range intervals {
if c.ID == e.ID {
meta.Ref = chunks.ChunkRef(chunks.NewHeadChunkRef(chunks.HeadSeriesRef(s1ID), chunks.HeadChunkID(ref)))
break
}
}
expChunks = append(expChunks, meta)
}
slices.SortFunc(expChunks, lessByMinTimeAndMinRef) // We always want the chunks to come back sorted by minTime asc.
if headChunk && len(intervals) > 0 {
// Put the last interval in the head chunk
s1.ooo.oooHeadChunk = &oooHeadChunk{
minTime: intervals[len(intervals)-1].mint,
maxTime: intervals[len(intervals)-1].maxt,
}
intervals = intervals[:len(intervals)-1]
}
for _, ic := range intervals {
s1.ooo.oooMmappedChunks = append(s1.ooo.oooMmappedChunks, &mmappedChunk{
minTime: ic.mint,
maxTime: ic.maxt,
})
}
ir := NewOOOHeadIndexReader(h, tc.queryMinT, tc.queryMaxT, 0)
var chks []chunks.Meta
var b labels.ScratchBuilder
err := ir.Series(storage.SeriesRef(s1ID), &b, &chks)
require.NoError(t, err)
require.Equal(t, s1Lset, b.Labels())
require.Equal(t, expChunks, chks)
err = ir.Series(storage.SeriesRef(s1ID+1), &b, &chks)
require.Equal(t, storage.ErrNotFound, err)
})
}
}
}
}
func TestOOOHeadChunkReader_LabelValues(t *testing.T) {
chunkRange := int64(2000)
head, _ := newTestHead(t, chunkRange, wlog.CompressionNone, true)
t.Cleanup(func() { require.NoError(t, head.Close()) })
ctx := context.Background()
app := head.Appender(context.Background())
// Add in-order samples
_, err := app.Append(0, labels.FromStrings("foo", "bar1"), 100, 1)
require.NoError(t, err)
_, err = app.Append(0, labels.FromStrings("foo", "bar2"), 100, 2)
require.NoError(t, err)
// Add ooo samples for those series
_, err = app.Append(0, labels.FromStrings("foo", "bar1"), 90, 1)
require.NoError(t, err)
_, err = app.Append(0, labels.FromStrings("foo", "bar2"), 90, 2)
require.NoError(t, err)
require.NoError(t, app.Commit())
cases := []struct {
name string
queryMinT int64
queryMaxT int64
expValues1 []string
expValues2 []string
expValues3 []string
expValues4 []string
}{
{
name: "LabelValues calls when ooo head has max query range",
queryMinT: math.MinInt64,
queryMaxT: math.MaxInt64,
expValues1: []string{"bar1"},
expValues2: nil,
expValues3: []string{"bar1", "bar2"},
expValues4: []string{"bar1", "bar2"},
},
{
name: "LabelValues calls with ooo head query range not overlapping in-order data",
queryMinT: 90,
queryMaxT: 90,
expValues1: []string{"bar1"},
expValues2: nil,
expValues3: []string{"bar1", "bar2"},
expValues4: []string{"bar1", "bar2"},
},
{
name: "LabelValues calls with ooo head query range not overlapping out-of-order data",
queryMinT: 100,
queryMaxT: 100,
expValues1: []string{},
expValues2: []string{},
expValues3: []string{},
expValues4: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// We first want to test using a head index reader that covers the biggest query interval
oh := NewOOOHeadIndexReader(head, tc.queryMinT, tc.queryMaxT, 0)
matchers := []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "foo", "bar1")}
values, err := oh.LabelValues(ctx, "foo", matchers...)
sort.Strings(values)
require.NoError(t, err)
require.Equal(t, tc.expValues1, values)
matchers = []*labels.Matcher{labels.MustNewMatcher(labels.MatchNotRegexp, "foo", "^bar.")}
values, err = oh.LabelValues(ctx, "foo", matchers...)
sort.Strings(values)
require.NoError(t, err)
require.Equal(t, tc.expValues2, values)
matchers = []*labels.Matcher{labels.MustNewMatcher(labels.MatchRegexp, "foo", "bar.")}
values, err = oh.LabelValues(ctx, "foo", matchers...)
sort.Strings(values)
require.NoError(t, err)
require.Equal(t, tc.expValues3, values)
values, err = oh.LabelValues(ctx, "foo")
sort.Strings(values)
require.NoError(t, err)
require.Equal(t, tc.expValues4, values)
})
}
}
// TestOOOHeadChunkReader_Chunk tests that the Chunk method works as expected.
// It does so by appending out of order samples to the db and then initializing
// an OOOHeadChunkReader to read chunks from it.
func TestOOOHeadChunkReader_Chunk(t *testing.T) {
opts := DefaultOptions()
opts.OutOfOrderCapMax = 5
opts.OutOfOrderTimeWindow = 120 * time.Minute.Milliseconds()
s1 := labels.FromStrings("l", "v1")
minutes := func(m int64) int64 { return m * time.Minute.Milliseconds() }
appendSample := func(app storage.Appender, l labels.Labels, timestamp int64, value float64) storage.SeriesRef {
ref, err := app.Append(0, l, timestamp, value)
require.NoError(t, err)
return ref
}
t.Run("Getting a non existing chunk fails with not found error", func(t *testing.T) {
db := newTestDBWithOpts(t, opts)
cr := NewOOOHeadChunkReader(db.head, 0, 1000, nil)
defer cr.Close()
c, iterable, err := cr.ChunkOrIterable(chunks.Meta{
Ref: 0x1000000, Chunk: chunkenc.Chunk(nil), MinTime: 100, MaxTime: 300,
})
require.Nil(t, iterable)
require.Equal(t, err, fmt.Errorf("not found"))
require.Nil(t, c)
})
tests := []struct {
name string
queryMinT int64
queryMaxT int64
firstInOrderSampleAt int64
inputSamples chunks.SampleSlice
expChunkError bool
expChunksSamples []chunks.SampleSlice
}{
{
name: "Getting the head when there are no overlapping chunks returns just the samples in the head",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
sample{t: minutes(30), f: float64(0)},
sample{t: minutes(40), f: float64(0)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0: Current Head [--------] (With 2 samples)
// Output Graphically [--------] (With 2 samples)
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(30), f: float64(0)},
sample{t: minutes(40), f: float64(0)},
},
},
},
{
name: "Getting the head chunk when there are overlapping chunks returns all combined",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// opts.OOOCapMax is 5 so these will be mmapped to the first mmapped chunk
sample{t: minutes(41), f: float64(0)},
sample{t: minutes(42), f: float64(0)},
sample{t: minutes(43), f: float64(0)},
sample{t: minutes(44), f: float64(0)},
sample{t: minutes(45), f: float64(0)},
// The following samples will go to the head chunk, and we want it
// to overlap with the previous chunk
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(50), f: float64(1)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0 [---] (With 5 samples)
// Chunk 1: Current Head [-----------------] (With 2 samples)
// Output Graphically [-----------------] (With 7 samples)
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(41), f: float64(0)},
sample{t: minutes(42), f: float64(0)},
sample{t: minutes(43), f: float64(0)},
sample{t: minutes(44), f: float64(0)},
sample{t: minutes(45), f: float64(0)},
sample{t: minutes(50), f: float64(1)},
},
},
},
{
name: "Two windows of overlapping chunks get properly converged",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(12), f: float64(0)},
sample{t: minutes(14), f: float64(0)},
sample{t: minutes(16), f: float64(0)},
sample{t: minutes(20), f: float64(0)},
// Chunk 1
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(22), f: float64(1)},
sample{t: minutes(24), f: float64(1)},
sample{t: minutes(26), f: float64(1)},
sample{t: minutes(29), f: float64(1)},
// Chunk 2
sample{t: minutes(30), f: float64(2)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(34), f: float64(2)},
sample{t: minutes(36), f: float64(2)},
sample{t: minutes(40), f: float64(2)},
// Head
sample{t: minutes(40), f: float64(3)},
sample{t: minutes(50), f: float64(3)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0 [--------]
// Chunk 1 [-------]
// Chunk 2 [--------]
// Chunk 3: Current Head [--------]
// Output Graphically [----------------][-----------------]
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(12), f: float64(0)},
sample{t: minutes(14), f: float64(0)},
sample{t: minutes(16), f: float64(0)},
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(22), f: float64(1)},
sample{t: minutes(24), f: float64(1)},
sample{t: minutes(26), f: float64(1)},
sample{t: minutes(29), f: float64(1)},
},
{
sample{t: minutes(30), f: float64(2)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(34), f: float64(2)},
sample{t: minutes(36), f: float64(2)},
sample{t: minutes(40), f: float64(3)},
sample{t: minutes(50), f: float64(3)},
},
},
},
{
name: "Two windows of overlapping chunks in descending order get properly converged",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(40), f: float64(0)},
sample{t: minutes(42), f: float64(0)},
sample{t: minutes(44), f: float64(0)},
sample{t: minutes(46), f: float64(0)},
sample{t: minutes(50), f: float64(0)},
// Chunk 1
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(32), f: float64(1)},
sample{t: minutes(34), f: float64(1)},
sample{t: minutes(36), f: float64(1)},
sample{t: minutes(40), f: float64(1)},
// Chunk 2
sample{t: minutes(20), f: float64(2)},
sample{t: minutes(22), f: float64(2)},
sample{t: minutes(24), f: float64(2)},
sample{t: minutes(26), f: float64(2)},
sample{t: minutes(29), f: float64(2)},
// Head
sample{t: minutes(10), f: float64(3)},
sample{t: minutes(20), f: float64(3)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0 [--------]
// Chunk 1 [--------]
// Chunk 2 [-------]
// Chunk 3: Current Head [--------]
// Output Graphically [----------------][-----------------]
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(10), f: float64(3)},
sample{t: minutes(20), f: float64(2)},
sample{t: minutes(22), f: float64(2)},
sample{t: minutes(24), f: float64(2)},
sample{t: minutes(26), f: float64(2)},
sample{t: minutes(29), f: float64(2)},
},
{
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(32), f: float64(1)},
sample{t: minutes(34), f: float64(1)},
sample{t: minutes(36), f: float64(1)},
sample{t: minutes(40), f: float64(0)},
sample{t: minutes(42), f: float64(0)},
sample{t: minutes(44), f: float64(0)},
sample{t: minutes(46), f: float64(0)},
sample{t: minutes(50), f: float64(0)},
},
},
},
{
name: "If chunks are not overlapped they are not converged",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(12), f: float64(0)},
sample{t: minutes(14), f: float64(0)},
sample{t: minutes(16), f: float64(0)},
sample{t: minutes(18), f: float64(0)},
// Chunk 1
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(22), f: float64(1)},
sample{t: minutes(24), f: float64(1)},
sample{t: minutes(26), f: float64(1)},
sample{t: minutes(28), f: float64(1)},
// Chunk 2
sample{t: minutes(30), f: float64(2)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(34), f: float64(2)},
sample{t: minutes(36), f: float64(2)},
sample{t: minutes(38), f: float64(2)},
// Head
sample{t: minutes(40), f: float64(3)},
sample{t: minutes(42), f: float64(3)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0 [-------]
// Chunk 1 [-------]
// Chunk 2 [-------]
// Chunk 3: Current Head [-------]
// Output Graphically [-------][-------][-------][--------]
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(12), f: float64(0)},
sample{t: minutes(14), f: float64(0)},
sample{t: minutes(16), f: float64(0)},
sample{t: minutes(18), f: float64(0)},
},
{
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(22), f: float64(1)},
sample{t: minutes(24), f: float64(1)},
sample{t: minutes(26), f: float64(1)},
sample{t: minutes(28), f: float64(1)},
},
{
sample{t: minutes(30), f: float64(2)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(34), f: float64(2)},
sample{t: minutes(36), f: float64(2)},
sample{t: minutes(38), f: float64(2)},
},
{
sample{t: minutes(40), f: float64(3)},
sample{t: minutes(42), f: float64(3)},
},
},
},
{
name: "Triplet of chunks overlapping returns a single merged chunk",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(15), f: float64(0)},
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(25), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
// Chunk 1
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(35), f: float64(1)},
sample{t: minutes(42), f: float64(1)},
// Chunk 2 Head
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(50), f: float64(2)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------------------------------------------------------------------------------]
// Chunk 0 [-----------------]
// Chunk 1 [--------------------]
// Chunk 2 Current Head [--------------]
// Output Graphically [-----------------------------------]
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(15), f: float64(0)},
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(35), f: float64(1)},
sample{t: minutes(42), f: float64(1)},
sample{t: minutes(50), f: float64(2)},
},
},
},
{
name: "Query interval partially overlaps with a triplet of chunks but still returns a single merged chunk",
queryMinT: minutes(12),
queryMaxT: minutes(33),
firstInOrderSampleAt: minutes(120),
inputSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(15), f: float64(0)},
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(25), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
// Chunk 1
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(35), f: float64(1)},
sample{t: minutes(42), f: float64(1)},
// Chunk 2 Head
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(50), f: float64(2)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [------------------]
// Chunk 0 [-----------------]
// Chunk 1 [--------------------]
// Chunk 2 Current Head [--------------]
// Output Graphically [-----------------------------------]
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(10), f: float64(0)},
sample{t: minutes(15), f: float64(0)},
sample{t: minutes(20), f: float64(1)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(30), f: float64(1)},
sample{t: minutes(32), f: float64(2)},
sample{t: minutes(35), f: float64(1)},
sample{t: minutes(42), f: float64(1)},
sample{t: minutes(50), f: float64(2)},
},
},
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("name=%s", tc.name), func(t *testing.T) {
db := newTestDBWithOpts(t, opts)
app := db.Appender(context.Background())
s1Ref := appendSample(app, s1, tc.firstInOrderSampleAt, float64(tc.firstInOrderSampleAt/1*time.Minute.Milliseconds()))
require.NoError(t, app.Commit())
// OOO few samples for s1.
app = db.Appender(context.Background())
for _, s := range tc.inputSamples {
appendSample(app, s1, s.T(), s.F())
}
require.NoError(t, app.Commit())
// The Series method is the one that populates the chunk meta OOO
// markers like OOOLastRef. These are then used by the ChunkReader.
ir := NewOOOHeadIndexReader(db.head, tc.queryMinT, tc.queryMaxT, 0)
var chks []chunks.Meta
var b labels.ScratchBuilder
err := ir.Series(s1Ref, &b, &chks)
require.NoError(t, err)
require.Equal(t, len(tc.expChunksSamples), len(chks))
cr := NewOOOHeadChunkReader(db.head, tc.queryMinT, tc.queryMaxT, nil)
defer cr.Close()
for i := 0; i < len(chks); i++ {
c, iterable, err := cr.ChunkOrIterable(chks[i])
require.NoError(t, err)
require.Nil(t, c)
var resultSamples chunks.SampleSlice
it := iterable.Iterator(nil)
for it.Next() == chunkenc.ValFloat {
t, v := it.At()
resultSamples = append(resultSamples, sample{t: t, f: v})
}
require.Equal(t, tc.expChunksSamples[i], resultSamples)
}
})
}
}
// TestOOOHeadChunkReader_Chunk_ConsistentQueryResponseDespiteOfHeadExpanding tests
// that if a query comes and performs a Series() call followed by a Chunks() call
// the response is consistent with the data seen by Series() even if the OOO
// head receives more samples before Chunks() is called.
// An example:
// - Response A comes from: Series() then Chunk()
// - Response B comes from : Series(), in parallel new samples added to the head, then Chunk()
// - A == B
func TestOOOHeadChunkReader_Chunk_ConsistentQueryResponseDespiteOfHeadExpanding(t *testing.T) {
opts := DefaultOptions()
opts.OutOfOrderCapMax = 5
opts.OutOfOrderTimeWindow = 120 * time.Minute.Milliseconds()
s1 := labels.FromStrings("l", "v1")
minutes := func(m int64) int64 { return m * time.Minute.Milliseconds() }
appendSample := func(app storage.Appender, l labels.Labels, timestamp int64, value float64) storage.SeriesRef {
ref, err := app.Append(0, l, timestamp, value)
require.NoError(t, err)
return ref
}
tests := []struct {
name string
queryMinT int64
queryMaxT int64
firstInOrderSampleAt int64
initialSamples chunks.SampleSlice
samplesAfterSeriesCall chunks.SampleSlice
expChunkError bool
expChunksSamples []chunks.SampleSlice
}{
{
name: "Current head gets old, new and in between sample after Series call, they all should be omitted from the result",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
initialSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(22), f: float64(0)},
sample{t: minutes(24), f: float64(0)},
sample{t: minutes(26), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
// Chunk 1 Head
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(35), f: float64(1)},
},
samplesAfterSeriesCall: chunks.SampleSlice{
sample{t: minutes(10), f: float64(1)},
sample{t: minutes(32), f: float64(1)},
sample{t: minutes(50), f: float64(1)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [-----------------------------------]
// Chunk 0: [--------] (5 samples)
// Chunk 1: Current Head [-------] (2 samples)
// New samples added after Series()
// Chunk 1: Current Head [-----------------------------------] (5 samples)
// Output Graphically [------------] (With 8 samples, samples newer than lastmint or older than lastmaxt are omitted but the ones in between are kept)
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(22), f: float64(0)},
sample{t: minutes(24), f: float64(0)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(26), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
sample{t: minutes(32), f: float64(1)}, // This sample was added after Series() but before Chunk() and its in between the lastmint and maxt so it should be kept
sample{t: minutes(35), f: float64(1)},
},
},
},
{
name: "After Series() previous head gets mmapped after getting samples, new head gets new samples also overlapping, none of these should appear in the response.",
queryMinT: minutes(0),
queryMaxT: minutes(100),
firstInOrderSampleAt: minutes(120),
initialSamples: chunks.SampleSlice{
// Chunk 0
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(22), f: float64(0)},
sample{t: minutes(24), f: float64(0)},
sample{t: minutes(26), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
// Chunk 1 Head
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(35), f: float64(1)},
},
samplesAfterSeriesCall: chunks.SampleSlice{
sample{t: minutes(10), f: float64(1)},
sample{t: minutes(32), f: float64(1)},
sample{t: minutes(50), f: float64(1)},
// Chunk 1 gets mmapped and Chunk 2, the new head is born
sample{t: minutes(25), f: float64(2)},
sample{t: minutes(31), f: float64(2)},
},
expChunkError: false,
// ts (in minutes) 0 10 20 30 40 50 60 70 80 90 100
// Query Interval [-----------------------------------]
// Chunk 0: [--------] (5 samples)
// Chunk 1: Current Head [-------] (2 samples)
// New samples added after Series()
// Chunk 1 (mmapped) [-------------------------] (5 samples)
// Chunk 2: Current Head [-----------] (2 samples)
// Output Graphically [------------] (8 samples) It has 5 from Chunk 0 and 3 from Chunk 1
expChunksSamples: []chunks.SampleSlice{
{
sample{t: minutes(20), f: float64(0)},
sample{t: minutes(22), f: float64(0)},
sample{t: minutes(24), f: float64(0)},
sample{t: minutes(25), f: float64(1)},
sample{t: minutes(26), f: float64(0)},
sample{t: minutes(30), f: float64(0)},
sample{t: minutes(32), f: float64(1)}, // This sample was added after Series() but before Chunk() and its in between the lastmint and maxt so it should be kept
sample{t: minutes(35), f: float64(1)},
},
},
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("name=%s", tc.name), func(t *testing.T) {
db := newTestDBWithOpts(t, opts)
app := db.Appender(context.Background())
s1Ref := appendSample(app, s1, tc.firstInOrderSampleAt, float64(tc.firstInOrderSampleAt/1*time.Minute.Milliseconds()))
require.NoError(t, app.Commit())
// OOO few samples for s1.
app = db.Appender(context.Background())
for _, s := range tc.initialSamples {
appendSample(app, s1, s.T(), s.F())
}
require.NoError(t, app.Commit())
// The Series method is the one that populates the chunk meta OOO
// markers like OOOLastRef. These are then used by the ChunkReader.
ir := NewOOOHeadIndexReader(db.head, tc.queryMinT, tc.queryMaxT, 0)
var chks []chunks.Meta
var b labels.ScratchBuilder
err := ir.Series(s1Ref, &b, &chks)
require.NoError(t, err)
require.Equal(t, len(tc.expChunksSamples), len(chks))
// Now we keep receiving ooo samples
// OOO few samples for s1.
app = db.Appender(context.Background())
for _, s := range tc.samplesAfterSeriesCall {
appendSample(app, s1, s.T(), s.F())
}
require.NoError(t, app.Commit())
cr := NewOOOHeadChunkReader(db.head, tc.queryMinT, tc.queryMaxT, nil)
defer cr.Close()
for i := 0; i < len(chks); i++ {
c, iterable, err := cr.ChunkOrIterable(chks[i])
require.NoError(t, err)
require.Nil(t, c)
var resultSamples chunks.SampleSlice
it := iterable.Iterator(nil)
for it.Next() == chunkenc.ValFloat {
ts, v := it.At()
resultSamples = append(resultSamples, sample{t: ts, f: v})
}
require.Equal(t, tc.expChunksSamples[i], resultSamples)
}
})
}
}
// TestSortByMinTimeAndMinRef tests that the sort function for chunk metas does sort
// by chunk meta MinTime and in case of same references by the lower reference.
func TestSortByMinTimeAndMinRef(t *testing.T) {
tests := []struct {
name string
input []chunkMetaAndChunkDiskMapperRef
exp []chunkMetaAndChunkDiskMapperRef
}{
{
name: "chunks are ordered by min time",
input: []chunkMetaAndChunkDiskMapperRef{
{
meta: chunks.Meta{
Ref: 0,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(0),
},
{
meta: chunks.Meta{
Ref: 1,
MinTime: 1,
},
ref: chunks.ChunkDiskMapperRef(1),
},
},
exp: []chunkMetaAndChunkDiskMapperRef{
{
meta: chunks.Meta{
Ref: 0,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(0),
},
{
meta: chunks.Meta{
Ref: 1,
MinTime: 1,
},
ref: chunks.ChunkDiskMapperRef(1),
},
},
},
{
name: "if same mintime, lower reference goes first",
input: []chunkMetaAndChunkDiskMapperRef{
{
meta: chunks.Meta{
Ref: 10,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(0),
},
{
meta: chunks.Meta{
Ref: 5,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(1),
},
},
exp: []chunkMetaAndChunkDiskMapperRef{
{
meta: chunks.Meta{
Ref: 5,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(1),
},
{
meta: chunks.Meta{
Ref: 10,
MinTime: 0,
},
ref: chunks.ChunkDiskMapperRef(0),
},
},
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("name=%s", tc.name), func(t *testing.T) {
slices.SortFunc(tc.input, refLessByMinTimeAndMinRef)
require.Equal(t, tc.exp, tc.input)
})
}
}
// TestSortMetaByMinTimeAndMinRef tests that the sort function for chunk metas does sort
// by chunk meta MinTime and in case of same references by the lower reference.
func TestSortMetaByMinTimeAndMinRef(t *testing.T) {
tests := []struct {
name string
inputMetas []chunks.Meta
expMetas []chunks.Meta
}{
{
name: "chunks are ordered by min time",
inputMetas: []chunks.Meta{
{
Ref: 0,
MinTime: 0,
},
{
Ref: 1,
MinTime: 1,
},
},
expMetas: []chunks.Meta{
{
Ref: 0,
MinTime: 0,
},
{
Ref: 1,
MinTime: 1,
},
},
},
{
name: "if same mintime, lower reference goes first",
inputMetas: []chunks.Meta{
{
Ref: 10,
MinTime: 0,
},
{
Ref: 5,
MinTime: 0,
},
},
expMetas: []chunks.Meta{
{
Ref: 5,
MinTime: 0,
},
{
Ref: 10,
MinTime: 0,
},
},
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("name=%s", tc.name), func(t *testing.T) {
slices.SortFunc(tc.inputMetas, lessByMinTimeAndMinRef)
require.Equal(t, tc.expMetas, tc.inputMetas)
})
}
}
func newTestDBWithOpts(t *testing.T, opts *Options) *DB {
dir := t.TempDir()
db, err := Open(dir, nil, nil, opts, nil)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, db.Close())
})
return db
}