Immutable blocks with mmap reads

Flush sealed head chunks to ULID-named block directories on disk. Each
block contains CRC'd chunk segment files (mmap'd for reads), a binary
index (symbol table, series, postings with TOC), and a meta.json written
last as the immutability gate. WAL is truncated after block fsync,
preserving the crash-safety ording invariant.
This commit is contained in:
2026-07-04 14:59:39 -04:00
parent 376d3faf25
commit 42b03db2fa
16 changed files with 2176 additions and 2 deletions
+177
View File
@@ -2,10 +2,12 @@ package head
import (
"math"
"os"
"path/filepath"
"sync"
"testing"
"git.dvdt.dev/david/ingot/internal/block"
"git.dvdt.dev/david/ingot/internal/wal"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
@@ -432,6 +434,181 @@ func TestWALReplay(t *testing.T) {
}
}
func TestFlushOlderThan(t *testing.T) {
tests := []struct {
name string
numSamples int // samples per series (each at 15s intervals)
flushMaxT int64
wantBlockSeries int // number of series in the block
wantBlockExists bool
}{
{
name: "flush_sealed_chunks",
numSamples: 250, // 2 sealed chunks (120 each) + 10 active
flushMaxT: math.MaxInt64,
wantBlockSeries: 1,
wantBlockExists: true,
},
{
name: "nothing_to_flush",
numSamples: 50, // only active chunk, no sealed
flushMaxT: math.MaxInt64,
wantBlockSeries: 0,
wantBlockExists: false,
},
{
name: "partial_flush_by_time",
numSamples: 250,
flushMaxT: 120 * 15000, // only flush first sealed chunk
wantBlockSeries: 1,
wantBlockExists: true,
},
}
for _, tc := range tests {
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)
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())
// Flush.
ulid, err := h.FlushOlderThan(tc.flushMaxT)
require.NoError(t, err)
if !tc.wantBlockExists {
assert.Empty(t, ulid)
return
}
assert.NotEmpty(t, ulid)
// 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 {
it, err := br.SeriesChunkIterator(ref, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
count := 0
for it.Next() {
count++
}
require.NoError(t, it.Err())
assert.Greater(t, count, 0, "block should contain samples")
}
// 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")
})
}
}
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)
}
require.NoError(t, app.Commit())
// Count WAL segments before flush.
walEntries, err := os.ReadDir(walDir)
require.NoError(t, err)
segsBefore := len(walEntries)
// Flush.
_, err = h.FlushOlderThan(math.MaxInt64)
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")
require.NoError(t, h.Close())
// Re-open: head should recover from WAL (only unflushed data).
h2, err := Open(walDir, wal.Options{})
require.NoError(t, err)
defer h2.Close()
// 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())
}
func TestConcurrentAppend(t *testing.T) {
tests := []struct {
name string