// 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" "slices" "sort" "testing" "time" "github.com/stretchr/testify/require" "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 } type expChunk struct { c chunkInterval m []chunkInterval } // 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 []expChunk }{ { 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: []expChunk{ {c: 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: []expChunk{ {c: 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: []expChunk{ {c: chunkInterval{0, 100, 250}, m: []chunkInterval{{0, 100, 200}, {2, 150, 250}}}, {c: chunkInterval{1, 500, 650}, m: []chunkInterval{{1, 500, 600}, {3, 550, 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: []expChunk{ {c: chunkInterval{0, 100, 500}, m: []chunkInterval{{0, 100, 200}, {1, 200, 300}, {2, 300, 400}, {3, 400, 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: []expChunk{ {c: chunkInterval{0, 100, 199}}, {c: chunkInterval{1, 200, 299}}, {c: chunkInterval{2, 300, 399}}, {c: chunkInterval{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: []expChunk{ {c: chunkInterval{0, 100, 350}, m: []chunkInterval{{0, 100, 200}, {1, 150, 300}, {2, 250, 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: []expChunk{ {c: chunkInterval{1, 0, 500}, m: []chunkInterval{{1, 0, 200}, {2, 150, 300}, {0, 250, 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: []expChunk{ {c: chunkInterval{0, 100, 300}, m: []chunkInterval{{0, 100, 300}, {2, 150, 250}}}, {c: chunkInterval{4, 600, 850}, m: []chunkInterval{{4, 600, 800}, {3, 650, 750}, {1, 770, 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: []expChunk{ {c: chunkInterval{0, 100, 150}}, {c: chunkInterval{2, 200, 250}}, {c: chunkInterval{1, 300, 350}}, }, }, } 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{} // define our expected chunks, by looking at the expected ChunkIntervals and setting... // Ref to whatever Ref the chunk has, that we refer to by ID findID := func(id int) chunks.ChunkRef { for ref, c := range intervals { if c.ID == id { return chunks.ChunkRef(chunks.NewHeadChunkRef(chunks.HeadSeriesRef(s1ID), s1.oooHeadChunkID(ref))) } } return 0 } var expChunks []chunks.Meta for _, e := range tc.expChunks { var chunk chunkenc.Chunk if len(e.m) > 0 { mm := &multiMeta{} for _, x := range e.m { meta := chunks.Meta{ MinTime: x.mint, MaxTime: x.maxt, Ref: findID(x.ID), } mm.metas = append(mm.metas, meta) } chunk = mm } meta := chunks.Meta{ Chunk: chunk, MinTime: e.c.mint, MaxTime: e.c.maxt, Ref: findID(e.c.ID), } expChunks = append(expChunks, meta) } if headChunk && len(intervals) > 0 { // Put the last interval in the head chunk s1.ooo.oooHeadChunk = &oooHeadChunk{ chunk: NewOOOChunk(), 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 := NewHeadAndOOOIndexReader(h, tc.queryMinT, 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) { for name, scenario := range sampleTypeScenarios { t.Run(name, func(t *testing.T) { testOOOHeadChunkReader_LabelValues(t, scenario) }) } } //nolint:revive // unexported-return. func testOOOHeadChunkReader_LabelValues(t *testing.T, scenario sampleTypeScenario) { 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 := scenario.appendFunc(app, labels.FromStrings("foo", "bar1"), 100, int64(1)) require.NoError(t, err) _, _, err = scenario.appendFunc(app, labels.FromStrings("foo", "bar2"), 100, int64(2)) require.NoError(t, err) // Add ooo samples for those series _, _, err = scenario.appendFunc(app, labels.FromStrings("foo", "bar1"), 90, int64(1)) require.NoError(t, err) _, _, err = scenario.appendFunc(app, labels.FromStrings("foo", "bar2"), 90, int64(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{"bar1"}, expValues2: nil, expValues3: []string{"bar1", "bar2"}, expValues4: []string{"bar1", "bar2"}, }, } 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 := NewHeadAndOOOIndexReader(head, tc.queryMinT, 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) { for name, scenario := range sampleTypeScenarios { t.Run(name, func(t *testing.T) { testOOOHeadChunkReader_Chunk(t, scenario) }) } } //nolint:revive // unexported-return. func testOOOHeadChunkReader_Chunk(t *testing.T, scenario sampleTypeScenario) { 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() } t.Run("Getting a non existing chunk fails with not found error", func(t *testing.T) { db := newTestDBWithOpts(t, opts) cr := NewHeadAndOOOChunkReader(db.head, 0, 1000, nil, nil, 0) defer cr.Close() c, iterable, err := cr.ChunkOrIterable(chunks.Meta{ Ref: 0x1800000, 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 []testValue expSingleChunks bool 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: []testValue{ {Ts: minutes(30), V: 0}, {Ts: minutes(40), V: 0}, }, expChunkError: false, expSingleChunks: true, // 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{ { scenario.sampleFunc(minutes(30), 0), scenario.sampleFunc(minutes(40), 0), }, }, }, { name: "Getting the head chunk when there are overlapping chunks returns all combined", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), inputSamples: []testValue{{Ts: minutes(41), V: 0}, {Ts: minutes(42), V: 0}, {Ts: minutes(43), V: 0}, {Ts: minutes(44), V: 0}, {Ts: minutes(45), V: 0}, {Ts: minutes(30), V: 1}, {Ts: minutes(50), V: 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{ { scenario.sampleFunc(minutes(30), 1), scenario.sampleFunc(minutes(41), 0), scenario.sampleFunc(minutes(42), 0), scenario.sampleFunc(minutes(43), 0), scenario.sampleFunc(minutes(44), 0), scenario.sampleFunc(minutes(45), 0), scenario.sampleFunc(minutes(50), 1), }, }, }, { name: "Two windows of overlapping chunks get properly converged", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), inputSamples: []testValue{ // Chunk 0 {Ts: minutes(10), V: 0}, {Ts: minutes(12), V: 0}, {Ts: minutes(14), V: 0}, {Ts: minutes(16), V: 0}, {Ts: minutes(20), V: 0}, // Chunk 1 {Ts: minutes(20), V: 1}, {Ts: minutes(22), V: 1}, {Ts: minutes(24), V: 1}, {Ts: minutes(26), V: 1}, {Ts: minutes(29), V: 1}, // Chunk 3 {Ts: minutes(30), V: 2}, {Ts: minutes(32), V: 2}, {Ts: minutes(34), V: 2}, {Ts: minutes(36), V: 2}, {Ts: minutes(40), V: 2}, // Head {Ts: minutes(40), V: 3}, {Ts: minutes(50), V: 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{ { scenario.sampleFunc(minutes(10), 0), scenario.sampleFunc(minutes(12), 0), scenario.sampleFunc(minutes(14), 0), scenario.sampleFunc(minutes(16), 0), scenario.sampleFunc(minutes(20), 1), scenario.sampleFunc(minutes(22), 1), scenario.sampleFunc(minutes(24), 1), scenario.sampleFunc(minutes(26), 1), scenario.sampleFunc(minutes(29), 1), }, { scenario.sampleFunc(minutes(30), 2), scenario.sampleFunc(minutes(32), 2), scenario.sampleFunc(minutes(34), 2), scenario.sampleFunc(minutes(36), 2), scenario.sampleFunc(minutes(40), 3), scenario.sampleFunc(minutes(50), 3), }, }, }, { name: "Two windows of overlapping chunks in descending order get properly converged", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), inputSamples: []testValue{ // Chunk 0 {Ts: minutes(40), V: 0}, {Ts: minutes(42), V: 0}, {Ts: minutes(44), V: 0}, {Ts: minutes(46), V: 0}, {Ts: minutes(50), V: 0}, // Chunk 1 {Ts: minutes(30), V: 1}, {Ts: minutes(32), V: 1}, {Ts: minutes(34), V: 1}, {Ts: minutes(36), V: 1}, {Ts: minutes(40), V: 1}, // Chunk 3 {Ts: minutes(20), V: 2}, {Ts: minutes(22), V: 2}, {Ts: minutes(24), V: 2}, {Ts: minutes(26), V: 2}, {Ts: minutes(29), V: 2}, // Head {Ts: minutes(10), V: 3}, {Ts: minutes(20), V: 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{ { scenario.sampleFunc(minutes(10), 3), scenario.sampleFunc(minutes(20), 2), scenario.sampleFunc(minutes(22), 2), scenario.sampleFunc(minutes(24), 2), scenario.sampleFunc(minutes(26), 2), scenario.sampleFunc(minutes(29), 2), }, { scenario.sampleFunc(minutes(30), 1), scenario.sampleFunc(minutes(32), 1), scenario.sampleFunc(minutes(34), 1), scenario.sampleFunc(minutes(36), 1), scenario.sampleFunc(minutes(40), 0), scenario.sampleFunc(minutes(42), 0), scenario.sampleFunc(minutes(44), 0), scenario.sampleFunc(minutes(46), 0), scenario.sampleFunc(minutes(50), 0), }, }, }, { name: "If chunks are not overlapped they are not converged", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), inputSamples: []testValue{ // Chunk 0 {Ts: minutes(10), V: 0}, {Ts: minutes(12), V: 0}, {Ts: minutes(14), V: 0}, {Ts: minutes(16), V: 0}, {Ts: minutes(18), V: 0}, // Chunk 1 {Ts: minutes(20), V: 1}, {Ts: minutes(22), V: 1}, {Ts: minutes(24), V: 1}, {Ts: minutes(26), V: 1}, {Ts: minutes(28), V: 1}, // Chunk 3 {Ts: minutes(30), V: 2}, {Ts: minutes(32), V: 2}, {Ts: minutes(34), V: 2}, {Ts: minutes(36), V: 2}, {Ts: minutes(38), V: 2}, // Head {Ts: minutes(40), V: 3}, {Ts: minutes(42), V: 3}, }, expChunkError: false, expSingleChunks: true, // 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{ { scenario.sampleFunc(minutes(10), 0), scenario.sampleFunc(minutes(12), 0), scenario.sampleFunc(minutes(14), 0), scenario.sampleFunc(minutes(16), 0), scenario.sampleFunc(minutes(18), 0), }, { scenario.sampleFunc(minutes(20), 1), scenario.sampleFunc(minutes(22), 1), scenario.sampleFunc(minutes(24), 1), scenario.sampleFunc(minutes(26), 1), scenario.sampleFunc(minutes(28), 1), }, { scenario.sampleFunc(minutes(30), 2), scenario.sampleFunc(minutes(32), 2), scenario.sampleFunc(minutes(34), 2), scenario.sampleFunc(minutes(36), 2), scenario.sampleFunc(minutes(38), 2), }, { scenario.sampleFunc(minutes(40), 3), scenario.sampleFunc(minutes(42), 3), }, }, }, { name: "Triplet of chunks overlapping returns a single merged chunk", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), inputSamples: []testValue{ // Chunk 0 {Ts: minutes(10), V: 0}, {Ts: minutes(15), V: 0}, {Ts: minutes(20), V: 0}, {Ts: minutes(25), V: 0}, {Ts: minutes(30), V: 0}, // Chunk 1 {Ts: minutes(20), V: 1}, {Ts: minutes(25), V: 1}, {Ts: minutes(30), V: 1}, {Ts: minutes(35), V: 1}, {Ts: minutes(42), V: 1}, // Chunk 2 Head {Ts: minutes(32), V: 2}, {Ts: minutes(50), V: 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{ { scenario.sampleFunc(minutes(10), 0), scenario.sampleFunc(minutes(15), 0), scenario.sampleFunc(minutes(20), 1), scenario.sampleFunc(minutes(25), 1), scenario.sampleFunc(minutes(30), 1), scenario.sampleFunc(minutes(32), 2), scenario.sampleFunc(minutes(35), 1), scenario.sampleFunc(minutes(42), 1), scenario.sampleFunc(minutes(50), 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: []testValue{ // Chunk 0 {Ts: minutes(10), V: 0}, {Ts: minutes(15), V: 0}, {Ts: minutes(20), V: 0}, {Ts: minutes(25), V: 0}, {Ts: minutes(30), V: 0}, // Chunk 1 {Ts: minutes(20), V: 1}, {Ts: minutes(25), V: 1}, {Ts: minutes(30), V: 1}, {Ts: minutes(35), V: 1}, {Ts: minutes(42), V: 1}, // Chunk 2 Head {Ts: minutes(32), V: 2}, {Ts: minutes(50), V: 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{ { scenario.sampleFunc(minutes(10), 0), scenario.sampleFunc(minutes(15), 0), scenario.sampleFunc(minutes(20), 1), scenario.sampleFunc(minutes(25), 1), scenario.sampleFunc(minutes(30), 1), scenario.sampleFunc(minutes(32), 2), scenario.sampleFunc(minutes(35), 1), scenario.sampleFunc(minutes(42), 1), scenario.sampleFunc(minutes(50), 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, _, err := scenario.appendFunc(app, s1, tc.firstInOrderSampleAt, tc.firstInOrderSampleAt/1*time.Minute.Milliseconds()) require.NoError(t, err) require.NoError(t, app.Commit()) // OOO few samples for s1. app = db.Appender(context.Background()) for _, s := range tc.inputSamples { _, _, err := scenario.appendFunc(app, s1, s.Ts, s.V) require.NoError(t, err) } require.NoError(t, app.Commit()) // The Series method populates the chunk metas, taking a copy of the // head OOO chunk if necessary. These are then used by the ChunkReader. ir := NewHeadAndOOOIndexReader(db.head, tc.queryMinT, 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 := NewHeadAndOOOChunkReader(db.head, tc.queryMinT, tc.queryMaxT, nil, nil, 0) defer cr.Close() for i := 0; i < len(chks); i++ { c, iterable, err := cr.ChunkOrIterable(chks[i]) require.NoError(t, err) var it chunkenc.Iterator if tc.expSingleChunks { it = c.Iterator(nil) } else { require.Nil(t, c) it = iterable.Iterator(nil) } resultSamples, err := storage.ExpandSamples(it, nil) require.NoError(t, err) requireEqualSamples(t, s1.String(), tc.expChunksSamples[i], resultSamples, true) } }) } } // 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) { for name, scenario := range sampleTypeScenarios { t.Run(name, func(t *testing.T) { testOOOHeadChunkReader_Chunk_ConsistentQueryResponseDespiteOfHeadExpanding(t, scenario) }) } } //nolint:revive // unexported-return. func testOOOHeadChunkReader_Chunk_ConsistentQueryResponseDespiteOfHeadExpanding(t *testing.T, scenario sampleTypeScenario) { 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() } tests := []struct { name string queryMinT int64 queryMaxT int64 firstInOrderSampleAt int64 initialSamples []testValue samplesAfterSeriesCall []testValue 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: []testValue{ // Chunk 0 {Ts: minutes(20), V: 0}, {Ts: minutes(22), V: 0}, {Ts: minutes(24), V: 0}, {Ts: minutes(26), V: 0}, {Ts: minutes(30), V: 0}, // Chunk 1 Head {Ts: minutes(25), V: 1}, {Ts: minutes(35), V: 1}, }, samplesAfterSeriesCall: []testValue{ {Ts: minutes(10), V: 1}, {Ts: minutes(32), V: 1}, {Ts: minutes(50), V: 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{ { scenario.sampleFunc(minutes(20), 0), scenario.sampleFunc(minutes(22), 0), scenario.sampleFunc(minutes(24), 0), scenario.sampleFunc(minutes(25), 1), scenario.sampleFunc(minutes(26), 0), scenario.sampleFunc(minutes(30), 0), scenario.sampleFunc(minutes(35), 1), }, }, }, { name: "After Series() prev head gets mmapped after getting samples, new head gets new samples also overlapping, none of these should appear in response.", queryMinT: minutes(0), queryMaxT: minutes(100), firstInOrderSampleAt: minutes(120), initialSamples: []testValue{ // Chunk 0 {Ts: minutes(20), V: 0}, {Ts: minutes(22), V: 0}, {Ts: minutes(24), V: 0}, {Ts: minutes(26), V: 0}, {Ts: minutes(30), V: 0}, // Chunk 1 Head {Ts: minutes(25), V: 1}, {Ts: minutes(35), V: 1}, }, samplesAfterSeriesCall: []testValue{ {Ts: minutes(10), V: 1}, {Ts: minutes(32), V: 1}, {Ts: minutes(50), V: 1}, // Chunk 1 gets mmapped and Chunk 2, the new head is born {Ts: minutes(25), V: 2}, {Ts: minutes(31), V: 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{ { scenario.sampleFunc(minutes(20), 0), scenario.sampleFunc(minutes(22), 0), scenario.sampleFunc(minutes(24), 0), scenario.sampleFunc(minutes(25), 1), scenario.sampleFunc(minutes(26), 0), scenario.sampleFunc(minutes(30), 0), scenario.sampleFunc(minutes(35), 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, _, err := scenario.appendFunc(app, s1, tc.firstInOrderSampleAt, tc.firstInOrderSampleAt/1*time.Minute.Milliseconds()) require.NoError(t, err) require.NoError(t, app.Commit()) // OOO few samples for s1. app = db.Appender(context.Background()) for _, s := range tc.initialSamples { _, _, err := scenario.appendFunc(app, s1, s.Ts, s.V) require.NoError(t, err) } require.NoError(t, app.Commit()) // The Series method populates the chunk metas, taking a copy of the // head OOO chunk if necessary. These are then used by the ChunkReader. ir := NewHeadAndOOOIndexReader(db.head, tc.queryMinT, 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 { _, _, err = scenario.appendFunc(app, s1, s.Ts, s.V) require.NoError(t, err) } require.NoError(t, app.Commit()) cr := NewHeadAndOOOChunkReader(db.head, tc.queryMinT, tc.queryMaxT, nil, nil, 0) 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) it := iterable.Iterator(nil) resultSamples, err := storage.ExpandSamples(it, nil) require.NoError(t, err) requireEqualSamples(t, s1.String(), tc.expChunksSamples[i], resultSamples, true) } }) } } // 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 }