Compaction, retention, and soak testing

Block refcounting (atomic refs + condemned flag) lets live queries
survive concurrent compaction and retention. Levelled compactor merges
2h->8h->32h blocks via raw chunk passthrough. Retention drops blocks
older than the configured window. Background goroutine drives
flush/compact/retain cycles; exported RunCompaction/ApplyRetention allow
deterministic test control via injectable clock.

Soak test: 10k series x 48h simulated at 15s intervals (115M samples).
Validates flat memory (158 MiB peak), bounded disk (13 blocks), and zero
query errors during compaction.

All existing tests refactored to table-driven with uniform assertions.
This commit is contained in:
2026-07-04 17:02:57 -04:00
parent 323a6f2951
commit 0356f2e082
10 changed files with 1748 additions and 341 deletions
+162 -94
View File
@@ -8,6 +8,7 @@ import (
"testing"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/internal/index"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -181,13 +182,49 @@ func TestBlockRoundTrip(t *testing.T) {
}
}
func TestBlockMultipleChunksPerSeries(t *testing.T) {
dataDir := t.TempDir()
func TestBlockSeriesChunkIterator(t *testing.T) {
chunk1Samples := []sample{s(1000, 1.0), s(1015, 2.0), s(1030, 3.0)}
chunk2Samples := []sample{s(2000, 4.0), s(2015, 5.0), s(2030, 6.0)}
allSamples := append(chunk1Samples, chunk2Samples...)
tests := []struct {
name string
ref uint64
mint int64
maxt int64
wantSamples []sample
}{
{
name: "full_range",
ref: 1,
mint: math.MinInt64,
maxt: math.MaxInt64,
wantSamples: append(chunk1Samples, chunk2Samples...),
},
{
name: "second_chunk_only",
ref: 1,
mint: 2000,
maxt: 3000,
wantSamples: chunk2Samples,
},
{
name: "no_overlap",
ref: 1,
mint: 5000,
maxt: 6000,
wantSamples: nil,
},
{
name: "unknown_ref",
ref: 999,
mint: math.MinInt64,
maxt: math.MaxInt64,
wantSamples: nil,
},
}
// Setup: create a block with two chunks for series ref=1.
dataDir := t.TempDir()
flushData := []SeriesFlush{
{
Ref: 1,
@@ -198,71 +235,76 @@ func TestBlockMultipleChunksPerSeries(t *testing.T) {
},
},
}
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
r, err := Open(filepath.Join(dataDir, ulid))
require.NoError(t, err)
defer r.Close()
// Full range.
it, err := r.SeriesChunkIterator(1, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
got := collectIterator(t, it)
require.Equal(t, len(allSamples), len(got))
for i, want := range allSamples {
assert.Equal(t, want.t, got[i].t, "sample %d t", i)
assert.Equal(t, want.vBits, got[i].vBits, "sample %d v", i)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
it, err := r.SeriesChunkIterator(tc.ref, tc.mint, tc.maxt)
require.NoError(t, err)
got := collectIterator(t, it)
assert.Equal(t, len(tc.wantSamples), len(got), "sample count")
for i, want := range tc.wantSamples {
assert.Equal(t, want.t, got[i].t, "sample %d t", i)
assert.Equal(t, want.vBits, got[i].vBits, "sample %d v", i)
}
})
}
// Query only second chunk's range.
it, err = r.SeriesChunkIterator(1, 2000, 3000)
require.NoError(t, err)
got = collectIterator(t, it)
require.Equal(t, len(chunk2Samples), len(got))
for i, want := range chunk2Samples {
assert.Equal(t, want.t, got[i].t, "sample %d t", i)
assert.Equal(t, want.vBits, got[i].vBits, "sample %d v", i)
}
// Query with no overlap.
it, err = r.SeriesChunkIterator(1, 5000, 6000)
require.NoError(t, err)
got = collectIterator(t, it)
assert.Empty(t, got)
// Unknown ref.
it, err = r.SeriesChunkIterator(999, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
assert.False(t, it.Next())
}
func TestBlockMetaTimeBounds(t *testing.T) {
dataDir := t.TempDir()
flushData := []SeriesFlush{
tests := []struct {
name string
series []SeriesFlush
wantMinT int64
wantMaxT int64
}{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "a"}},
Chunks: []ChunkData{{MinT: 500, MaxT: 1000, Data: makeChunk(t, []sample{s(500, 1.0), s(1000, 2.0)})}},
name: "two_series_different_ranges",
series: []SeriesFlush{
{Ref: 1, Labels: []labels.Label{{Name: "__name__", Value: "a"}}, Chunks: []ChunkData{{MinT: 500, MaxT: 1000, Data: makeChunkFromPairs([]int64{500, 1000}, []float64{1.0, 2.0})}}},
{Ref: 2, Labels: []labels.Label{{Name: "__name__", Value: "b"}}, Chunks: []ChunkData{{MinT: 200, MaxT: 800, Data: makeChunkFromPairs([]int64{200, 800}, []float64{3.0, 4.0})}}},
},
wantMinT: 200,
wantMaxT: 1000,
},
{
Ref: 2,
Labels: []labels.Label{{Name: "__name__", Value: "b"}},
Chunks: []ChunkData{{MinT: 200, MaxT: 800, Data: makeChunk(t, []sample{s(200, 3.0), s(800, 4.0)})}},
name: "single_series",
series: []SeriesFlush{
{Ref: 1, Labels: []labels.Label{{Name: "__name__", Value: "a"}}, Chunks: []ChunkData{{MinT: 100, MaxT: 500, Data: makeChunkFromPairs([]int64{100, 500}, []float64{1.0, 2.0})}}},
},
wantMinT: 100,
wantMaxT: 500,
},
{
name: "multiple_chunks",
series: []SeriesFlush{
{Ref: 1, Labels: []labels.Label{{Name: "__name__", Value: "a"}}, Chunks: []ChunkData{
{MinT: 100, MaxT: 200, Data: makeChunkFromPairs([]int64{100, 200}, []float64{1.0, 2.0})},
{MinT: 300, MaxT: 900, Data: makeChunkFromPairs([]int64{300, 900}, []float64{3.0, 4.0})},
}},
},
wantMinT: 100,
wantMaxT: 900,
},
}
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
ulid, err := Flush(dataDir, tc.series)
require.NoError(t, err)
r, err := Open(filepath.Join(dataDir, ulid))
require.NoError(t, err)
defer r.Close()
r, err := Open(filepath.Join(dataDir, ulid))
require.NoError(t, err)
defer r.Close()
assert.Equal(t, int64(200), r.Meta.MinTime)
assert.Equal(t, int64(1000), r.Meta.MaxTime)
assert.Equal(t, tc.wantMinT, r.Meta.MinTime, "MinTime")
assert.Equal(t, tc.wantMaxT, r.Meta.MaxTime, "MaxTime")
})
}
}
func TestULIDRoundTrip(t *testing.T) {
@@ -276,57 +318,83 @@ func TestULIDRoundTrip(t *testing.T) {
}
}
func TestCorruptChunkCRC(t *testing.T) {
dataDir := t.TempDir()
samples := []sample{s(1000, 71.3), s(1015, 71.4)}
flushData := []SeriesFlush{
func TestBlockCorruption(t *testing.T) {
tests := []struct {
name string
corruptFunc func(t *testing.T, blockDir string, chunkRef index.ChunkRef)
wantErr error
}{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}},
Chunks: []ChunkData{{MinT: 1000, MaxT: 1015, Data: makeChunk(t, samples)}},
name: "corrupt_chunk_data_byte",
corruptFunc: func(t *testing.T, blockDir string, chunkRef index.ChunkRef) {
chunkPath := filepath.Join(blockDir, chunksDirName, segmentName(int(chunkRef.Segment())))
data, err := os.ReadFile(chunkPath)
require.NoError(t, err)
off := int(chunkRef.Offset()) + chunkEntryHeaderLen + 1
data[off] ^= 0xFF
require.NoError(t, os.WriteFile(chunkPath, data, 0644))
},
wantErr: ErrCorruptChunk,
},
{
name: "corrupt_chunk_crc",
corruptFunc: func(t *testing.T, blockDir string, chunkRef index.ChunkRef) {
chunkPath := filepath.Join(blockDir, chunksDirName, segmentName(int(chunkRef.Segment())))
data, err := os.ReadFile(chunkPath)
require.NoError(t, err)
// Corrupt the last byte of the CRC.
off := int(chunkRef.Offset()) + chunkEntryHeaderLen
// Read dataLen to find CRC position.
dataLen := int(data[chunkRef.Offset()])*16777216 + int(data[chunkRef.Offset()+1])*65536 +
int(data[chunkRef.Offset()+2])*256 + int(data[chunkRef.Offset()+3])
crcOff := off + dataLen + 3 // last byte of CRC
data[crcOff] ^= 0xFF
require.NoError(t, os.WriteFile(chunkPath, data, 0644))
},
wantErr: ErrCorruptChunk,
},
}
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
samples := []sample{s(1000, 71.3), s(1015, 71.4)}
flushData := []SeriesFlush{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}},
Chunks: []ChunkData{{MinT: 1000, MaxT: 1015, Data: makeChunk(t, samples)}},
},
}
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
// Open block, find the chunk ref, then corrupt the chunk file.
blockDir := filepath.Join(dataDir, ulid)
r, err := Open(blockDir)
require.NoError(t, err)
blockDir := filepath.Join(dataDir, ulid)
r, err := Open(blockDir)
require.NoError(t, err)
series := r.Series()
require.Equal(t, 1, len(series))
chunkRef := series[0].Chunks[0].Ref
r.Close()
series := r.Series()
require.Equal(t, 1, len(series))
chunkRef := series[0].Chunks[0].Ref
r.Close()
tc.corruptFunc(t, blockDir, chunkRef)
// Corrupt chunk data on disk.
chunkSeg := chunkRef.Segment()
chunkPath := filepath.Join(blockDir, chunksDirName, segmentName(int(chunkSeg)))
r, err = Open(blockDir)
require.NoError(t, err)
defer r.Close()
// Read, corrupt a data byte, write back.
chunkFile, err := readFileBytes(chunkPath)
require.NoError(t, err)
off := int(chunkRef.Offset()) + chunkEntryHeaderLen + 1 // corrupt a data byte
if off < len(chunkFile) {
chunkFile[off] ^= 0xFF
_, err = r.ChunkIterator(chunkRef)
assert.Equal(t, tc.wantErr, err)
})
}
require.NoError(t, writeFileBytes(chunkPath, chunkFile))
// Re-open and try to read the corrupt chunk.
r, err = Open(blockDir)
require.NoError(t, err)
defer r.Close()
_, err = r.ChunkIterator(chunkRef)
assert.Equal(t, ErrCorruptChunk, err)
}
func readFileBytes(path string) ([]byte, error) {
return os.ReadFile(path)
}
func writeFileBytes(path string, data []byte) error {
return os.WriteFile(path, data, 0644)
// makeChunkFromPairs creates a chunk from timestamp/value slices.
func makeChunkFromPairs(ts []int64, vs []float64) []byte {
c := chunkenc.NewXORChunk()
a, _ := c.Appender()
for i := range ts {
a.Append(ts[i], vs[i])
}
return append([]byte(nil), c.Bytes()...)
}
+35 -6
View File
@@ -3,6 +3,7 @@ package block
import (
"os"
"path/filepath"
"sync/atomic"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/internal/index"
@@ -11,10 +12,12 @@ import (
// Reader provides read access to an immutable on-disk block.
type Reader struct {
dir string
Meta BlockMeta
idx *index.Reader
chunks *chunkReader
dir string
Meta BlockMeta
idx *index.Reader
chunks *chunkReader
refs atomic.Int32
condemned atomic.Bool
}
// Open opens a block directory for reading. Chunk files are mmap'd.
@@ -39,12 +42,14 @@ func Open(dir string) (*Reader, error) {
return nil, err
}
return &Reader{
r := &Reader{
dir: dir,
Meta: meta,
idx: idx,
chunks: cr,
}, nil
}
r.refs.Store(1) // DB's ownership ref
return r, nil
}
// Series returns all series entries from the index.
@@ -115,6 +120,30 @@ func (r *Reader) AllPostings() []uint64 {
return r.idx.AllPostings()
}
// Dir returns the block directory path.
func (r *Reader) Dir() string { return r.dir }
// Ref increments the refcount. Called by Querier on snapshot.
func (r *Reader) Ref() { r.refs.Add(1) }
// Release decrements the refcount. Returns true if the refcount hit zero
// and the block is condemned (caller should delete the directory).
func (r *Reader) Release() bool {
if r.refs.Add(-1) == 0 {
r.Close()
return r.condemned.Load()
}
return false
}
// Condemn marks the block for directory deletion when refcount reaches zero.
func (r *Reader) Condemn() { r.condemned.Store(true) }
// RawChunkData returns the raw chunk bytes at the given ref (for compaction).
func (r *Reader) RawChunkData(ref index.ChunkRef) ([]byte, error) {
return r.chunks.chunkData(ref)
}
// Close releases all resources (munmaps chunk files).
func (r *Reader) Close() error {
return r.chunks.close()
+16 -1
View File
@@ -30,6 +30,16 @@ type SeriesFlush struct {
//
// Returns the block ULID and any error.
func Flush(dataDir string, series []SeriesFlush) (string, error) {
return flushBlock(dataDir, series, 1, nil)
}
// FlushCompacted writes a new immutable block from compacted series data,
// recording the compaction level and source block ULIDs.
func FlushCompacted(dataDir string, series []SeriesFlush, level int, sources []string) (string, error) {
return flushBlock(dataDir, series, level, sources)
}
func flushBlock(dataDir string, series []SeriesFlush, level int, sources []string) (string, error) {
ulid := newULID()
blockDir := filepath.Join(dataDir, ulid)
@@ -49,7 +59,12 @@ func Flush(dataDir string, series []SeriesFlush) (string, error) {
)
meta.ULID = ulid
meta.Version = 1
meta.Compaction = CompactionInfo{Level: 1, Sources: []string{ulid}}
meta.Compaction = CompactionInfo{Level: level}
if sources != nil {
meta.Compaction.Sources = sources
} else {
meta.Compaction.Sources = []string{ulid}
}
meta.MinTime = int64(^uint64(0) >> 1) // max int64
meta.MaxTime = int64(0)
+206
View File
@@ -0,0 +1,206 @@
// Package compact implements levelled compaction and retention for ingot blocks.
package compact
import (
"fmt"
"sort"
"git.dvdt.dev/david/ingot/internal/block"
"git.dvdt.dev/david/ingot/labels"
)
// Clock returns the current time in milliseconds since epoch.
type Clock func() int64
// Compactor manages levelled compaction and retention.
type Compactor struct {
dataDir string
levels []int64 // level durations in ms, e.g. [2h, 8h, 32h]
retention int64 // retention window in ms (0 = disabled)
clock Clock
}
// New creates a Compactor. levels are the compaction level durations in
// ascending order (e.g. 2h, 8h, 32h in milliseconds). retention is the
// maximum age of data in milliseconds (0 disables retention).
func New(dataDir string, levels []int64, retention int64, clock Clock) *Compactor {
return &Compactor{
dataDir: dataDir,
levels: levels,
retention: retention,
clock: clock,
}
}
// CompactionGroup describes a set of source blocks to compact.
type CompactionGroup struct {
Sources []*block.Reader
Level int // resulting compaction level
}
// Plan returns the first eligible compaction group, or nil if no compaction
// is needed. Lower levels are prioritized. A group requires at least 2
// blocks at the same compaction level whose combined time span fits within
// the next level's duration.
func (c *Compactor) Plan(blocks []*block.Reader) *CompactionGroup {
if len(blocks) < 2 {
return nil
}
// Group blocks by compaction level.
byLevel := make(map[int][]*block.Reader)
for _, b := range blocks {
lvl := b.Meta.Compaction.Level
byLevel[lvl] = append(byLevel[lvl], b)
}
// Sort levels ascending.
var levels []int
for lvl := range byLevel {
levels = append(levels, lvl)
}
sort.Ints(levels)
for _, lvl := range levels {
group := c.planLevel(byLevel[lvl], lvl)
if group != nil {
return group
}
}
return nil
}
// planLevel finds a compactable group within blocks at the same level.
func (c *Compactor) planLevel(blocks []*block.Reader, level int) *CompactionGroup {
if len(blocks) < 2 {
return nil
}
// Sort by MinTime.
sorted := make([]*block.Reader, len(blocks))
copy(sorted, blocks)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Meta.MinTime < sorted[j].Meta.MinTime
})
// Determine the max span for the next level.
var maxSpan int64
if level-1 < len(c.levels) && level >= 1 {
// Find the next level's duration. Level 1 blocks should compact
// into level 2 when they span up to levels[1] (8h), etc.
if level < len(c.levels) {
maxSpan = c.levels[level]
}
}
if maxSpan == 0 {
// No higher level defined, or level 0 — use the second level duration.
if level < len(c.levels) {
maxSpan = c.levels[level]
} else {
return nil // already at max level
}
}
// Find the first group of consecutive blocks whose span fits maxSpan.
for i := 0; i < len(sorted)-1; i++ {
group := []*block.Reader{sorted[i]}
for j := i + 1; j < len(sorted); j++ {
span := sorted[j].Meta.MaxTime - sorted[i].Meta.MinTime
if span > maxSpan {
break
}
group = append(group, sorted[j])
}
if len(group) >= 2 {
return &CompactionGroup{
Sources: group,
Level: level + 1,
}
}
}
return nil
}
// Compact merges source blocks into a single new block. Returns the new
// block's ULID. The caller is responsible for swapping the block set and
// releasing source blocks.
func (c *Compactor) Compact(sources []*block.Reader) (string, error) {
if len(sources) == 0 {
return "", fmt.Errorf("compact: no source blocks")
}
merged := make(map[uint64]*mergedEntry)
for _, src := range sources {
for _, entry := range src.Series() {
me, ok := merged[entry.Ref]
if !ok {
me = &mergedEntry{
ref: entry.Ref,
labels: entry.Labels,
}
merged[entry.Ref] = me
}
for _, cm := range entry.Chunks {
raw, err := src.RawChunkData(cm.Ref)
if err != nil {
return "", fmt.Errorf("compact: read chunk ref %v from %s: %w",
cm.Ref, src.Meta.ULID, err)
}
// Copy the raw bytes since the source may be munmapped later.
data := make([]byte, len(raw))
copy(data, raw)
me.chunks = append(me.chunks, block.ChunkData{
MinT: cm.MinT,
MaxT: cm.MaxT,
Data: data,
})
}
}
}
// Build flush data sorted by ref for deterministic output.
flushData := make([]block.SeriesFlush, 0, len(merged))
for _, me := range merged {
flushData = append(flushData, block.SeriesFlush{
Ref: me.ref,
Labels: me.labels,
Chunks: me.chunks,
})
}
sort.Slice(flushData, func(i, j int) bool { return flushData[i].Ref < flushData[j].Ref })
// Determine new compaction level and collect source ULIDs.
maxLevel := 0
sourceULIDs := make([]string, 0, len(sources))
for _, src := range sources {
if src.Meta.Compaction.Level > maxLevel {
maxLevel = src.Meta.Compaction.Level
}
sourceULIDs = append(sourceULIDs, src.Meta.ULID)
}
return block.FlushCompacted(c.dataDir, flushData, maxLevel+1, sourceULIDs)
}
// Expired returns blocks whose MaxTime is older than the retention window.
// Returns nil if retention is disabled (zero).
func (c *Compactor) Expired(blocks []*block.Reader) []*block.Reader {
if c.retention == 0 {
return nil
}
cutoff := c.clock() - c.retention
var expired []*block.Reader
for _, b := range blocks {
if b.Meta.MaxTime < cutoff {
expired = append(expired, b)
}
}
return expired
}
type mergedEntry struct {
ref uint64
labels []labels.Label
chunks []block.ChunkData
}
+430
View File
@@ -0,0 +1,430 @@
package compact
import (
"math"
"path/filepath"
"testing"
"git.dvdt.dev/david/ingot/internal/block"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
hour = 3600 * 1000 // 1 hour in ms
)
// makeChunk creates a chunk with the given samples and returns its raw bytes.
func makeChunk(t *testing.T, timestamps []int64, values []float64) []byte {
t.Helper()
c := chunkenc.NewXORChunk()
a, err := c.Appender()
require.NoError(t, err)
for i := range timestamps {
a.Append(timestamps[i], values[i])
}
return append([]byte(nil), c.Bytes()...)
}
// flushTestBlock creates a block in dataDir and returns an opened Reader.
func flushTestBlock(t *testing.T, dataDir string, series []block.SeriesFlush, level int, sources []string) *block.Reader {
t.Helper()
var ulid string
var err error
if level == 1 && sources == nil {
ulid, err = block.Flush(dataDir, series)
} else {
ulid, err = block.FlushCompacted(dataDir, series, level, sources)
}
require.NoError(t, err)
r, err := block.Open(filepath.Join(dataDir, ulid))
require.NoError(t, err)
return r
}
// collectBlockSamples reads all samples for a series ref from a block.
func collectBlockSamples(t *testing.T, r *block.Reader, ref uint64) []sample {
t.Helper()
it, err := r.SeriesChunkIterator(ref, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
var out []sample
for it.Next() {
ts, v := it.At()
out = append(out, sample{ts, v})
}
require.NoError(t, it.Err())
return out
}
type sample struct {
t int64
v float64
}
func TestPlan(t *testing.T) {
tests := []struct {
name string
blocks []blockSpec // blocks to create
wantGroup bool // expect a compaction group
wantLevel int // expected resulting level
wantCount int // expected number of sources in group
}{
{
name: "no_blocks",
blocks: nil,
wantGroup: false,
},
{
name: "single_block",
blocks: []blockSpec{
{minT: 0, maxT: 2 * hour, level: 1},
},
wantGroup: false,
},
{
name: "two_level1_blocks_within_8h",
blocks: []blockSpec{
{minT: 0, maxT: 2 * hour, level: 1},
{minT: 2 * hour, maxT: 4 * hour, level: 1},
},
wantGroup: true,
wantLevel: 2,
wantCount: 2,
},
{
name: "four_level1_blocks",
blocks: []blockSpec{
{minT: 0, maxT: 2 * hour, level: 1},
{minT: 2 * hour, maxT: 4 * hour, level: 1},
{minT: 4 * hour, maxT: 6 * hour, level: 1},
{minT: 6 * hour, maxT: 8 * hour, level: 1},
},
wantGroup: true,
wantLevel: 2,
wantCount: 4,
},
{
name: "level1_blocks_exceed_8h_span",
blocks: []blockSpec{
{minT: 0, maxT: 2 * hour, level: 1},
{minT: 7 * hour, maxT: 9 * hour, level: 1},
},
wantGroup: false,
},
{
name: "two_level2_blocks_within_32h",
blocks: []blockSpec{
{minT: 0, maxT: 8 * hour, level: 2},
{minT: 8 * hour, maxT: 16 * hour, level: 2},
},
wantGroup: true,
wantLevel: 3,
wantCount: 2,
},
{
name: "max_level_blocks_not_compacted",
blocks: []blockSpec{
{minT: 0, maxT: 32 * hour, level: 3},
{minT: 32 * hour, maxT: 64 * hour, level: 3},
},
wantGroup: false,
},
{
name: "mixed_levels_lower_prioritized",
blocks: []blockSpec{
{minT: 0, maxT: 2 * hour, level: 1},
{minT: 2 * hour, maxT: 4 * hour, level: 1},
{minT: 10 * hour, maxT: 18 * hour, level: 2},
{minT: 18 * hour, maxT: 26 * hour, level: 2},
},
wantGroup: true,
wantLevel: 2, // level-1 group found first
wantCount: 2,
},
}
levels := []int64{2 * hour, 8 * hour, 32 * hour}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
c := New(dataDir, levels, 0, nil)
var blocks []*block.Reader
for _, spec := range tc.blocks {
r := createBlockWithMeta(t, dataDir, spec)
blocks = append(blocks, r)
t.Cleanup(func() { r.Close() })
}
group := c.Plan(blocks)
if !tc.wantGroup {
assert.Nil(t, group, "expected no compaction group")
return
}
require.NotNil(t, group, "expected a compaction group")
assert.Equal(t, tc.wantLevel, group.Level, "compaction level")
assert.Equal(t, tc.wantCount, len(group.Sources), "source count")
})
}
}
func TestCompact(t *testing.T) {
tests := []struct {
name string
sourceBlocks []sourceBlock
wantSeriesRefs []uint64
wantSamples map[uint64][]sample
wantLevel int
}{
{
name: "merge_two_blocks_disjoint_series",
sourceBlocks: []sourceBlock{
{
level: 1,
series: []seriesData{
{ref: 1, labels: labels.FromStrings("__name__", "a"), samples: []sample{{1000, 1.0}, {2000, 2.0}}},
},
},
{
level: 1,
series: []seriesData{
{ref: 2, labels: labels.FromStrings("__name__", "b"), samples: []sample{{1000, 3.0}, {2000, 4.0}}},
},
},
},
wantSeriesRefs: []uint64{1, 2},
wantSamples: map[uint64][]sample{
1: {{1000, 1.0}, {2000, 2.0}},
2: {{1000, 3.0}, {2000, 4.0}},
},
wantLevel: 2,
},
{
name: "merge_two_blocks_same_series",
sourceBlocks: []sourceBlock{
{
level: 1,
series: []seriesData{
{ref: 1, labels: labels.FromStrings("__name__", "temp"), samples: []sample{{1000, 1.0}, {2000, 2.0}}},
},
},
{
level: 1,
series: []seriesData{
{ref: 1, labels: labels.FromStrings("__name__", "temp"), samples: []sample{{3000, 3.0}, {4000, 4.0}}},
},
},
},
wantSeriesRefs: []uint64{1},
wantSamples: map[uint64][]sample{
1: {{1000, 1.0}, {2000, 2.0}, {3000, 3.0}, {4000, 4.0}},
},
wantLevel: 2,
},
{
name: "merge_level2_blocks",
sourceBlocks: []sourceBlock{
{
level: 2,
series: []seriesData{
{ref: 1, labels: labels.FromStrings("__name__", "a"), samples: []sample{{1000, 1.0}}},
},
},
{
level: 2,
series: []seriesData{
{ref: 1, labels: labels.FromStrings("__name__", "a"), samples: []sample{{5000, 5.0}}},
},
},
},
wantSeriesRefs: []uint64{1},
wantSamples: map[uint64][]sample{
1: {{1000, 1.0}, {5000, 5.0}},
},
wantLevel: 3,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
c := New(dataDir, []int64{2 * hour, 8 * hour, 32 * hour}, 0, nil)
// Create source blocks.
var sources []*block.Reader
for _, sb := range tc.sourceBlocks {
r := createSourceBlock(t, dataDir, sb)
sources = append(sources, r)
}
// Compact.
newULID, err := c.Compact(sources)
require.NoError(t, err)
require.NotEmpty(t, newULID)
// Close source blocks.
for _, s := range sources {
s.Close()
}
// Open compacted block.
compacted, err := block.Open(filepath.Join(dataDir, newULID))
require.NoError(t, err)
defer compacted.Close()
// Verify compaction level.
assert.Equal(t, tc.wantLevel, compacted.Meta.Compaction.Level, "compaction level")
assert.Equal(t, len(tc.sourceBlocks), len(compacted.Meta.Compaction.Sources), "source count")
// Verify series count.
assert.Equal(t, len(tc.wantSeriesRefs), compacted.Meta.Stats.NumSeries, "series count")
// Verify each series' samples.
for _, ref := range tc.wantSeriesRefs {
got := collectBlockSamples(t, compacted, ref)
want := tc.wantSamples[ref]
require.Equal(t, len(want), len(got), "sample count for ref %d", ref)
for i := range want {
assert.Equal(t, want[i].t, got[i].t, "ref %d sample %d t", ref, i)
assert.Equal(t, want[i].v, got[i].v, "ref %d sample %d v", ref, i)
}
}
})
}
}
func TestExpired(t *testing.T) {
tests := []struct {
name string
blocks []blockSpec
now int64
retention int64
wantCount int
}{
{
name: "no_retention",
blocks: []blockSpec{{minT: 0, maxT: 1000}},
now: 100000,
retention: 0,
wantCount: 0,
},
{
name: "all_within_retention",
blocks: []blockSpec{
{minT: 80000, maxT: 90000},
},
now: 100000,
retention: 50000,
wantCount: 0,
},
{
name: "one_expired",
blocks: []blockSpec{
{minT: 0, maxT: 10000},
{minT: 80000, maxT: 90000},
},
now: 100000,
retention: 50000,
wantCount: 1,
},
{
name: "all_expired",
blocks: []blockSpec{
{minT: 0, maxT: 10000},
{minT: 20000, maxT: 30000},
},
now: 100000,
retention: 50000,
wantCount: 2,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
clock := func() int64 { return tc.now }
c := New(dataDir, nil, tc.retention, clock)
var blocks []*block.Reader
for _, spec := range tc.blocks {
r := createBlockWithMeta(t, dataDir, spec)
blocks = append(blocks, r)
t.Cleanup(func() { r.Close() })
}
expired := c.Expired(blocks)
assert.Equal(t, tc.wantCount, len(expired), "expired block count")
})
}
}
// --- helpers ---
type blockSpec struct {
minT int64
maxT int64
level int
}
type sourceBlock struct {
level int
series []seriesData
}
type seriesData struct {
ref uint64
labels []labels.Label
samples []sample
}
// createBlockWithMeta creates a minimal block with the given time range and level.
func createBlockWithMeta(t *testing.T, dataDir string, spec blockSpec) *block.Reader {
t.Helper()
// Need at least one series with samples spanning [minT, maxT].
timestamps := []int64{spec.minT, spec.maxT}
values := []float64{1.0, 2.0}
data := makeChunk(t, timestamps, values)
series := []block.SeriesFlush{{
Ref: 1,
Labels: labels.FromStrings("__name__", "test"),
Chunks: []block.ChunkData{{MinT: spec.minT, MaxT: spec.maxT, Data: data}},
}}
level := spec.level
if level == 0 {
level = 1
}
return flushTestBlock(t, dataDir, series, level, []string{"src"})
}
// createSourceBlock creates a block with the given series data.
func createSourceBlock(t *testing.T, dataDir string, sb sourceBlock) *block.Reader {
t.Helper()
var flushData []block.SeriesFlush
for _, sd := range sb.series {
timestamps := make([]int64, len(sd.samples))
values := make([]float64, len(sd.samples))
for i, s := range sd.samples {
timestamps[i] = s.t
values[i] = s.v
}
data := makeChunk(t, timestamps, values)
minT, maxT := sd.samples[0].t, sd.samples[len(sd.samples)-1].t
flushData = append(flushData, block.SeriesFlush{
Ref: sd.ref,
Labels: sd.labels,
Chunks: []block.ChunkData{{MinT: minT, MaxT: maxT, Data: data}},
})
}
level := sb.level
if level == 0 {
level = 1
}
return flushTestBlock(t, dataDir, flushData, level, []string{"src"})
}
+166 -144
View File
@@ -439,29 +439,51 @@ func TestFlushOlderThan(t *testing.T) {
name string
numSamples int // samples per series (each at 15s intervals)
flushMaxT int64
wantBlockSeries int // number of series in the block
wantBlockExists bool
postFlushAppend int // additional samples to append after flush (0 = none)
wantULID bool // whether flush produces a block
wantBlockSeries int // number of series in the block (0 if no block)
wantBlockCount int // samples in the block (0 if no block)
wantHeadCount int // minimum samples remaining in head after flush
}{
{
name: "flush_sealed_chunks",
numSamples: 250, // 2 sealed chunks (120 each) + 10 active
numSamples: 250,
flushMaxT: math.MaxInt64,
postFlushAppend: 0,
wantULID: true,
wantBlockSeries: 1,
wantBlockExists: true,
wantBlockCount: 240,
wantHeadCount: 10,
},
{
name: "nothing_to_flush",
numSamples: 50, // only active chunk, no sealed
numSamples: 50,
flushMaxT: math.MaxInt64,
postFlushAppend: 0,
wantULID: false,
wantBlockSeries: 0,
wantBlockExists: false,
wantBlockCount: 0,
wantHeadCount: 50,
},
{
name: "partial_flush_by_time",
numSamples: 250,
flushMaxT: 120 * 15000, // only flush first sealed chunk
flushMaxT: 120 * 15000,
postFlushAppend: 0,
wantULID: true,
wantBlockSeries: 1,
wantBlockExists: true,
wantBlockCount: 120,
wantHeadCount: 10,
},
{
name: "flush_then_continue_appending",
numSamples: 250,
flushMaxT: math.MaxInt64,
postFlushAppend: 10,
wantULID: true,
wantBlockSeries: 1,
wantBlockCount: 240,
wantHeadCount: 10,
},
}
@@ -469,7 +491,6 @@ func TestFlushOlderThan(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
h := openHead(t)
// Append samples.
app := h.Appender()
ref, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 0, 0)
require.NoError(t, err)
@@ -479,134 +500,98 @@ func TestFlushOlderThan(t *testing.T) {
}
require.NoError(t, app.Commit())
// Flush.
ulid, err := h.FlushOlderThan(tc.flushMaxT)
require.NoError(t, err)
if !tc.wantBlockExists {
assert.Empty(t, ulid)
return
// Post-flush appends.
if tc.postFlushAppend > 0 {
app = h.Appender()
for i := tc.numSamples; i < tc.numSamples+tc.postFlushAppend; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
}
require.NoError(t, app.Commit())
}
assert.NotEmpty(t, ulid)
// Assert ULID presence.
assert.Equal(t, tc.wantULID, ulid != "", "block ULID presence")
// Verify block exists and is readable.
blockDir := filepath.Join(h.DataDir(), ulid)
br, err := block.Open(blockDir)
require.NoError(t, err)
defer br.Close()
assert.Equal(t, tc.wantBlockSeries, br.Meta.Stats.NumSeries)
// Verify block data is correct by iterating.
if tc.wantBlockSeries > 0 {
// Assert block contents.
blockCount := 0
if ulid != "" {
blockDir := filepath.Join(h.DataDir(), ulid)
br, err := block.Open(blockDir)
require.NoError(t, err)
defer br.Close()
assert.Equal(t, tc.wantBlockSeries, br.Meta.Stats.NumSeries, "block series count")
it, err := br.SeriesChunkIterator(ref, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
count := 0
for it.Next() {
count++
blockCount++
}
require.NoError(t, it.Err())
assert.Greater(t, count, 0, "block should contain samples")
}
assert.Equal(t, tc.wantBlockCount, blockCount, "block sample count")
// Head should still have its active chunk data.
allSamples := collectSamples(t, h, ref, math.MinInt64, math.MaxInt64)
assert.Greater(t, len(allSamples), 0, "head should still have active chunk")
// Assert head still has data.
headSamples := collectSamples(t, h, ref, math.MinInt64, math.MaxInt64)
assert.GreaterOrEqual(t, len(headSamples), tc.wantHeadCount, "head sample count")
})
}
}
func TestFlushThenContinueAppending(t *testing.T) {
h := openHead(t)
// Append enough to seal two chunks (240 samples), plus a few more.
app := h.Appender()
ref, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 0, 0)
require.NoError(t, err)
for i := 1; i < 250; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
}
require.NoError(t, app.Commit())
// Flush sealed chunks.
ulid, err := h.FlushOlderThan(math.MaxInt64)
require.NoError(t, err)
require.NotEmpty(t, ulid)
// Continue appending after flush.
app = h.Appender()
for i := 250; i < 260; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
}
require.NoError(t, app.Commit())
// Head should have the active chunk data (unflushed).
allSamples := collectSamples(t, h, ref, math.MinInt64, math.MaxInt64)
assert.Greater(t, len(allSamples), 0)
// Block should have the flushed data.
blockDir := filepath.Join(h.DataDir(), ulid)
br, err := block.Open(blockDir)
require.NoError(t, err)
defer br.Close()
it, err := br.SeriesChunkIterator(ref, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
blockCount := 0
for it.Next() {
blockCount++
}
require.NoError(t, it.Err())
assert.Equal(t, 240, blockCount, "block should contain 2 sealed chunks of 120 samples each")
}
func TestFlushWALTruncation(t *testing.T) {
dir := t.TempDir()
walDir := filepath.Join(dir, "wal")
h, err := Open(walDir, wal.Options{})
require.NoError(t, err)
// Append enough to seal chunks.
app := h.Appender()
ref, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 0, 0)
require.NoError(t, err)
for i := 1; i < 250; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
tests := []struct {
name string
numSamples int
}{
{name: "250_samples", numSamples: 250},
{name: "500_samples", numSamples: 500},
}
require.NoError(t, app.Commit())
// Count WAL segments before flush.
walEntries, err := os.ReadDir(walDir)
require.NoError(t, err)
segsBefore := len(walEntries)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
walDir := filepath.Join(dir, "wal")
// Flush.
_, err = h.FlushOlderThan(math.MaxInt64)
require.NoError(t, err)
h, err := Open(walDir, wal.Options{})
require.NoError(t, err)
// WAL segments should have been truncated (or at least not grown).
walEntries, err = os.ReadDir(walDir)
require.NoError(t, err)
segsAfter := len(walEntries)
assert.LessOrEqual(t, segsAfter, segsBefore, "WAL should be truncated after flush")
app := h.Appender()
ref, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 0, 0)
require.NoError(t, err)
_ = ref
for i := 1; i < tc.numSamples; i++ {
_, err = app.Append(ref, nil, int64(i*15000), float64(i))
require.NoError(t, err)
}
require.NoError(t, app.Commit())
require.NoError(t, h.Close())
walEntries, err := os.ReadDir(walDir)
require.NoError(t, err)
segsBefore := len(walEntries)
// Re-open: head should recover from WAL (only unflushed data).
h2, err := Open(walDir, wal.Options{})
require.NoError(t, err)
defer h2.Close()
_, err = h.FlushOlderThan(math.MaxInt64)
require.NoError(t, err)
// The re-opened head should be functional.
app = h2.Appender()
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "new_series"}}, 5000000, 42.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
walEntries, err = os.ReadDir(walDir)
require.NoError(t, err)
segsAfter := len(walEntries)
assert.LessOrEqual(t, segsAfter, segsBefore, "WAL should be truncated after flush")
require.NoError(t, h.Close())
// Re-open: head should be functional.
h2, err := Open(walDir, wal.Options{})
require.NoError(t, err)
defer h2.Close()
app = h2.Appender()
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "new_series"}}, 5000000, 42.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
})
}
}
func TestHeadPostings(t *testing.T) {
@@ -642,44 +627,81 @@ func TestHeadPostings(t *testing.T) {
}
}
func TestHeadLabelValues(t *testing.T) {
h := openHead(t)
func TestHeadQueryMethods(t *testing.T) {
tests := []struct {
name string
series [][]labels.Label
wantLabelValues map[string][]string // label name -> expected values
wantLabels map[uint64][]labels.Label // ref -> expected labels
wantAllPostings []uint64
}{
{
name: "multiple_series_with_shared_labels",
series: [][]labels.Label{
{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}},
{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}},
{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}},
},
wantLabelValues: map[string][]string{
"__name__": {"humidity", "temp"},
"room": {"kitchen", "office"},
"nonexistent": {},
},
wantLabels: map[uint64][]labels.Label{
1: {{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}},
2: {{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}},
3: {{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}},
},
wantAllPostings: []uint64{1, 2, 3},
},
{
name: "single_series",
series: [][]labels.Label{
{{Name: "__name__", Value: "temp"}},
},
wantLabelValues: map[string][]string{
"__name__": {"temp"},
"nonexistent": {},
},
wantLabels: map[uint64][]labels.Label{
1: {{Name: "__name__", Value: "temp"}},
},
wantAllPostings: []uint64{1},
},
}
app := h.Appender()
_, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}}, 1000, 1.0)
require.NoError(t, err)
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}}, 1000, 2.0)
require.NoError(t, err)
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}}, 1000, 3.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := openHead(t)
assert.Equal(t, []string{"humidity", "temp"}, h.LabelValues("__name__"))
assert.Equal(t, []string{"kitchen", "office"}, h.LabelValues("room"))
assert.Equal(t, []string{}, h.LabelValues("nonexistent"))
}
app := h.Appender()
for _, ls := range tc.series {
_, err := app.Append(0, ls, 1000, 1.0)
require.NoError(t, err)
}
require.NoError(t, app.Commit())
func TestHeadLabelsAndAllPostings(t *testing.T) {
h := openHead(t)
// Assert LabelValues.
for name, wantVals := range tc.wantLabelValues {
got := h.LabelValues(name)
assert.Equal(t, wantVals, got, "LabelValues(%q)", name)
}
app := h.Appender()
_, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 1.0)
require.NoError(t, err)
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}}, 1000, 2.0)
require.NoError(t, err)
require.NoError(t, app.Commit())
// Assert Labels by ref.
for ref, wantLabels := range tc.wantLabels {
ls, ok := h.Labels(ref)
assert.True(t, ok, "Labels(%d) should exist", ref)
assert.Equal(t, wantLabels, ls, "Labels(%d)", ref)
}
// Labels
ls, ok := h.Labels(1)
assert.True(t, ok)
assert.Equal(t, []labels.Label{{Name: "__name__", Value: "temp"}}, ls)
// Unknown ref returns false.
_, ok := h.Labels(999)
assert.False(t, ok, "Labels(999) should not exist")
_, ok = h.Labels(999)
assert.False(t, ok)
// AllPostings
refs := h.AllPostings()
assert.Equal(t, []uint64{1, 2}, refs)
// Assert AllPostings.
assert.Equal(t, tc.wantAllPostings, h.AllPostings(), "AllPostings")
})
}
}
func TestConcurrentAppend(t *testing.T) {