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
+332
View File
@@ -0,0 +1,332 @@
package block
import (
"math"
"math/rand"
"os"
"path/filepath"
"testing"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type sample struct {
t int64
vBits uint64
}
func s(t int64, v float64) sample { return sample{t, math.Float64bits(v)} }
// makeChunk creates a chunk with the given samples and returns its raw bytes.
func makeChunk(t *testing.T, samples []sample) []byte {
t.Helper()
c := chunkenc.NewXORChunk()
a, err := c.Appender()
require.NoError(t, err)
for _, s := range samples {
a.Append(s.t, math.Float64frombits(s.vBits))
}
return append([]byte(nil), c.Bytes()...)
}
// collectIterator reads all samples from a ChunkIterator.
func collectIterator(t *testing.T, it chunkenc.ChunkIterator) []sample {
t.Helper()
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
}
func TestBlockRoundTrip(t *testing.T) {
tests := []struct {
name string
series []struct {
ref uint64
labels []labels.Label
samples []sample
}
}{
{
name: "single_series_single_chunk",
series: []struct {
ref uint64
labels []labels.Label
samples []sample
}{
{
ref: 1,
labels: []labels.Label{{Name: "__name__", Value: "temp"}},
samples: []sample{
s(1000, 71.3), s(1015, 71.4), s(1030, 71.5),
},
},
},
},
{
name: "multiple_series",
series: []struct {
ref uint64
labels []labels.Label
samples []sample
}{
{
ref: 1,
labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}},
samples: []sample{
s(1000, 71.3), s(1015, 71.4), s(1030, 71.5),
},
},
{
ref: 2,
labels: []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}},
samples: []sample{
s(1000, 55.0), s(1015, 54.5), s(1030, 54.0),
},
},
},
},
{
name: "large_chunk",
series: []struct {
ref uint64
labels []labels.Label
samples []sample
}{
{
ref: 1,
labels: []labels.Label{{Name: "__name__", Value: "counter"}},
samples: func() []sample {
rnd := rand.New(rand.NewSource(42))
out := make([]sample, 120)
ts, v := int64(0), 0.0
for i := range out {
out[i] = s(ts, v)
ts += 15000 + int64(rnd.Intn(100)) - 50
v += rnd.Float64()
}
return out
}(),
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dataDir := t.TempDir()
// Build SeriesFlush data.
var flushData []SeriesFlush
for _, s := range tc.series {
data := makeChunk(t, s.samples)
minT, maxT := s.samples[0].t, s.samples[len(s.samples)-1].t
flushData = append(flushData, SeriesFlush{
Ref: s.ref,
Labels: s.labels,
Chunks: []ChunkData{{MinT: minT, MaxT: maxT, Data: data}},
})
}
// Write block.
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
require.NotEmpty(t, ulid)
// Open block for reading.
blockDir := filepath.Join(dataDir, ulid)
r, err := Open(blockDir)
require.NoError(t, err)
defer r.Close()
// Verify meta.
assert.Equal(t, ulid, r.Meta.ULID)
assert.Equal(t, 1, r.Meta.Version)
assert.Equal(t, len(tc.series), r.Meta.Stats.NumSeries)
assert.Equal(t, len(tc.series), r.Meta.Stats.NumChunks)
// Verify each series' data via iteration.
for _, s := range tc.series {
it, err := r.SeriesChunkIterator(s.ref, math.MinInt64, math.MaxInt64)
require.NoError(t, err)
got := collectIterator(t, it)
require.Equal(t, len(s.samples), len(got), "ref %d sample count", s.ref)
for i, want := range s.samples {
assert.Equal(t, want.t, got[i].t, "ref %d sample %d t", s.ref, i)
assert.Equal(t, want.vBits, got[i].vBits, "ref %d sample %d v", s.ref, i)
}
}
// Verify postings.
for _, s := range tc.series {
for _, l := range s.labels {
refs := r.Postings(l.Name, l.Value)
assert.Contains(t, refs, s.ref, "postings for %s=%s", l.Name, l.Value)
}
}
// Verify labels lookup.
for _, s := range tc.series {
ls, ok := r.Labels(s.ref)
assert.True(t, ok)
assert.Equal(t, s.labels, ls)
}
})
}
}
func TestBlockMultipleChunksPerSeries(t *testing.T) {
dataDir := t.TempDir()
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...)
flushData := []SeriesFlush{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}},
Chunks: []ChunkData{
{MinT: 1000, MaxT: 1030, Data: makeChunk(t, chunk1Samples)},
{MinT: 2000, MaxT: 2030, Data: makeChunk(t, chunk2Samples)},
},
},
}
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)
}
// 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{
{
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)})}},
},
{
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)})}},
},
}
ulid, err := Flush(dataDir, flushData)
require.NoError(t, err)
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)
}
func TestULIDRoundTrip(t *testing.T) {
for i := 0; i < 100; i++ {
u := newULID()
assert.Equal(t, 26, len(u), "ULID length")
// Should parse without error.
_, err := parseULID(u)
assert.NoError(t, err, "parse ULID %q", u)
}
}
func TestCorruptChunkCRC(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)
series := r.Series()
require.Equal(t, 1, len(series))
chunkRef := series[0].Chunks[0].Ref
r.Close()
// Corrupt chunk data on disk.
chunkSeg := chunkRef.Segment()
chunkPath := filepath.Join(blockDir, chunksDirName, segmentName(int(chunkSeg)))
// 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
}
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)
}
+174
View File
@@ -0,0 +1,174 @@
package block
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"sort"
"syscall"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/internal/index"
)
var (
ErrInvalidChunkMagic = errors.New("block: invalid chunk file magic")
ErrInvalidChunkVersion = errors.New("block: unsupported chunk file version")
ErrCorruptChunk = errors.New("block: corrupt chunk (CRC mismatch)")
ErrChunkNotFound = errors.New("block: chunk ref out of bounds")
)
// chunkReader reads chunk data from mmap'd segment files.
type chunkReader struct {
segments map[int][]byte // segment index -> mmap'd data
}
func newChunkReader(blockDir string) (*chunkReader, error) {
chunksDir := filepath.Join(blockDir, chunksDirName)
entries, err := os.ReadDir(chunksDir)
if err != nil {
return nil, err
}
cr := &chunkReader{segments: make(map[int][]byte)}
for _, e := range entries {
if e.IsDir() {
continue
}
idx := parseSegmentName(e.Name())
if idx < 0 {
continue
}
data, err := mmapFile(filepath.Join(chunksDir, e.Name()))
if err != nil {
cr.close()
return nil, fmt.Errorf("block: mmap segment %s: %w", e.Name(), err)
}
// Validate header.
if len(data) < chunkHeaderLen {
cr.close()
return nil, ErrInvalidChunkMagic
}
magic := binary.BigEndian.Uint32(data[:4])
if magic != chunkMagic {
cr.close()
return nil, ErrInvalidChunkMagic
}
if data[4] != chunkVersion {
cr.close()
return nil, ErrInvalidChunkVersion
}
cr.segments[idx] = data
}
return cr, nil
}
// chunkData reads the raw chunk bytes at the given ref, validates CRC.
func (cr *chunkReader) chunkData(ref index.ChunkRef) ([]byte, error) {
seg := int(ref.Segment())
off := int(ref.Offset())
data, ok := cr.segments[seg]
if !ok {
return nil, ErrChunkNotFound
}
if off+chunkEntryHeaderLen > len(data) {
return nil, ErrChunkNotFound
}
dataLen := int(binary.BigEndian.Uint32(data[off : off+4]))
encoding := data[off+4]
off += chunkEntryHeaderLen
end := off + dataLen + chunkEntryCRCLen
if end > len(data) {
return nil, ErrChunkNotFound
}
chunkBytes := data[off : off+dataLen]
off += dataLen
// Validate CRC.
wantCRC := binary.BigEndian.Uint32(data[off : off+4])
crc := crc32.New(castagnoliTable)
crc.Write([]byte{encoding})
crc.Write(chunkBytes)
if crc.Sum32() != wantCRC {
return nil, ErrCorruptChunk
}
return chunkBytes, nil
}
// chunkIterator returns a ChunkIterator for the chunk at the given ref.
func (cr *chunkReader) chunkIterator(ref index.ChunkRef) (chunkenc.ChunkIterator, error) {
data, err := cr.chunkData(ref)
if err != nil {
return nil, err
}
return chunkenc.XORChunkFromBytes(data).Iterator(), nil
}
func (cr *chunkReader) close() error {
for _, data := range cr.segments {
syscall.Munmap(data)
}
cr.segments = nil
return nil
}
// segmentIndices returns sorted segment indices.
func (cr *chunkReader) segmentIndices() []int {
idxs := make([]int, 0, len(cr.segments))
for idx := range cr.segments {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
func mmapFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() == 0 {
return nil, fmt.Errorf("block: empty file %s", path)
}
data, err := syscall.Mmap(int(f.Fd()), 0, int(info.Size()),
syscall.PROT_READ, syscall.MAP_PRIVATE)
if err != nil {
return nil, err
}
return data, nil
}
func parseSegmentName(name string) int {
if len(name) != 6 {
return -1
}
n := 0
for _, c := range name {
if c < '0' || c > '9' {
return -1
}
n = n*10 + int(c-'0')
}
return n
}
+133
View File
@@ -0,0 +1,133 @@
package block
import (
"encoding/binary"
"hash/crc32"
"os"
"path/filepath"
"git.dvdt.dev/david/ingot/internal/index"
)
const (
chunkMagic uint32 = 0x0FACE5C4
chunkVersion byte = 1
chunkHeaderLen = 5 // 4-byte magic + 1-byte version
chunkEntryHeaderLen = 5 // 4-byte length + 1-byte encoding
chunkEntryCRCLen = 4
encodingXOR byte = 1
chunkSegmentMaxSize = 512 * 1024 * 1024 // 512 MiB
chunksDirName = "chunks"
)
var castagnoliTable = crc32.MakeTable(crc32.Castagnoli)
// chunkWriter writes chunk data to segmented chunk files inside a block directory.
type chunkWriter struct {
dir string
segmentIdx int
segmentOff int
f *os.File
}
func newChunkWriter(blockDir string) (*chunkWriter, error) {
chunksDir := filepath.Join(blockDir, chunksDirName)
if err := os.MkdirAll(chunksDir, 0755); err != nil {
return nil, err
}
cw := &chunkWriter{dir: chunksDir, segmentIdx: 1}
if err := cw.newSegment(); err != nil {
return nil, err
}
return cw, nil
}
func (cw *chunkWriter) newSegment() error {
if cw.f != nil {
if err := cw.f.Sync(); err != nil {
return err
}
if err := cw.f.Close(); err != nil {
return err
}
}
name := segmentName(cw.segmentIdx)
f, err := os.Create(filepath.Join(cw.dir, name))
if err != nil {
return err
}
cw.f = f
cw.segmentOff = 0
// Write chunk file header.
var hdr [chunkHeaderLen]byte
binary.BigEndian.PutUint32(hdr[:4], chunkMagic)
hdr[4] = chunkVersion
n, err := f.Write(hdr[:])
cw.segmentOff += n
return err
}
// writeChunk writes a chunk entry and returns its ChunkRef.
// Format: dataLen(4) | encoding(1) | data(dataLen) | CRC32C(4)
func (cw *chunkWriter) writeChunk(data []byte) (index.ChunkRef, error) {
entrySize := chunkEntryHeaderLen + len(data) + chunkEntryCRCLen
// Rotate if this entry would exceed segment max size.
if cw.segmentOff+entrySize > chunkSegmentMaxSize {
cw.segmentIdx++
if err := cw.newSegment(); err != nil {
return 0, err
}
}
ref := index.NewChunkRef(uint32(cw.segmentIdx), uint32(cw.segmentOff))
// Header: length + encoding.
var hdr [chunkEntryHeaderLen]byte
binary.BigEndian.PutUint32(hdr[:4], uint32(len(data)))
hdr[4] = encodingXOR
if _, err := cw.f.Write(hdr[:]); err != nil {
return 0, err
}
// Chunk data.
if _, err := cw.f.Write(data); err != nil {
return 0, err
}
// CRC over encoding + data.
crc := crc32.New(castagnoliTable)
crc.Write(hdr[4:5]) // encoding byte
crc.Write(data)
var crcBuf [4]byte
binary.BigEndian.PutUint32(crcBuf[:], crc.Sum32())
if _, err := cw.f.Write(crcBuf[:]); err != nil {
return 0, err
}
cw.segmentOff += entrySize
return ref, nil
}
func (cw *chunkWriter) close() error {
if cw.f == nil {
return nil
}
if err := cw.f.Sync(); err != nil {
cw.f.Close()
return err
}
return cw.f.Close()
}
func segmentName(idx int) string {
var buf [6]byte
s := idx
for i := 5; i >= 0; i-- {
buf[i] = '0' + byte(s%10)
s /= 10
}
return string(buf[:])
}
+55
View File
@@ -0,0 +1,55 @@
package block
import (
"encoding/json"
"os"
"path/filepath"
)
// BlockMeta describes a block on disk.
type BlockMeta struct {
ULID string `json:"ulid"`
MinTime int64 `json:"minTime"`
MaxTime int64 `json:"maxTime"`
Stats BlockStats `json:"stats"`
Compaction CompactionInfo `json:"compaction"`
Version int `json:"version"`
}
// BlockStats holds summary statistics for a block.
type BlockStats struct {
NumSamples int `json:"numSamples"`
NumSeries int `json:"numSeries"`
NumChunks int `json:"numChunks"`
}
// CompactionInfo records the block's compaction lineage.
type CompactionInfo struct {
Level int `json:"level"`
Sources []string `json:"sources"`
}
const metaFilename = "meta.json"
// readMeta reads a block's meta.json from the given block directory.
func readMeta(dir string) (BlockMeta, error) {
data, err := os.ReadFile(filepath.Join(dir, metaFilename))
if err != nil {
return BlockMeta{}, err
}
var m BlockMeta
if err := json.Unmarshal(data, &m); err != nil {
return BlockMeta{}, err
}
return m, nil
}
// writeMeta writes a block's meta.json to the given block directory.
// This is the last file written when creating a block — the immutability gate.
func writeMeta(dir string, m BlockMeta) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, metaFilename), data, 0644)
}
+147
View File
@@ -0,0 +1,147 @@
package block
import (
"os"
"path/filepath"
"git.dvdt.dev/david/ingot/internal/chunkenc"
"git.dvdt.dev/david/ingot/internal/index"
"git.dvdt.dev/david/ingot/labels"
)
// Reader provides read access to an immutable on-disk block.
type Reader struct {
dir string
Meta BlockMeta
idx *index.Reader
chunks *chunkReader
}
// Open opens a block directory for reading. Chunk files are mmap'd.
func Open(dir string) (*Reader, error) {
meta, err := readMeta(dir)
if err != nil {
return nil, err
}
// Read the index file into memory.
indexData, err := os.ReadFile(filepath.Join(dir, "index"))
if err != nil {
return nil, err
}
idx, err := index.NewReader(indexData)
if err != nil {
return nil, err
}
cr, err := newChunkReader(dir)
if err != nil {
return nil, err
}
return &Reader{
dir: dir,
Meta: meta,
idx: idx,
chunks: cr,
}, nil
}
// Series returns all series entries from the index.
func (r *Reader) Series() []index.SeriesEntry {
return r.idx.Series()
}
// SeriesByRef looks up a series by ref.
func (r *Reader) SeriesByRef(ref uint64) (index.SeriesEntry, bool) {
return r.idx.SeriesByRef(ref)
}
// Postings returns sorted series refs matching label name=value.
func (r *Reader) Postings(name, value string) []uint64 {
return r.idx.Postings(name, value)
}
// ChunkIterator returns an iterator for a chunk at the given ref.
func (r *Reader) ChunkIterator(ref index.ChunkRef) (chunkenc.ChunkIterator, error) {
return r.chunks.chunkIterator(ref)
}
// SeriesChunkIterator returns an iterator over all chunks for a series in a
// time range. Chunks are iterated in order.
func (r *Reader) SeriesChunkIterator(ref uint64, mint, maxt int64) (chunkenc.ChunkIterator, error) {
entry, ok := r.idx.SeriesByRef(ref)
if !ok {
return &emptyIterator{}, nil
}
var iters []chunkenc.ChunkIterator
for _, cm := range entry.Chunks {
if cm.MaxT < mint || cm.MinT > maxt {
continue
}
it, err := r.chunks.chunkIterator(cm.Ref)
if err != nil {
return nil, err
}
iters = append(iters, it)
}
if len(iters) == 0 {
return &emptyIterator{}, nil
}
if len(iters) == 1 {
return iters[0], nil
}
return &multiIterator{iters: iters}, nil
}
// Labels returns the labels for a series by ref.
func (r *Reader) Labels(ref uint64) ([]labels.Label, bool) {
entry, ok := r.idx.SeriesByRef(ref)
if !ok {
return nil, false
}
return entry.Labels, true
}
// Close releases all resources (munmaps chunk files).
func (r *Reader) Close() error {
return r.chunks.close()
}
// multiIterator chains multiple ChunkIterators in order.
type multiIterator struct {
iters []chunkenc.ChunkIterator
cur int
}
func (m *multiIterator) Next() bool {
for m.cur < len(m.iters) {
if m.iters[m.cur].Next() {
return true
}
if m.iters[m.cur].Err() != nil {
return false
}
m.cur++
}
return false
}
func (m *multiIterator) At() (int64, float64) {
return m.iters[m.cur].At()
}
func (m *multiIterator) Err() error {
if m.cur < len(m.iters) {
return m.iters[m.cur].Err()
}
return nil
}
type emptyIterator struct{}
func (e *emptyIterator) Next() bool { return false }
func (e *emptyIterator) At() (int64, float64) { return 0, 0 }
func (e *emptyIterator) Err() error { return nil }
+119
View File
@@ -0,0 +1,119 @@
package block
import (
"crypto/rand"
"encoding/binary"
"errors"
"strings"
"time"
)
// ULID encoding alphabet (Crockford's Base32).
const ulidEncoding = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// newULID generates a ULID with the current time and random entropy.
// Zero external dependencies — the encoding is simple enough to inline.
func newULID() string {
var b [16]byte
// Timestamp: upper 48 bits = milliseconds since epoch.
ms := uint64(time.Now().UnixMilli())
b[0] = byte(ms >> 40)
b[1] = byte(ms >> 32)
b[2] = byte(ms >> 24)
b[3] = byte(ms >> 16)
b[4] = byte(ms >> 8)
b[5] = byte(ms)
// Randomness: lower 80 bits.
rand.Read(b[6:])
return encodeULID(b)
}
// parseULID decodes a ULID string back to its 16-byte binary form.
func parseULID(s string) ([16]byte, error) {
var b [16]byte
if len(s) != 26 {
return b, errInvalidULID
}
// Decode 26 base32 chars -> 130 bits -> 16 bytes + 2 spare bits.
var val [26]byte
for i := 0; i < 26; i++ {
idx := strings.IndexByte(ulidEncoding, s[i])
if idx < 0 {
// Try lowercase.
idx = strings.IndexByte(ulidEncoding, s[i]&^0x20)
}
if idx < 0 {
return b, errInvalidULID
}
val[i] = byte(idx)
}
// Pack 5-bit groups into bytes.
b[0] = val[0]<<5 | val[1]
b[1] = val[2]<<3 | val[3]>>2
b[2] = val[3]<<6 | val[4]<<1 | val[5]>>4
b[3] = val[5]<<4 | val[6]>>1
b[4] = val[6]<<7 | val[7]<<2 | val[8]>>3
b[5] = val[8]<<5 | val[9]
b[6] = val[10]<<3 | val[11]>>2
b[7] = val[11]<<6 | val[12]<<1 | val[13]>>4
b[8] = val[13]<<4 | val[14]>>1
b[9] = val[14]<<7 | val[15]<<2 | val[16]>>3
b[10] = val[16]<<5 | val[17]
b[11] = val[18]<<3 | val[19]>>2
b[12] = val[19]<<6 | val[20]<<1 | val[21]>>4
b[13] = val[21]<<4 | val[22]>>1
b[14] = val[22]<<7 | val[23]<<2 | val[24]>>3
b[15] = val[24]<<5 | val[25]
return b, nil
}
// ulidTime extracts the millisecond timestamp from a ULID string.
func ulidTime(s string) (int64, error) {
b, err := parseULID(s)
if err != nil {
return 0, err
}
// Timestamp is in the upper 6 bytes.
ms := int64(binary.BigEndian.Uint64(append([]byte{0, 0}, b[:6]...)))
return ms, nil
}
func encodeULID(b [16]byte) string {
// 16 bytes = 128 bits, encoded in 26 base32 chars (130 bits, 2 spare).
var dst [26]byte
dst[0] = ulidEncoding[(b[0]&0xE0)>>5]
dst[1] = ulidEncoding[b[0]&0x1F]
dst[2] = ulidEncoding[(b[1]&0xF8)>>3]
dst[3] = ulidEncoding[(b[1]&0x07)<<2|(b[2]&0xC0)>>6]
dst[4] = ulidEncoding[(b[2]&0x3E)>>1]
dst[5] = ulidEncoding[(b[2]&0x01)<<4|(b[3]&0xF0)>>4]
dst[6] = ulidEncoding[(b[3]&0x0F)<<1|(b[4]&0x80)>>7]
dst[7] = ulidEncoding[(b[4]&0x7C)>>2]
dst[8] = ulidEncoding[(b[4]&0x03)<<3|(b[5]&0xE0)>>5]
dst[9] = ulidEncoding[b[5]&0x1F]
dst[10] = ulidEncoding[(b[6]&0xF8)>>3]
dst[11] = ulidEncoding[(b[6]&0x07)<<2|(b[7]&0xC0)>>6]
dst[12] = ulidEncoding[(b[7]&0x3E)>>1]
dst[13] = ulidEncoding[(b[7]&0x01)<<4|(b[8]&0xF0)>>4]
dst[14] = ulidEncoding[(b[8]&0x0F)<<1|(b[9]&0x80)>>7]
dst[15] = ulidEncoding[(b[9]&0x7C)>>2]
dst[16] = ulidEncoding[(b[9]&0x03)<<3|(b[10]&0xE0)>>5]
dst[17] = ulidEncoding[b[10]&0x1F]
dst[18] = ulidEncoding[(b[11]&0xF8)>>3]
dst[19] = ulidEncoding[(b[11]&0x07)<<2|(b[12]&0xC0)>>6]
dst[20] = ulidEncoding[(b[12]&0x3E)>>1]
dst[21] = ulidEncoding[(b[12]&0x01)<<4|(b[13]&0xF0)>>4]
dst[22] = ulidEncoding[(b[13]&0x0F)<<1|(b[14]&0x80)>>7]
dst[23] = ulidEncoding[(b[14]&0x7C)>>2]
dst[24] = ulidEncoding[(b[14]&0x03)<<3|(b[15]&0xE0)>>5]
dst[25] = ulidEncoding[b[15]&0x1F]
return string(dst[:])
}
var errInvalidULID = errors.New("block: invalid ULID")
+141
View File
@@ -0,0 +1,141 @@
package block
import (
"os"
"path/filepath"
"git.dvdt.dev/david/ingot/internal/index"
"git.dvdt.dev/david/ingot/labels"
)
// ChunkData describes a single chunk to be flushed to a block.
type ChunkData struct {
MinT int64
MaxT int64
Data []byte // raw XOR chunk bytes (including 2-byte sample count header)
}
// SeriesFlush describes a series with its sealed chunks for block writing.
type SeriesFlush struct {
Ref uint64
Labels []labels.Label
Chunks []ChunkData
}
// Flush writes a new immutable block from the given series data.
// It creates a ULID-named directory under dataDir containing:
// - chunks/ with segment files
// - index file
// - meta.json (written last as the immutability gate)
//
// Returns the block ULID and any error.
func Flush(dataDir string, series []SeriesFlush) (string, error) {
ulid := newULID()
blockDir := filepath.Join(dataDir, ulid)
if err := os.MkdirAll(blockDir, 0755); err != nil {
return "", err
}
// Write chunk files and collect index entries.
cw, err := newChunkWriter(blockDir)
if err != nil {
return "", err
}
var (
indexEntries []index.SeriesEntry
meta BlockMeta
)
meta.ULID = ulid
meta.Version = 1
meta.Compaction = CompactionInfo{Level: 1, Sources: []string{ulid}}
meta.MinTime = int64(^uint64(0) >> 1) // max int64
meta.MaxTime = int64(0)
for _, sf := range series {
var chunks []index.ChunkMeta
for _, cd := range sf.Chunks {
ref, err := cw.writeChunk(cd.Data)
if err != nil {
cw.close()
return "", err
}
chunks = append(chunks, index.ChunkMeta{
MinT: cd.MinT,
MaxT: cd.MaxT,
Ref: ref,
})
if cd.MinT < meta.MinTime {
meta.MinTime = cd.MinT
}
if cd.MaxT > meta.MaxTime {
meta.MaxTime = cd.MaxT
}
meta.Stats.NumChunks++
// Count samples from the chunk's 2-byte header.
if len(cd.Data) >= 2 {
meta.Stats.NumSamples += int(uint16(cd.Data[0])<<8 | uint16(cd.Data[1]))
}
}
indexEntries = append(indexEntries, index.SeriesEntry{
Ref: sf.Ref,
Labels: sf.Labels,
Chunks: chunks,
})
meta.Stats.NumSeries++
}
if err := cw.close(); err != nil {
return "", err
}
// Write index file.
indexPath := filepath.Join(blockDir, "index")
indexFile, err := os.Create(indexPath)
if err != nil {
return "", err
}
iw := index.NewWriter(indexFile)
for _, e := range indexEntries {
iw.AddSeries(e)
}
if _, err := iw.WriteTo(); err != nil {
indexFile.Close()
return "", err
}
if err := indexFile.Sync(); err != nil {
indexFile.Close()
return "", err
}
if err := indexFile.Close(); err != nil {
return "", err
}
// Fsync the block directory to ensure all files are durable.
if err := syncDir(blockDir); err != nil {
return "", err
}
// Write meta.json last — the immutability gate.
if err := writeMeta(blockDir, meta); err != nil {
return "", err
}
// Fsync the data directory so the block directory entry is visible.
if err := syncDir(dataDir); err != nil {
return "", err
}
return ulid, nil
}
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
+9
View File
@@ -28,6 +28,15 @@ func NewXORChunk() *XORChunk {
return &XORChunk{b: bstream{stream: make([]byte, 2), count: 0}}
}
// XORChunkFromBytes creates a read-only XORChunk from raw bytes.
// The data must include the 2-byte sample count header (as returned by Bytes).
// The returned chunk supports Iterator, NumSamples, and Bytes but not Appender.
func XORChunkFromBytes(data []byte) *XORChunk {
cp := make([]byte, len(data))
copy(cp, data)
return &XORChunk{b: bstream{stream: cp}}
}
func (c *XORChunk) NumSamples() int {
return int(binary.BigEndian.Uint16(c.b.bytes()))
}
+52
View File
@@ -298,6 +298,58 @@ func TestXORChunk(t *testing.T) {
}
}
func TestXORChunkFromBytes(t *testing.T) {
rnd := rand.New(rand.NewSource(99))
tests := []struct {
name string
samples [][2]float64
}{
{"single", [][2]float64{{1000, 71.3}}},
{"two", [][2]float64{{1000, 71.3}, {1015, 71.4}}},
{"full_chunk", func() [][2]float64 {
out := make([][2]float64, 120)
ts, v := int64(0), 70.0
for i := range out {
out[i] = [2]float64{float64(ts), v}
ts += 15000 + int64(rnd.Intn(100)) - 50
v += rnd.Float64() - 0.5
}
return out
}()},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
orig := NewXORChunk()
a, _ := orig.Appender()
for _, s := range tc.samples {
a.Append(int64(s[0]), s[1])
}
reconstituted := XORChunkFromBytes(orig.Bytes())
assert.Equal(t, orig.NumSamples(), reconstituted.NumSamples())
assert.Equal(t, orig.Bytes(), reconstituted.Bytes())
// Verify iteration produces identical samples.
it := reconstituted.Iterator()
for i, s := range tc.samples {
assert.True(t, it.Next(), "sample %d", i)
gotT, gotV := it.At()
assert.Equal(t, int64(s[0]), gotT, "sample %d t", i)
assert.Equal(t, math.Float64bits(s[1]), math.Float64bits(gotV), "sample %d v", i)
}
assert.False(t, it.Next())
assert.NoError(t, it.Err())
// Appender on non-empty reconstituted chunk should fail.
_, err := reconstituted.Appender()
assert.Error(t, err)
})
}
}
func FuzzXORIterator(f *testing.F) {
c := NewXORChunk()
a, _ := c.Appender()
+73
View File
@@ -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,6 +34,7 @@ func Open(walDir string, walOpts wal.Options) (*Head, error) {
}
h := &Head{
dataDir: filepath.Dir(walDir),
series: newSeriesMap(),
wal: w,
}
@@ -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
}
+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
+12
View File
@@ -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]
+54
View File
@@ -0,0 +1,54 @@
// Package index implements the binary index file format for ingot blocks.
//
// An index file contains four sections:
//
// [Header 5B] [Symbol Table] [Series] [Postings] [TOC 28B]
//
// The TOC at the end stores offsets to each section. Readers seek to the
// end, read the TOC, and use the offsets to locate each section.
package index
import (
"errors"
"git.dvdt.dev/david/ingot/labels"
)
const (
indexMagic uint32 = 0x0FACEDB1
indexVersion byte = 1
headerLen = 5 // 4-byte magic + 1-byte version
tocLen = 28 // 3 offsets (24) + CRC (4)
)
var (
ErrInvalidMagic = errors.New("index: invalid magic number")
ErrInvalidVersion = errors.New("index: unsupported version")
ErrCorruptTOC = errors.New("index: corrupt TOC (CRC mismatch)")
ErrCorruptIndex = errors.New("index: corrupt index data")
ErrTooShort = errors.New("index: file too short")
)
// ChunkRef encodes a chunk's location as (segment << 32 | offset).
type ChunkRef uint64
func NewChunkRef(segment, offset uint32) ChunkRef {
return ChunkRef(uint64(segment)<<32 | uint64(offset))
}
func (r ChunkRef) Segment() uint32 { return uint32(r >> 32) }
func (r ChunkRef) Offset() uint32 { return uint32(r) }
// ChunkMeta describes a chunk's time range and location in a chunk file.
type ChunkMeta struct {
MinT int64
MaxT int64
Ref ChunkRef
}
// SeriesEntry is the input for writing and the output for reading a series.
type SeriesEntry struct {
Ref uint64
Labels []labels.Label
Chunks []ChunkMeta
}
+208
View File
@@ -0,0 +1,208 @@
package index
import (
"bytes"
"testing"
"git.dvdt.dev/david/ingot/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeIndex(t *testing.T, entries []SeriesEntry) []byte {
t.Helper()
var buf bytes.Buffer
w := NewWriter(&buf)
for _, e := range entries {
w.AddSeries(e)
}
_, err := w.WriteTo()
require.NoError(t, err)
return buf.Bytes()
}
func TestIndexRoundTrip(t *testing.T) {
tests := []struct {
name string
entries []SeriesEntry
}{
{
name: "single_series_single_chunk",
entries: []SeriesEntry{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}},
Chunks: []ChunkMeta{{MinT: 1000, MaxT: 2000, Ref: NewChunkRef(1, 0)}},
},
},
},
{
name: "single_series_multiple_chunks",
entries: []SeriesEntry{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}},
Chunks: []ChunkMeta{
{MinT: 1000, MaxT: 2000, Ref: NewChunkRef(1, 0)},
{MinT: 2001, MaxT: 3000, Ref: NewChunkRef(1, 500)},
{MinT: 3001, MaxT: 4000, Ref: NewChunkRef(1, 1000)},
},
},
},
},
{
name: "multiple_series",
entries: []SeriesEntry{
{
Ref: 1,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}},
Chunks: []ChunkMeta{{MinT: 1000, MaxT: 2000, Ref: NewChunkRef(1, 0)}},
},
{
Ref: 2,
Labels: []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}},
Chunks: []ChunkMeta{{MinT: 1000, MaxT: 2000, Ref: NewChunkRef(1, 200)}},
},
{
Ref: 3,
Labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}},
Chunks: []ChunkMeta{{MinT: 1000, MaxT: 2000, Ref: NewChunkRef(1, 400)}},
},
},
},
{
name: "empty",
entries: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
data := writeIndex(t, tc.entries)
r, err := NewReader(data)
require.NoError(t, err)
// Verify series count and content.
gotSeries := r.Series()
require.Equal(t, len(tc.entries), len(gotSeries))
for i, want := range tc.entries {
got := gotSeries[i]
assert.Equal(t, want.Ref, got.Ref, "series %d ref", i)
assert.Equal(t, want.Labels, got.Labels, "series %d labels", i)
require.Equal(t, len(want.Chunks), len(got.Chunks), "series %d chunk count", i)
for j, wc := range want.Chunks {
assert.Equal(t, wc.MinT, got.Chunks[j].MinT, "series %d chunk %d minT", i, j)
assert.Equal(t, wc.MaxT, got.Chunks[j].MaxT, "series %d chunk %d maxT", i, j)
assert.Equal(t, wc.Ref, got.Chunks[j].Ref, "series %d chunk %d ref", i, j)
}
// SeriesByRef lookup.
byRef, ok := r.SeriesByRef(want.Ref)
assert.True(t, ok, "series %d lookup by ref", i)
assert.Equal(t, want.Ref, byRef.Ref)
}
// Verify postings.
for _, e := range tc.entries {
for _, l := range e.Labels {
refs := r.Postings(l.Name, l.Value)
assert.Contains(t, refs, e.Ref, "postings for %s=%s should contain ref %d", l.Name, l.Value, e.Ref)
}
}
// Verify missing lookups return empty/false.
_, ok := r.SeriesByRef(999999)
assert.False(t, ok)
assert.Nil(t, r.Postings("nonexistent", "value"))
})
}
}
func TestIndexPostingsSorted(t *testing.T) {
entries := []SeriesEntry{
{Ref: 5, Labels: []labels.Label{{Name: "room", Value: "office"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}},
{Ref: 2, Labels: []labels.Label{{Name: "room", Value: "office"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}},
{Ref: 8, Labels: []labels.Label{{Name: "room", Value: "office"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}},
}
data := writeIndex(t, entries)
r, err := NewReader(data)
require.NoError(t, err)
refs := r.Postings("room", "office")
require.Equal(t, 3, len(refs))
assert.Equal(t, uint64(2), refs[0])
assert.Equal(t, uint64(5), refs[1])
assert.Equal(t, uint64(8), refs[2])
}
func TestIndexChunkRefEncoding(t *testing.T) {
tests := []struct {
name string
segment uint32
offset uint32
}{
{"zero", 0, 0},
{"first_segment", 1, 0},
{"with_offset", 1, 12345},
{"large_values", 100, 536870912},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ref := NewChunkRef(tc.segment, tc.offset)
assert.Equal(t, tc.segment, ref.Segment())
assert.Equal(t, tc.offset, ref.Offset())
})
}
}
func TestIndexCorruptData(t *testing.T) {
tests := []struct {
name string
data []byte
wantErr error
}{
{
name: "too_short",
data: []byte{1, 2, 3},
wantErr: ErrTooShort,
},
{
name: "bad_magic",
data: func() []byte {
d := writeIndex(t, nil)
d[0] = 0xFF
return d
}(),
wantErr: ErrInvalidMagic,
},
{
name: "bad_version",
data: func() []byte {
d := writeIndex(t, nil)
d[4] = 99
return d
}(),
wantErr: ErrInvalidVersion,
},
{
name: "corrupt_toc_crc",
data: func() []byte {
d := writeIndex(t, nil)
d[len(d)-1] ^= 0xFF // flip CRC bits
return d
}(),
wantErr: ErrCorruptTOC,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewReader(tc.data)
assert.Equal(t, tc.wantErr, err)
})
}
}
+215
View File
@@ -0,0 +1,215 @@
package index
import (
"encoding/binary"
"hash/crc32"
"git.dvdt.dev/david/ingot/labels"
)
// Reader reads an index from a byte slice (typically mmap'd).
type Reader struct {
data []byte
symbols []string
series []SeriesEntry
seriesByRef map[uint64]int // ref -> index into series
postings map[labelPair][]uint64 // label pair -> sorted refs
}
// NewReader parses an index from data.
func NewReader(data []byte) (*Reader, error) {
if len(data) < headerLen+tocLen {
return nil, ErrTooShort
}
// Validate header.
magic := binary.BigEndian.Uint32(data[:4])
if magic != indexMagic {
return nil, ErrInvalidMagic
}
if data[4] != indexVersion {
return nil, ErrInvalidVersion
}
// Read TOC from the last 28 bytes.
tocStart := len(data) - tocLen
toc := data[tocStart:]
// Validate TOC CRC.
wantCRC := binary.BigEndian.Uint32(toc[24:28])
gotCRC := crc32.Checksum(toc[:24], castagnoliTable)
if gotCRC != wantCRC {
return nil, ErrCorruptTOC
}
symbolsOff := int(binary.BigEndian.Uint64(toc[0:8]))
seriesOff := int(binary.BigEndian.Uint64(toc[8:16]))
postingsOff := int(binary.BigEndian.Uint64(toc[16:24]))
r := &Reader{
data: data,
seriesByRef: make(map[uint64]int),
postings: make(map[labelPair][]uint64),
}
if err := r.readSymbols(symbolsOff); err != nil {
return nil, err
}
if err := r.readSeries(seriesOff); err != nil {
return nil, err
}
if err := r.readPostings(postingsOff, tocStart); err != nil {
return nil, err
}
return r, nil
}
func (r *Reader) readSymbols(off int) error {
if off+4 > len(r.data) {
return ErrCorruptIndex
}
numSymbols := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
r.symbols = make([]string, 0, numSymbols)
for i := 0; i < numSymbols; i++ {
if off+2 > len(r.data) {
return ErrCorruptIndex
}
slen := int(binary.BigEndian.Uint16(r.data[off : off+2]))
off += 2
if off+slen > len(r.data) {
return ErrCorruptIndex
}
r.symbols = append(r.symbols, string(r.data[off:off+slen]))
off += slen
}
return nil
}
func (r *Reader) readSeries(off int) error {
if off+4 > len(r.data) {
return ErrCorruptIndex
}
numSeries := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
r.series = make([]SeriesEntry, 0, numSeries)
for i := 0; i < numSeries; i++ {
if off+8 > len(r.data) {
return ErrCorruptIndex
}
ref := binary.BigEndian.Uint64(r.data[off : off+8])
off += 8
if off+2 > len(r.data) {
return ErrCorruptIndex
}
numLabels := int(binary.BigEndian.Uint16(r.data[off : off+2]))
off += 2
ls := make([]labels.Label, numLabels)
for j := 0; j < numLabels; j++ {
if off+8 > len(r.data) {
return ErrCorruptIndex
}
nameIdx := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
valueIdx := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
if nameIdx >= len(r.symbols) || valueIdx >= len(r.symbols) {
return ErrCorruptIndex
}
ls[j] = labels.Label{Name: r.symbols[nameIdx], Value: r.symbols[valueIdx]}
}
if off+4 > len(r.data) {
return ErrCorruptIndex
}
numChunks := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
chunks := make([]ChunkMeta, numChunks)
for j := 0; j < numChunks; j++ {
if off+24 > len(r.data) {
return ErrCorruptIndex
}
chunks[j].MinT = int64(binary.BigEndian.Uint64(r.data[off : off+8]))
off += 8
chunks[j].MaxT = int64(binary.BigEndian.Uint64(r.data[off : off+8]))
off += 8
chunks[j].Ref = ChunkRef(binary.BigEndian.Uint64(r.data[off : off+8]))
off += 8
}
entry := SeriesEntry{Ref: ref, Labels: ls, Chunks: chunks}
r.seriesByRef[ref] = len(r.series)
r.series = append(r.series, entry)
}
return nil
}
func (r *Reader) readPostings(off, limit int) error {
if off+4 > limit {
return ErrCorruptIndex
}
numEntries := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
for i := 0; i < numEntries; i++ {
if off+12 > limit {
return ErrCorruptIndex
}
nameIdx := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
valueIdx := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
numRefs := int(binary.BigEndian.Uint32(r.data[off : off+4]))
off += 4
if nameIdx >= len(r.symbols) || valueIdx >= len(r.symbols) {
return ErrCorruptIndex
}
if off+numRefs*8 > limit {
return ErrCorruptIndex
}
refs := make([]uint64, numRefs)
for j := 0; j < numRefs; j++ {
refs[j] = binary.BigEndian.Uint64(r.data[off : off+8])
off += 8
}
key := labelPair{r.symbols[nameIdx], r.symbols[valueIdx]}
r.postings[key] = refs
}
return nil
}
// Symbols returns all symbols in the index.
func (r *Reader) Symbols() []string {
return r.symbols
}
// Series returns all series entries.
func (r *Reader) Series() []SeriesEntry {
return r.series
}
// SeriesByRef looks up a series by its ref.
func (r *Reader) SeriesByRef(ref uint64) (SeriesEntry, bool) {
idx, ok := r.seriesByRef[ref]
if !ok {
return SeriesEntry{}, false
}
return r.series[idx], true
}
// Postings returns sorted series refs for the given label pair.
func (r *Reader) Postings(name, value string) []uint64 {
return r.postings[labelPair{name, value}]
}
+273
View File
@@ -0,0 +1,273 @@
package index
import (
"encoding/binary"
"hash/crc32"
"io"
"sort"
"git.dvdt.dev/david/ingot/labels"
)
var castagnoliTable = crc32.MakeTable(crc32.Castagnoli)
// Writer builds an index file from series data.
type Writer struct {
w io.Writer
off int // bytes written so far
symbols []string // ordered symbol list
symbolIdx map[string]int // string -> index in symbols
series []SeriesEntry
postings map[labelPair][]uint64 // label pair -> sorted series refs
}
type labelPair struct {
name, value string
}
// NewWriter creates a Writer that writes to w.
func NewWriter(w io.Writer) *Writer {
return &Writer{
w: w,
symbolIdx: make(map[string]int),
postings: make(map[labelPair][]uint64),
}
}
// AddSeries adds a series to the index. Series must be added before calling
// WriteTo. Labels must be sorted.
func (iw *Writer) AddSeries(entry SeriesEntry) {
for _, l := range entry.Labels {
iw.addSymbol(l.Name)
iw.addSymbol(l.Value)
}
iw.series = append(iw.series, entry)
for _, l := range entry.Labels {
key := labelPair{l.Name, l.Value}
iw.postings[key] = append(iw.postings[key], entry.Ref)
}
}
func (iw *Writer) addSymbol(s string) {
if _, ok := iw.symbolIdx[s]; ok {
return
}
idx := len(iw.symbols)
iw.symbols = append(iw.symbols, s)
iw.symbolIdx[s] = idx
}
// WriteTo writes the complete index file. Returns bytes written and any error.
func (iw *Writer) WriteTo() (int, error) {
// Sort symbols for deterministic output.
sort.Strings(iw.symbols)
iw.symbolIdx = make(map[string]int, len(iw.symbols))
for i, s := range iw.symbols {
iw.symbolIdx[s] = i
}
iw.off = 0
// Header.
if err := iw.writeHeader(); err != nil {
return iw.off, err
}
// Symbol table.
symbolsOff := iw.off
if err := iw.writeSymbols(); err != nil {
return iw.off, err
}
// Series.
seriesOff := iw.off
if err := iw.writeSeries(); err != nil {
return iw.off, err
}
// Postings.
postingsOff := iw.off
if err := iw.writePostings(); err != nil {
return iw.off, err
}
// TOC.
if err := iw.writeTOC(symbolsOff, seriesOff, postingsOff); err != nil {
return iw.off, err
}
return iw.off, nil
}
func (iw *Writer) write(b []byte) error {
n, err := iw.w.Write(b)
iw.off += n
return err
}
func (iw *Writer) writeHeader() error {
var buf [headerLen]byte
binary.BigEndian.PutUint32(buf[:4], indexMagic)
buf[4] = indexVersion
return iw.write(buf[:])
}
func (iw *Writer) writeSymbols() error {
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], uint32(len(iw.symbols)))
if err := iw.write(buf[:]); err != nil {
return err
}
var lenbuf [2]byte
for _, s := range iw.symbols {
binary.BigEndian.PutUint16(lenbuf[:], uint16(len(s)))
if err := iw.write(lenbuf[:]); err != nil {
return err
}
if err := iw.write([]byte(s)); err != nil {
return err
}
}
return nil
}
func (iw *Writer) writeSeries() error {
var buf [8]byte
// numSeries
binary.BigEndian.PutUint32(buf[:4], uint32(len(iw.series)))
if err := iw.write(buf[:4]); err != nil {
return err
}
for _, s := range iw.series {
// ref
binary.BigEndian.PutUint64(buf[:8], s.Ref)
if err := iw.write(buf[:8]); err != nil {
return err
}
// numLabels
binary.BigEndian.PutUint16(buf[:2], uint16(len(s.Labels)))
if err := iw.write(buf[:2]); err != nil {
return err
}
// labels as symbol refs
for _, l := range s.Labels {
binary.BigEndian.PutUint32(buf[:4], uint32(iw.symbolIdx[l.Name]))
if err := iw.write(buf[:4]); err != nil {
return err
}
binary.BigEndian.PutUint32(buf[:4], uint32(iw.symbolIdx[l.Value]))
if err := iw.write(buf[:4]); err != nil {
return err
}
}
// numChunks
binary.BigEndian.PutUint32(buf[:4], uint32(len(s.Chunks)))
if err := iw.write(buf[:4]); err != nil {
return err
}
// chunks
for _, cm := range s.Chunks {
binary.BigEndian.PutUint64(buf[:8], uint64(cm.MinT))
if err := iw.write(buf[:8]); err != nil {
return err
}
binary.BigEndian.PutUint64(buf[:8], uint64(cm.MaxT))
if err := iw.write(buf[:8]); err != nil {
return err
}
binary.BigEndian.PutUint64(buf[:8], uint64(cm.Ref))
if err := iw.write(buf[:8]); err != nil {
return err
}
}
}
return nil
}
func (iw *Writer) writePostings() error {
// Sort postings keys for deterministic output.
keys := make([]labelPair, 0, len(iw.postings))
for k := range iw.postings {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].name != keys[j].name {
return keys[i].name < keys[j].name
}
return keys[i].value < keys[j].value
})
var buf [8]byte
// numEntries
binary.BigEndian.PutUint32(buf[:4], uint32(len(keys)))
if err := iw.write(buf[:4]); err != nil {
return err
}
for _, key := range keys {
refs := iw.postings[key]
// nameSymIdx
binary.BigEndian.PutUint32(buf[:4], uint32(iw.symbolIdx[key.name]))
if err := iw.write(buf[:4]); err != nil {
return err
}
// valueSymIdx
binary.BigEndian.PutUint32(buf[:4], uint32(iw.symbolIdx[key.value]))
if err := iw.write(buf[:4]); err != nil {
return err
}
// Sort refs.
sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] })
// numRefs
binary.BigEndian.PutUint32(buf[:4], uint32(len(refs)))
if err := iw.write(buf[:4]); err != nil {
return err
}
for _, ref := range refs {
binary.BigEndian.PutUint64(buf[:8], ref)
if err := iw.write(buf[:8]); err != nil {
return err
}
}
}
return nil
}
func (iw *Writer) writeTOC(symbolsOff, seriesOff, postingsOff int) error {
var buf [tocLen]byte
binary.BigEndian.PutUint64(buf[0:8], uint64(symbolsOff))
binary.BigEndian.PutUint64(buf[8:16], uint64(seriesOff))
binary.BigEndian.PutUint64(buf[16:24], uint64(postingsOff))
crc := crc32.Checksum(buf[:24], castagnoliTable)
binary.BigEndian.PutUint32(buf[24:28], crc)
return iw.write(buf[:])
}
// SymbolIndex returns the index of a symbol string, for use in lookups.
func (iw *Writer) SymbolIndex(s string) (int, bool) {
idx, ok := iw.symbolIdx[s]
return idx, ok
}
// labelValues extracts the sorted label set for a series, resolving symbol refs.
func resolveLabelNames(ls []labels.Label) []labelPair {
pairs := make([]labelPair, len(ls))
for i, l := range ls {
pairs[i] = labelPair{l.Name, l.Value}
}
return pairs
}