In-memory head with WAL
Head connects chunkenc and WAL: label-to-ref resolution with striped concurrent maps, active Gorilla chunk per series sealing at 120 samples, and an Appender that buffers samples, writes WAL on commit, then applies to head. OOO rejection checks both committed and batch state. WAL replay on Open rebuilds the full in-memory state. Exported ChunkAppender/ChunkIterator interfaces from chunkenc.
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
package head
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.dvdt.dev/david/ingot/internal/wal"
|
||||
"git.dvdt.dev/david/ingot/labels"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// sample is a convenience type for expected results.
|
||||
type sample struct {
|
||||
t int64
|
||||
vBits uint64
|
||||
}
|
||||
|
||||
func s(t int64, v float64) sample { return sample{t, math.Float64bits(v)} }
|
||||
|
||||
// collectSamples reads all samples from a head series iterator.
|
||||
func collectSamples(t *testing.T, h *Head, ref uint64, mint, maxt int64) []sample {
|
||||
t.Helper()
|
||||
it := h.SeriesIterator(ref, mint, maxt)
|
||||
var out []sample
|
||||
for it.Next() {
|
||||
ts, v := it.At()
|
||||
out = append(out, sample{ts, math.Float64bits(v)})
|
||||
}
|
||||
require.NoError(t, it.Err())
|
||||
return out
|
||||
}
|
||||
|
||||
// Action kinds for the appender lifecycle table.
|
||||
const (
|
||||
actNew = iota // create a new Appender
|
||||
actAppend // call Append
|
||||
actCommit // call Commit
|
||||
actRollback // call Rollback
|
||||
)
|
||||
|
||||
type action struct {
|
||||
kind int
|
||||
|
||||
// actAppend fields.
|
||||
ref uint64
|
||||
labels []labels.Label
|
||||
t int64
|
||||
v float64
|
||||
|
||||
// Expected results (actAppend: ref+err, actCommit/actRollback: err).
|
||||
wantRef uint64
|
||||
wantErr error
|
||||
}
|
||||
|
||||
func openHead(t *testing.T) *Head {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "wal")
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { h.Close() })
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHead(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []action
|
||||
wantSamples map[uint64][]sample // ref -> expected samples after all actions
|
||||
}{
|
||||
{
|
||||
name: "single_series_single_sample",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 71.3, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 71.3)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single_series_multiple_samples",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, ref: 1, t: 1015, v: 2.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, ref: 1, t: 1030, v: 3.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0), s(1015, 2.0), s(1030, 3.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple_series",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 71.3, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "humidity"}}, t: 1000, v: 55.0, wantRef: 2, wantErr: nil},
|
||||
{kind: actAppend, ref: 1, t: 1015, v: 71.4, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, ref: 2, t: 1015, v: 54.0, wantRef: 2, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 71.3), s(1015, 71.4)},
|
||||
2: {s(1000, 55.0), s(1015, 54.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "out_of_order_rejected",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, ref: 1, t: 999, v: 2.0, wantRef: 0, wantErr: ErrOutOfOrder},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate_timestamp_rejected",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actAppend, ref: 1, t: 1000, v: 2.0, wantRef: 0, wantErr: ErrOutOfOrder},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "out_of_order_across_commits",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
{kind: actNew},
|
||||
{kind: actAppend, ref: 1, t: 500, v: 2.0, wantRef: 0, wantErr: ErrOutOfOrder},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown_ref_rejected",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, ref: 999, t: 1000, v: 1.0, wantRef: 0, wantErr: ErrSeriesNotFound},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "empty_label_name_rejected",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 0, wantErr: labels.ErrEmptyName},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "unsorted_labels_normalized",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "room", Value: "office"}, {Name: "__name__", Value: "temp"}}, t: 1000, v: 71.3, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 71.3)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chunk_sealing",
|
||||
actions: func() []action {
|
||||
acts := []action{{kind: actNew}}
|
||||
acts = append(acts, action{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 0, v: 0, wantRef: 1})
|
||||
for i := 1; i < 250; i++ {
|
||||
acts = append(acts, action{kind: actAppend, ref: 1, t: int64(i * 15000), v: float64(i), wantRef: 1})
|
||||
}
|
||||
acts = append(acts, action{kind: actCommit})
|
||||
return acts
|
||||
}(),
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: func() []sample {
|
||||
out := make([]sample, 250)
|
||||
for i := range out {
|
||||
out[i] = s(int64(i*15000), float64(i))
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query_out_of_range_returns_empty",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
},
|
||||
// wantSamples checks ref=1 over all time; a separate query assertion below.
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Appender lifecycle error paths ---
|
||||
{
|
||||
name: "rollback_discards_samples",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
{kind: actNew},
|
||||
{kind: actAppend, ref: 1, t: 2000, v: 2.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actRollback, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rollback_removes_new_series",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actAppend, labels: []labels.Label{{Name: "__name__", Value: "temp"}}, t: 1000, v: 1.0, wantRef: 1, wantErr: nil},
|
||||
{kind: actRollback, wantErr: nil},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "commit_after_commit",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
{kind: actCommit, wantErr: ErrAppenderClosed},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "rollback_after_commit",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actCommit, wantErr: nil},
|
||||
{kind: actRollback, wantErr: ErrAppenderClosed},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "commit_after_rollback",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actRollback, wantErr: nil},
|
||||
{kind: actCommit, wantErr: ErrAppenderClosed},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
{
|
||||
name: "rollback_after_rollback",
|
||||
actions: []action{
|
||||
{kind: actNew},
|
||||
{kind: actRollback, wantErr: nil},
|
||||
{kind: actRollback, wantErr: ErrAppenderClosed},
|
||||
},
|
||||
wantSamples: map[uint64][]sample{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := openHead(t)
|
||||
|
||||
var app *Appender
|
||||
for i, act := range tc.actions {
|
||||
switch act.kind {
|
||||
case actNew:
|
||||
app = h.Appender()
|
||||
case actAppend:
|
||||
ref, err := app.Append(act.ref, act.labels, act.t, act.v)
|
||||
assert.Equal(t, act.wantRef, ref, "action %d ref", i)
|
||||
assert.Equal(t, act.wantErr, err, "action %d error", i)
|
||||
case actCommit:
|
||||
err := app.Commit()
|
||||
assert.Equal(t, act.wantErr, err, "action %d commit error", i)
|
||||
case actRollback:
|
||||
err := app.Rollback()
|
||||
assert.Equal(t, act.wantErr, err, "action %d rollback error", i)
|
||||
}
|
||||
}
|
||||
|
||||
for ref, wantSamples := range tc.wantSamples {
|
||||
got := collectSamples(t, h, ref, math.MinInt64, math.MaxInt64)
|
||||
require.Equal(t, len(wantSamples), len(got), "ref %d sample count", ref)
|
||||
for i, want := range wantSamples {
|
||||
assert.Equal(t, want, got[i], "ref %d sample %d", ref, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWALReplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, dir string)
|
||||
wantSeries map[uint64][]sample
|
||||
}{
|
||||
{
|
||||
name: "basic_recovery",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
app := h.Appender()
|
||||
ref, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 71.3)
|
||||
require.NoError(t, err)
|
||||
_, err = app.Append(ref, nil, 1015, 71.4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Commit())
|
||||
require.NoError(t, h.Close())
|
||||
},
|
||||
wantSeries: map[uint64][]sample{
|
||||
1: {s(1000, 71.3), s(1015, 71.4)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi_series_recovery",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
app := h.Appender()
|
||||
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 71.3)
|
||||
require.NoError(t, err)
|
||||
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}}, 1000, 55.0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Commit())
|
||||
require.NoError(t, h.Close())
|
||||
},
|
||||
wantSeries: map[uint64][]sample{
|
||||
1: {s(1000, 71.3)},
|
||||
2: {s(1000, 55.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chunk_sealing_recovery",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
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())
|
||||
require.NoError(t, h.Close())
|
||||
},
|
||||
wantSeries: map[uint64][]sample{
|
||||
1: func() []sample {
|
||||
out := make([]sample, 250)
|
||||
for i := range out {
|
||||
out[i] = s(int64(i*15000), float64(i))
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple_commits_recovery",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
app := h.Appender()
|
||||
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Commit())
|
||||
app = h.Appender()
|
||||
_, err = app.Append(1, nil, 2000, 2.0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Commit())
|
||||
require.NoError(t, h.Close())
|
||||
},
|
||||
wantSeries: map[uint64][]sample{
|
||||
1: {s(1000, 1.0), s(2000, 2.0)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rollback_not_recovered",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
app := h.Appender()
|
||||
_, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Commit())
|
||||
// Second batch is rolled back — should not survive restart.
|
||||
app = h.Appender()
|
||||
_, err = app.Append(1, nil, 2000, 2.0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, app.Rollback())
|
||||
require.NoError(t, h.Close())
|
||||
},
|
||||
wantSeries: map[uint64][]sample{
|
||||
1: {s(1000, 1.0)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "wal")
|
||||
tc.setup(t, dir)
|
||||
|
||||
h, err := Open(dir, wal.Options{})
|
||||
require.NoError(t, err)
|
||||
defer h.Close()
|
||||
|
||||
for ref, wantSamples := range tc.wantSeries {
|
||||
got := collectSamples(t, h, ref, math.MinInt64, math.MaxInt64)
|
||||
require.Equal(t, len(wantSamples), len(got), "ref %d sample count", ref)
|
||||
for i, want := range wantSamples {
|
||||
assert.Equal(t, want, got[i], "ref %d sample %d", ref, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAppend(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
numGoroutines int
|
||||
samplesPerGoroutine int
|
||||
}{
|
||||
{name: "8_goroutines_100_samples", numGoroutines: 8, samplesPerGoroutine: 100},
|
||||
{name: "1_goroutine_1000_samples", numGoroutines: 1, samplesPerGoroutine: 1000},
|
||||
{name: "32_goroutines_10_samples", numGoroutines: 32, samplesPerGoroutine: 10},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := openHead(t)
|
||||
|
||||
refs := make([]uint64, tc.numGoroutines)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for g := 0; g < tc.numGoroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
app := h.Appender()
|
||||
ls := []labels.Label{
|
||||
{Name: "__name__", Value: "metric"},
|
||||
{Name: "goroutine", Value: string(rune('A' + g))},
|
||||
}
|
||||
ref, err := app.Append(0, ls, int64(g*1000000), 0)
|
||||
require.NoError(t, err)
|
||||
refs[g] = ref
|
||||
for i := 1; i < tc.samplesPerGoroutine; i++ {
|
||||
_, err = app.Append(ref, nil, int64(g*1000000+i*1000), float64(i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, app.Commit())
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for g := 0; g < tc.numGoroutines; g++ {
|
||||
got := collectSamples(t, h, refs[g], math.MinInt64, math.MaxInt64)
|
||||
assert.Equal(t, tc.samplesPerGoroutine, len(got), "goroutine %d sample count", g)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user