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:
+75
-2
@@ -4,8 +4,10 @@ package head
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"git.dvdt.dev/david/ingot/internal/block"
|
||||
"git.dvdt.dev/david/ingot/internal/chunkenc"
|
||||
"git.dvdt.dev/david/ingot/internal/wal"
|
||||
"git.dvdt.dev/david/ingot/labels"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
// Head is the in-memory store for active series and their chunks.
|
||||
type Head struct {
|
||||
dataDir string // parent directory containing WAL and block dirs
|
||||
series *seriesMap
|
||||
wal *wal.WAL
|
||||
nextRef atomic.Uint64
|
||||
@@ -23,6 +26,7 @@ type Head struct {
|
||||
}
|
||||
|
||||
// Open creates or recovers a Head backed by a WAL in walDir.
|
||||
// The dataDir (parent of walDir) is used for writing blocks.
|
||||
func Open(walDir string, walOpts wal.Options) (*Head, error) {
|
||||
w, err := wal.Open(walDir, walOpts)
|
||||
if err != nil {
|
||||
@@ -30,8 +34,9 @@ func Open(walDir string, walOpts wal.Options) (*Head, error) {
|
||||
}
|
||||
|
||||
h := &Head{
|
||||
series: newSeriesMap(),
|
||||
wal: w,
|
||||
dataDir: filepath.Dir(walDir),
|
||||
series: newSeriesMap(),
|
||||
wal: w,
|
||||
}
|
||||
|
||||
if err := h.replay(); err != nil {
|
||||
@@ -145,3 +150,71 @@ func (h *Head) MaxTime() int64 { return h.maxTime.Load() }
|
||||
func (h *Head) Close() error {
|
||||
return h.wal.Close()
|
||||
}
|
||||
|
||||
// FlushOlderThan collects all sealed chunks with maxT <= threshold from all
|
||||
// series, writes them to an immutable block, and truncates the WAL.
|
||||
//
|
||||
// The ordering invariant is enforced: block fsync -> meta.json write -> WAL truncate.
|
||||
// Returns the block ULID (empty string if nothing to flush) and any error.
|
||||
func (h *Head) FlushOlderThan(maxT int64) (string, error) {
|
||||
var flushData []block.SeriesFlush
|
||||
|
||||
h.series.forEach(func(s *memSeries) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var toFlush []chunkMeta
|
||||
var remaining []chunkMeta
|
||||
for _, cm := range s.sealed {
|
||||
if cm.maxT <= maxT {
|
||||
toFlush = append(toFlush, cm)
|
||||
} else {
|
||||
remaining = append(remaining, cm)
|
||||
}
|
||||
}
|
||||
if len(toFlush) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sf := block.SeriesFlush{
|
||||
Ref: s.ref,
|
||||
Labels: s.labels,
|
||||
}
|
||||
for _, cm := range toFlush {
|
||||
sf.Chunks = append(sf.Chunks, block.ChunkData{
|
||||
MinT: cm.minT,
|
||||
MaxT: cm.maxT,
|
||||
Data: append([]byte(nil), cm.chunk.Bytes()...),
|
||||
})
|
||||
}
|
||||
flushData = append(flushData, sf)
|
||||
|
||||
// Clear flushed chunks from the series.
|
||||
s.sealed = remaining
|
||||
})
|
||||
|
||||
if len(flushData) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Write block. Flush handles: chunk files + index + fsync + meta.json.
|
||||
ulid, err := block.Flush(h.dataDir, flushData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("head: flush block: %w", err)
|
||||
}
|
||||
|
||||
// WAL truncation: safe because the block is fully fsynced.
|
||||
// Truncate all segments below the current one — the flushed data is now
|
||||
// in the block and doesn't need WAL replay.
|
||||
lastSeg := h.wal.LastSegment()
|
||||
if err := h.wal.Truncate(lastSeg); err != nil {
|
||||
return ulid, fmt.Errorf("head: truncate WAL: %w", err)
|
||||
}
|
||||
|
||||
return ulid, nil
|
||||
}
|
||||
|
||||
// DataDir returns the data directory (parent of WAL dir).
|
||||
func (h *Head) DataDir() string {
|
||||
return h.dataDir
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +70,18 @@ func (sm *seriesMap) set(hash uint64, s *memSeries) {
|
||||
rs.mu.Unlock()
|
||||
}
|
||||
|
||||
// forEach calls fn for every series in the map. The series lock is NOT held.
|
||||
func (sm *seriesMap) forEach(fn func(s *memSeries)) {
|
||||
for i := range sm.refStripes {
|
||||
rs := &sm.refStripes[i]
|
||||
rs.mu.RLock()
|
||||
for _, s := range rs.m {
|
||||
fn(s)
|
||||
}
|
||||
rs.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// remove deletes a series from both maps.
|
||||
func (sm *seriesMap) remove(hash uint64, s *memSeries) {
|
||||
hs := &sm.hashStripes[hash%numStripes]
|
||||
|
||||
Reference in New Issue
Block a user