Files
ingot/internal/head/head.go
T
david 323a6f2951 Query oracle and API freeze
Label matchers (equal, not-equal, regexp, not-regexp) in labels/,
postings intersection/union/difference in internal/postings/,
LabelValues and AllPostings on index, block, and head readers.

ingot.go wired to real DB backed by head + block readers. Querier
snapshots overlapping blocks, Select resolves matchers to postings,
merged iterator chains blocks then head with block-wins dedup.

Oracle tests compare every query against a naive []sample reference
across head-only, block-only, head+block merge seam, multiple blocks,
all four matcher types, and time range filtering.
2026-07-04 15:19:09 -04:00

275 lines
6.4 KiB
Go

// Package head implements the in-memory series store for the active
// write window, backed by a write-ahead log for crash safety.
package head
import (
"fmt"
"path/filepath"
"sort"
"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"
)
// 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
minTime atomic.Int64
maxTime atomic.Int64
minSet atomic.Bool
}
// 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 {
return nil, err
}
h := &Head{
dataDir: filepath.Dir(walDir),
series: newSeriesMap(),
wal: w,
}
if err := h.replay(); err != nil {
w.Close()
return nil, err
}
return h, nil
}
// replay reads all WAL records and rebuilds in-memory state.
func (h *Head) replay() error {
r, err := h.wal.Replay()
if err != nil {
return err
}
defer r.Close()
for r.Next() {
rec := r.Record()
switch rec.Type {
case wal.RecordSeries:
sr, err := wal.DecodeSeriesRecord(rec.Data)
if err != nil {
return fmt.Errorf("head: replay series: %w", err)
}
h.replaySeries(sr)
case wal.RecordSamples:
samples, err := wal.DecodeSamplesRecord(rec.Data)
if err != nil {
return fmt.Errorf("head: replay samples: %w", err)
}
for _, s := range samples {
h.applySample(s.Ref, s.T, s.V)
}
}
}
return r.Err()
}
func (h *Head) replaySeries(sr wal.SeriesRecord) {
// Update nextRef so new series don't collide.
for {
cur := h.nextRef.Load()
if sr.Ref < cur {
break
}
if h.nextRef.CompareAndSwap(cur, sr.Ref+1) {
break
}
}
// Skip if already exists (idempotent replay).
hash := labels.Hash(sr.Labels)
if h.series.getByHash(hash, sr.Labels) != nil {
return
}
s := &memSeries{
ref: sr.Ref,
labels: sr.Labels,
}
h.series.set(hash, s)
}
// applySample appends a sample to the series' active chunk.
func (h *Head) applySample(ref uint64, t int64, v float64) {
s := h.series.getByRef(ref)
if s == nil {
return // series not found; skip during replay of partial WAL
}
s.mu.Lock()
s.append(t, v)
s.mu.Unlock()
// Update head time bounds.
if !h.minSet.Load() || t < h.minTime.Load() {
h.minTime.Store(t)
h.minSet.Store(true)
}
if t > h.maxTime.Load() {
h.maxTime.Store(t)
}
}
// Appender returns a new Appender for batching writes.
func (h *Head) Appender() *Appender {
return &Appender{head: h}
}
// SeriesIterator returns an iterator over all samples in [mint, maxt]
// for the given series ref.
func (h *Head) SeriesIterator(ref uint64, mint, maxt int64) chunkenc.ChunkIterator {
s := h.series.getByRef(ref)
if s == nil {
return &emptyIterator{}
}
s.mu.Lock()
defer s.mu.Unlock()
return s.iterator(mint, maxt)
}
// MinTime returns the earliest sample timestamp in the head.
func (h *Head) MinTime() int64 { return h.minTime.Load() }
// MaxTime returns the latest sample timestamp in the head.
func (h *Head) MaxTime() int64 { return h.maxTime.Load() }
// Close syncs and closes the WAL.
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
}
// Postings returns sorted series refs where the series has label name=value.
func (h *Head) Postings(name, value string) []uint64 {
var refs []uint64
h.series.forEach(func(s *memSeries) {
for _, l := range s.labels {
if l.Name == name && l.Value == value {
refs = append(refs, s.ref)
break
}
}
})
sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] })
return refs
}
// LabelValues returns sorted unique values for the given label name.
func (h *Head) LabelValues(name string) []string {
seen := make(map[string]struct{})
h.series.forEach(func(s *memSeries) {
for _, l := range s.labels {
if l.Name == name {
seen[l.Value] = struct{}{}
break
}
}
})
vals := make([]string, 0, len(seen))
for v := range seen {
vals = append(vals, v)
}
sort.Strings(vals)
return vals
}
// Labels returns the labels for a series by ref.
func (h *Head) Labels(ref uint64) ([]labels.Label, bool) {
s := h.series.getByRef(ref)
if s == nil {
return nil, false
}
return s.labels, true
}
// AllPostings returns sorted refs for all series in the head.
func (h *Head) AllPostings() []uint64 {
var refs []uint64
h.series.forEach(func(s *memSeries) {
refs = append(refs, s.ref)
})
sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] })
return refs
}
// DataDir returns the data directory (parent of WAL dir).
func (h *Head) DataDir() string {
return h.dataDir
}