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:
2026-07-04 14:36:24 -04:00
parent 9a322274a5
commit 376d3faf25
7 changed files with 1090 additions and 2 deletions
+480
View File
@@ -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)
}
})
}
}