diff --git a/ingot.go b/ingot.go new file mode 100644 index 0000000..b7043ed --- /dev/null +++ b/ingot.go @@ -0,0 +1,458 @@ +// Package ingot is an embedded time-series database library for Go. +package ingot + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "git.dvdt.dev/david/ingot/internal/block" + "git.dvdt.dev/david/ingot/internal/chunkenc" + "git.dvdt.dev/david/ingot/internal/head" + "git.dvdt.dev/david/ingot/internal/postings" + "git.dvdt.dev/david/ingot/internal/wal" + "git.dvdt.dev/david/ingot/labels" +) + +// DB is an embedded time-series database. +type DB struct { + dataDir string + opts Options + head *head.Head + blocks []*block.Reader // sorted by MinTime + mu sync.RWMutex // protects blocks slice +} + +// Options configures a DB. +type Options struct { + Retention time.Duration + BlockDuration time.Duration +} + +// Open opens or creates a DB at the given directory. +func Open(dataDir string, opts Options) (*DB, error) { + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, fmt.Errorf("ingot: create data dir: %w", err) + } + + walDir := filepath.Join(dataDir, "wal") + h, err := head.Open(walDir, wal.Options{}) + if err != nil { + return nil, fmt.Errorf("ingot: open head: %w", err) + } + + db := &DB{ + dataDir: dataDir, + opts: opts, + head: h, + } + + if err := db.loadBlocks(); err != nil { + h.Close() + return nil, fmt.Errorf("ingot: load blocks: %w", err) + } + + return db, nil +} + +// loadBlocks scans dataDir for block directories and opens them. +func (db *DB) loadBlocks() error { + entries, err := os.ReadDir(db.dataDir) + if err != nil { + return err + } + + for _, e := range entries { + if !e.IsDir() || e.Name() == "wal" { + continue + } + // Try to open as a block — skip if meta.json is missing. + dir := filepath.Join(db.dataDir, e.Name()) + if _, err := os.Stat(filepath.Join(dir, "meta.json")); err != nil { + continue + } + br, err := block.Open(dir) + if err != nil { + return fmt.Errorf("open block %s: %w", e.Name(), err) + } + db.blocks = append(db.blocks, br) + } + + sort.Slice(db.blocks, func(i, j int) bool { + return db.blocks[i].Meta.MinTime < db.blocks[j].Meta.MinTime + }) + + return nil +} + +// Appender returns a new Appender for batching writes. +func (db *DB) Appender() *Appender { + return &Appender{inner: db.head.Appender()} +} + +// Querier returns a Querier over [mint, maxt]. +func (db *DB) Querier(mint, maxt int64) (*Querier, error) { + db.mu.RLock() + var overlapping []*block.Reader + for _, b := range db.blocks { + if b.Meta.MaxTime >= mint && b.Meta.MinTime <= maxt { + overlapping = append(overlapping, b) + } + } + db.mu.RUnlock() + + return &Querier{ + mint: mint, + maxt: maxt, + head: db.head, + blocks: overlapping, + }, nil +} + +// FlushOlderThan flushes sealed head chunks to an immutable block. +func (db *DB) FlushOlderThan(maxT int64) (string, error) { + ulid, err := db.head.FlushOlderThan(maxT) + if err != nil { + return "", err + } + if ulid == "" { + return "", nil + } + + // Open the new block and add it to the block list. + dir := filepath.Join(db.dataDir, ulid) + br, err := block.Open(dir) + if err != nil { + return ulid, fmt.Errorf("ingot: open flushed block: %w", err) + } + + db.mu.Lock() + db.blocks = append(db.blocks, br) + sort.Slice(db.blocks, func(i, j int) bool { + return db.blocks[i].Meta.MinTime < db.blocks[j].Meta.MinTime + }) + db.mu.Unlock() + + return ulid, nil +} + +// Close closes the DB, releasing all resources. +func (db *DB) Close() error { + var firstErr error + if err := db.head.Close(); err != nil { + firstErr = err + } + for _, b := range db.blocks { + if err := b.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// Appender buffers samples and new series for atomic commit. +type Appender struct { + inner *head.Appender +} + +// Append adds a sample. If ref is 0, the series is resolved (or created) from ls. +func (a *Appender) Append(ref uint64, ls []labels.Label, t int64, v float64) (uint64, error) { + return a.inner.Append(ref, ls, t, v) +} + +// Commit writes the batch to the WAL and applies it to the head. +func (a *Appender) Commit() error { + return a.inner.Commit() +} + +// Rollback discards the batch. +func (a *Appender) Rollback() error { + return a.inner.Rollback() +} + +// Querier queries the DB over a time range. +type Querier struct { + mint, maxt int64 + head *head.Head + blocks []*block.Reader +} + +// Select returns a SeriesSet matching the given matchers. +func (q *Querier) Select(matchers ...*labels.Matcher) SeriesSet { + // Collect refs from all sources, keyed by ref. + type seriesSource struct { + labels []labels.Label + ref uint64 + } + refSet := make(map[uint64]seriesSource) + + // Resolve from each block. + for _, b := range q.blocks { + refs := resolveBlockPostings(b, matchers) + for _, ref := range refs { + if _, ok := refSet[ref]; !ok { + ls, ok := b.Labels(ref) + if !ok { + continue + } + refSet[ref] = seriesSource{labels: ls, ref: ref} + } + } + } + + // Resolve from head. + headRefs := resolveHeadPostings(q.head, matchers) + for _, ref := range headRefs { + if _, ok := refSet[ref]; !ok { + ls, ok := q.head.Labels(ref) + if !ok { + continue + } + refSet[ref] = seriesSource{labels: ls, ref: ref} + } + } + + // Build sorted series list by ref. + sorted := make([]seriesSource, 0, len(refSet)) + for _, ss := range refSet { + sorted = append(sorted, ss) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ref < sorted[j].ref }) + + // Build series entries. + entries := make([]resultSeries, 0, len(sorted)) + for _, ss := range sorted { + entries = append(entries, resultSeries{ + labels: ss.labels, + ref: ss.ref, + querier: q, + }) + } + + return &sliceSeriesSet{series: entries} +} + +// Close is a no-op for now (refcounting is M5). +func (q *Querier) Close() error { + return nil +} + +func resolveBlockPostings(b *block.Reader, matchers []*labels.Matcher) []uint64 { + if len(matchers) == 0 { + return b.AllPostings() + } + var lists [][]uint64 + for _, m := range matchers { + var refs []uint64 + switch m.Type { + case labels.MatchEqual: + refs = b.Postings(m.Name, m.Value) + case labels.MatchNotEqual: + refs = postings.Without(b.AllPostings(), b.Postings(m.Name, m.Value)) + case labels.MatchRegexp: + var parts [][]uint64 + for _, v := range b.LabelValues(m.Name) { + if m.Matches(v) { + parts = append(parts, b.Postings(m.Name, v)) + } + } + refs = postings.Union(parts...) + case labels.MatchNotRegexp: + var matching [][]uint64 + for _, v := range b.LabelValues(m.Name) { + if !m.Matches(v) { // m.Matches returns false for values matching the regex + matching = append(matching, b.Postings(m.Name, v)) + } + } + refs = postings.Without(b.AllPostings(), postings.Union(matching...)) + } + lists = append(lists, refs) + } + return postings.Intersect(lists...) +} + +func resolveHeadPostings(h *head.Head, matchers []*labels.Matcher) []uint64 { + if len(matchers) == 0 { + return h.AllPostings() + } + var lists [][]uint64 + for _, m := range matchers { + var refs []uint64 + switch m.Type { + case labels.MatchEqual: + refs = h.Postings(m.Name, m.Value) + case labels.MatchNotEqual: + refs = postings.Without(h.AllPostings(), h.Postings(m.Name, m.Value)) + case labels.MatchRegexp: + var parts [][]uint64 + for _, v := range h.LabelValues(m.Name) { + if m.Matches(v) { + parts = append(parts, h.Postings(m.Name, v)) + } + } + refs = postings.Union(parts...) + case labels.MatchNotRegexp: + var matching [][]uint64 + for _, v := range h.LabelValues(m.Name) { + if !m.Matches(v) { + matching = append(matching, h.Postings(m.Name, v)) + } + } + refs = postings.Without(h.AllPostings(), postings.Union(matching...)) + } + lists = append(lists, refs) + } + return postings.Intersect(lists...) +} + +// SeriesSet iterates over query results. +type SeriesSet interface { + Next() bool + At() Series + Err() error +} + +// Series represents a single time series. +type Series interface { + Labels() []labels.Label + Iterator() SampleIterator +} + +// SampleIterator iterates over samples. +type SampleIterator interface { + Next() bool + At() (int64, float64) + Err() error +} + +// --- concrete implementations --- + +type sliceSeriesSet struct { + series []resultSeries + cur int +} + +func (s *sliceSeriesSet) Next() bool { + if s.cur >= len(s.series) { + return false + } + s.cur++ + return s.cur <= len(s.series) +} + +func (s *sliceSeriesSet) At() Series { + return &s.series[s.cur-1] +} + +func (s *sliceSeriesSet) Err() error { return nil } + +type resultSeries struct { + labels []labels.Label + ref uint64 + querier *Querier +} + +func (s *resultSeries) Labels() []labels.Label { + return s.labels +} + +func (s *resultSeries) Iterator() SampleIterator { + var iters []chunkenc.ChunkIterator + + // Blocks first (in minTime order) — block values win on duplicate timestamps. + for _, b := range s.querier.blocks { + it, err := b.SeriesChunkIterator(s.ref, s.querier.mint, s.querier.maxt) + if err != nil { + return &errIterator{err: err} + } + iters = append(iters, it) + } + + // Head last. + iters = append(iters, s.querier.head.SeriesIterator(s.ref, s.querier.mint, s.querier.maxt)) + + return &mergedSampleIterator{ + iters: iters, + mint: s.querier.mint, + maxt: s.querier.maxt, + } +} + +// mergedSampleIterator merges multiple ChunkIterators in order, deduplicating +// timestamps. Earlier iterators (blocks) win over later ones (head). +type mergedSampleIterator struct { + iters []chunkenc.ChunkIterator + mint int64 + maxt int64 + cur int + lastT int64 + curT int64 + curV float64 + started bool + err error +} + +func (m *mergedSampleIterator) Next() bool { + for { + if m.err != nil { + return false + } + // Try to advance the current iterator. + for m.cur < len(m.iters) { + if m.iters[m.cur].Next() { + t, v := m.iters[m.cur].At() + // Filter to [mint, maxt]. + if t < m.mint { + continue + } + if t > m.maxt { + // This iterator is past our range; move to next. + m.cur++ + continue + } + // Dedup: skip if we've already emitted this timestamp. + if m.started && t <= m.lastT { + continue + } + m.curT = t + m.curV = v + m.lastT = t + m.started = true + return true + } + if err := m.iters[m.cur].Err(); err != nil { + m.err = err + return false + } + m.cur++ + } + return false + } +} + +func (m *mergedSampleIterator) At() (int64, float64) { + return m.curT, m.curV +} + +func (m *mergedSampleIterator) Err() error { + return m.err +} + +type errIterator struct { + err error +} + +func (e *errIterator) Next() bool { return false } +func (e *errIterator) At() (int64, float64) { return 0, 0 } +func (e *errIterator) Err() error { return e.err } + +// ensure interfaces are satisfied. +var ( + _ SeriesSet = (*sliceSeriesSet)(nil) + _ Series = (*resultSeries)(nil) + _ SampleIterator = (*mergedSampleIterator)(nil) + _ SampleIterator = (*errIterator)(nil) +) diff --git a/ingot_test.go b/ingot_test.go new file mode 100644 index 0000000..ec404e3 --- /dev/null +++ b/ingot_test.go @@ -0,0 +1,547 @@ +package ingot + +import ( + "math" + "testing" + + "git.dvdt.dev/david/ingot/labels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sample is a test convenience type. +type sample struct { + t int64 + v float64 +} + +// oracle is a naive reference implementation for query comparison. +type oracle struct { + series map[uint64][]sample // ref -> samples in order + labels map[uint64][]labels.Label // ref -> labels +} + +func newOracle() *oracle { + return &oracle{ + series: make(map[uint64][]sample), + labels: make(map[uint64][]labels.Label), + } +} + +func (o *oracle) addSeries(ref uint64, ls []labels.Label) { + if _, ok := o.labels[ref]; !ok { + o.labels[ref] = ls + } +} + +func (o *oracle) addSample(ref uint64, t int64, v float64) { + o.series[ref] = append(o.series[ref], sample{t, v}) +} + +func (o *oracle) query(mint, maxt int64, matchers ...*labels.Matcher) map[uint64][]sample { + result := make(map[uint64][]sample) + for ref, ls := range o.labels { + if !matchesAll(ls, matchers) { + continue + } + var filtered []sample + for _, s := range o.series[ref] { + if s.t >= mint && s.t <= maxt { + filtered = append(filtered, s) + } + } + if len(filtered) > 0 { + result[ref] = filtered + } + } + return result +} + +func matchesAll(ls []labels.Label, matchers []*labels.Matcher) bool { + for _, m := range matchers { + val := "" + for _, l := range ls { + if l.Name == m.Name { + val = l.Value + break + } + } + if !m.Matches(val) { + return false + } + } + return true +} + +// collectSeriesSet drains a SeriesSet into a map of ref -> samples. +func collectSeriesSet(t *testing.T, ss SeriesSet) map[uint64][]sample { + t.Helper() + result := make(map[uint64][]sample) + for ss.Next() { + s := ss.At() + ls := s.Labels() + // Determine ref by looking at labels hash — we need a stable key. + // Use the series ref which is the map key from the querier. + // Actually, we need to match by labels since the oracle uses refs. + // Let's collect by label hash. + it := s.Iterator() + var samples []sample + for it.Next() { + st, sv := it.At() + samples = append(samples, sample{st, sv}) + } + require.NoError(t, it.Err()) + if len(samples) > 0 { + h := labels.Hash(ls) + result[h] = samples + } + } + require.NoError(t, ss.Err()) + return result +} + +// oracleByLabelHash re-keys oracle results by label hash for comparison. +func oracleByLabelHash(o *oracle, mint, maxt int64, matchers ...*labels.Matcher) map[uint64][]sample { + byRef := o.query(mint, maxt, matchers...) + result := make(map[uint64][]sample, len(byRef)) + for ref, samples := range byRef { + ls := o.labels[ref] + h := labels.Hash(ls) + result[h] = samples + } + return result +} + +func openTestDB(t *testing.T) *DB { + t.Helper() + db, err := Open(t.TempDir(), Options{}) + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + return db +} + +func TestQueryOracle(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, db *DB, o *oracle) + queries []queryCase + }{ + { + name: "head_only", + setup: func(t *testing.T, db *DB, o *oracle) { + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "temp", "room", "office"), 1000, 71.3) + require.NoError(t, err) + o.addSeries(ref, labels.FromStrings("__name__", "temp", "room", "office")) + o.addSample(ref, 1000, 71.3) + + _, err = app.Append(ref, nil, 2000, 71.4) + require.NoError(t, err) + o.addSample(ref, 2000, 71.4) + + ref2, err := app.Append(0, labels.FromStrings("__name__", "humidity", "room", "office"), 1000, 55.0) + require.NoError(t, err) + o.addSeries(ref2, labels.FromStrings("__name__", "humidity", "room", "office")) + o.addSample(ref2, 1000, 55.0) + + require.NoError(t, app.Commit()) + }, + queries: []queryCase{ + { + name: "match_all_by_room", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "room", "office")}, + }, + { + name: "match_temp_only", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "match_not_equal", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchNotEqual, "__name__", "temp")}, + }, + { + name: "match_regex", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchRegexp, "__name__", "te.*")}, + }, + { + name: "match_not_regex", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchNotRegexp, "__name__", "te.*")}, + }, + { + name: "time_range_filter", + mint: 1500, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "no_match", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "pressure")}, + }, + }, + }, + { + name: "block_only", + setup: func(t *testing.T, db *DB, o *oracle) { + app := db.Appender() + // Write enough samples to seal chunks (need >120 for a sealed chunk). + ref, err := app.Append(0, labels.FromStrings("__name__", "temp"), 0, 0) + require.NoError(t, err) + o.addSeries(ref, labels.FromStrings("__name__", "temp")) + o.addSample(ref, 0, 0) + for i := 1; i < 250; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + + // Flush to block. + _, err = db.FlushOlderThan(math.MaxInt64) + require.NoError(t, err) + }, + queries: []queryCase{ + { + name: "query_all", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "query_subset", + mint: 100000, + maxt: 200000, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + }, + }, + { + name: "head_block_merge_seam", + setup: func(t *testing.T, db *DB, o *oracle) { + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "temp", "room", "office"), 0, 0) + require.NoError(t, err) + o.addSeries(ref, labels.FromStrings("__name__", "temp", "room", "office")) + o.addSample(ref, 0, 0) + + // Write 250 samples (2 sealed chunks + 10 active). + for i := 1; i < 250; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + + // Flush sealed chunks to block. + _, err = db.FlushOlderThan(math.MaxInt64) + require.NoError(t, err) + + // Append more samples to head (after flush). + app = db.Appender() + for i := 250; i < 260; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + }, + queries: []queryCase{ + { + name: "full_range", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "block_portion_only", + mint: 0, + maxt: 120 * 15000, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "head_portion_only", + mint: 250 * 15000, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "overlap_seam", + mint: 230 * 15000, + maxt: 255 * 15000, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "room", "office")}, + }, + }, + }, + { + name: "multiple_series_with_matchers", + setup: func(t *testing.T, db *DB, o *oracle) { + series := []struct { + ls []labels.Label + }{ + {ls: labels.FromStrings("__name__", "temp", "room", "office")}, + {ls: labels.FromStrings("__name__", "temp", "room", "kitchen")}, + {ls: labels.FromStrings("__name__", "humidity", "room", "office")}, + {ls: labels.FromStrings("__name__", "pressure", "room", "lab")}, + } + + app := db.Appender() + for _, s := range series { + ref, err := app.Append(0, s.ls, 1000, 1.0) + require.NoError(t, err) + o.addSeries(ref, s.ls) + o.addSample(ref, 1000, 1.0) + + _, err = app.Append(ref, nil, 2000, 2.0) + require.NoError(t, err) + o.addSample(ref, 2000, 2.0) + } + require.NoError(t, app.Commit()) + }, + queries: []queryCase{ + { + name: "equal_name_temp", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "equal_room_office", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "room", "office")}, + }, + { + name: "combined_matchers", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{ + labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp"), + labels.MustNewMatcher(labels.MatchEqual, "room", "office"), + }, + }, + { + name: "regex_name", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchRegexp, "__name__", "temp|humidity")}, + }, + { + name: "not_equal_room", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchNotEqual, "room", "office")}, + }, + { + name: "not_regex_room", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchNotRegexp, "room", "off.*")}, + }, + }, + }, + { + name: "multiple_blocks", + setup: func(t *testing.T, db *DB, o *oracle) { + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "temp"), 0, 0) + require.NoError(t, err) + o.addSeries(ref, labels.FromStrings("__name__", "temp")) + o.addSample(ref, 0, 0) + for i := 1; i < 250; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + + // First flush. + _, err = db.FlushOlderThan(math.MaxInt64) + require.NoError(t, err) + + // More data -> second flush. + app = db.Appender() + for i := 250; i < 500; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + _, err = db.FlushOlderThan(math.MaxInt64) + require.NoError(t, err) + + // Some data in head. + app = db.Appender() + for i := 500; i < 510; i++ { + _, err = app.Append(ref, nil, int64(i*15000), float64(i)) + require.NoError(t, err) + o.addSample(ref, int64(i*15000), float64(i)) + } + require.NoError(t, app.Commit()) + }, + queries: []queryCase{ + { + name: "full_range", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + { + name: "second_block_range", + mint: 300 * 15000, + maxt: 400 * 15000, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + }, + }, + { + name: "empty_results", + setup: func(t *testing.T, db *DB, o *oracle) { + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "temp"), 1000, 1.0) + require.NoError(t, err) + o.addSeries(ref, labels.FromStrings("__name__", "temp")) + o.addSample(ref, 1000, 1.0) + require.NoError(t, app.Commit()) + }, + queries: []queryCase{ + { + name: "no_matching_series", + mint: math.MinInt64, + maxt: math.MaxInt64, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "nonexistent")}, + }, + { + name: "time_range_miss", + mint: 5000, + maxt: 6000, + matchers: []*labels.Matcher{labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := openTestDB(t) + o := newOracle() + + tc.setup(t, db, o) + + for _, qc := range tc.queries { + t.Run(qc.name, func(t *testing.T) { + q, err := db.Querier(qc.mint, qc.maxt) + require.NoError(t, err) + defer q.Close() + + ss := q.Select(qc.matchers...) + got := collectSeriesSet(t, ss) + want := oracleByLabelHash(o, qc.mint, qc.maxt, qc.matchers...) + + assert.Equal(t, len(want), len(got), "series count mismatch") + for h, wantSamples := range want { + gotSamples, ok := got[h] + assert.True(t, ok, "missing series with hash %d", h) + assert.Equal(t, wantSamples, gotSamples, "sample mismatch for hash %d", h) + } + }) + } + }) + } +} + +type queryCase struct { + name string + mint int64 + maxt int64 + matchers []*labels.Matcher +} + +func TestDBAppenderAPI(t *testing.T) { + db := openTestDB(t) + + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "temp", "room", "office"), 1000, 71.3) + require.NoError(t, err) + assert.NotZero(t, ref) + + _, err = app.Append(ref, nil, 2000, 71.4) + require.NoError(t, err) + + require.NoError(t, app.Commit()) + + // Query back. + q, err := db.Querier(math.MinInt64, math.MaxInt64) + require.NoError(t, err) + defer q.Close() + + ss := q.Select(labels.MustNewMatcher(labels.MatchEqual, "room", "office")) + require.True(t, ss.Next()) + s := ss.At() + assert.Equal(t, labels.FromStrings("__name__", "temp", "room", "office"), s.Labels()) + + it := s.Iterator() + require.True(t, it.Next()) + st, sv := it.At() + assert.Equal(t, int64(1000), st) + assert.Equal(t, 71.3, sv) + + require.True(t, it.Next()) + st, sv = it.At() + assert.Equal(t, int64(2000), st) + assert.Equal(t, 71.4, sv) + + assert.False(t, it.Next()) + assert.False(t, ss.Next()) +} + +func TestDBReopenWithBlocks(t *testing.T) { + dir := t.TempDir() + + // Write data, flush, close. + db, err := Open(dir, Options{}) + require.NoError(t, err) + + app := db.Appender() + ref, err := app.Append(0, labels.FromStrings("__name__", "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()) + + _, err = db.FlushOlderThan(math.MaxInt64) + require.NoError(t, err) + require.NoError(t, db.Close()) + + // Reopen. + db2, err := Open(dir, Options{}) + require.NoError(t, err) + defer db2.Close() + + // Should find data in block. + q, err := db2.Querier(math.MinInt64, math.MaxInt64) + require.NoError(t, err) + defer q.Close() + + ss := q.Select(labels.MustNewMatcher(labels.MatchEqual, "__name__", "temp")) + require.True(t, ss.Next()) + it := ss.At().Iterator() + count := 0 + for it.Next() { + count++ + } + require.NoError(t, it.Err()) + assert.Equal(t, 250, count, "should find all samples: 240 from block + 10 from head WAL replay") +} diff --git a/internal/block/reader.go b/internal/block/reader.go index 771a739..36fe6ac 100644 --- a/internal/block/reader.go +++ b/internal/block/reader.go @@ -105,6 +105,16 @@ func (r *Reader) Labels(ref uint64) ([]labels.Label, bool) { return entry.Labels, true } +// LabelValues returns sorted unique values for the given label name. +func (r *Reader) LabelValues(name string) []string { + return r.idx.LabelValues(name) +} + +// AllPostings returns sorted refs for all series in the block. +func (r *Reader) AllPostings() []uint64 { + return r.idx.AllPostings() +} + // Close releases all resources (munmaps chunk files). func (r *Reader) Close() error { return r.chunks.close() diff --git a/internal/head/head.go b/internal/head/head.go index 0a09097..4cae262 100644 --- a/internal/head/head.go +++ b/internal/head/head.go @@ -5,6 +5,7 @@ package head import ( "fmt" "path/filepath" + "sort" "sync/atomic" "git.dvdt.dev/david/ingot/internal/block" @@ -214,6 +215,59 @@ func (h *Head) FlushOlderThan(maxT int64) (string, error) { 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 diff --git a/internal/head/head_test.go b/internal/head/head_test.go index e4080f8..539d543 100644 --- a/internal/head/head_test.go +++ b/internal/head/head_test.go @@ -609,6 +609,79 @@ func TestFlushWALTruncation(t *testing.T) { require.NoError(t, app.Commit()) } +func TestHeadPostings(t *testing.T) { + h := openHead(t) + + // Add three series. + app := h.Appender() + _, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}}, 1000, 1.0) + require.NoError(t, err) + _, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}}, 1000, 2.0) + require.NoError(t, err) + _, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}}, 1000, 3.0) + require.NoError(t, err) + require.NoError(t, app.Commit()) + + tests := []struct { + name string + label string + value string + wantRefs []uint64 + }{ + {name: "match_name_temp", label: "__name__", value: "temp", wantRefs: []uint64{1, 3}}, + {name: "match_room_office", label: "room", value: "office", wantRefs: []uint64{1, 2}}, + {name: "match_name_humidity", label: "__name__", value: "humidity", wantRefs: []uint64{2}}, + {name: "no_match", label: "__name__", value: "pressure", wantRefs: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := h.Postings(tc.label, tc.value) + assert.Equal(t, tc.wantRefs, got) + }) + } +} + +func TestHeadLabelValues(t *testing.T) { + h := openHead(t) + + app := h.Appender() + _, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}}, 1000, 1.0) + require.NoError(t, err) + _, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}}, 1000, 2.0) + require.NoError(t, err) + _, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}}, 1000, 3.0) + require.NoError(t, err) + require.NoError(t, app.Commit()) + + assert.Equal(t, []string{"humidity", "temp"}, h.LabelValues("__name__")) + assert.Equal(t, []string{"kitchen", "office"}, h.LabelValues("room")) + assert.Equal(t, []string{}, h.LabelValues("nonexistent")) +} + +func TestHeadLabelsAndAllPostings(t *testing.T) { + h := openHead(t) + + app := h.Appender() + _, err := app.Append(0, []labels.Label{{Name: "__name__", Value: "temp"}}, 1000, 1.0) + require.NoError(t, err) + _, err = app.Append(0, []labels.Label{{Name: "__name__", Value: "humidity"}}, 1000, 2.0) + require.NoError(t, err) + require.NoError(t, app.Commit()) + + // Labels + ls, ok := h.Labels(1) + assert.True(t, ok) + assert.Equal(t, []labels.Label{{Name: "__name__", Value: "temp"}}, ls) + + _, ok = h.Labels(999) + assert.False(t, ok) + + // AllPostings + refs := h.AllPostings() + assert.Equal(t, []uint64{1, 2}, refs) +} + func TestConcurrentAppend(t *testing.T) { tests := []struct { name string diff --git a/internal/index/index_test.go b/internal/index/index_test.go index 96c3c76..d0d3797 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -159,6 +159,50 @@ func TestIndexChunkRefEncoding(t *testing.T) { } } +func TestIndexLabelValues(t *testing.T) { + entries := []SeriesEntry{ + {Ref: 1, Labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + {Ref: 2, Labels: []labels.Label{{Name: "__name__", Value: "humidity"}, {Name: "room", Value: "office"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + {Ref: 3, Labels: []labels.Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "kitchen"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + } + + data := writeIndex(t, entries) + r, err := NewReader(data) + require.NoError(t, err) + + tests := []struct { + name string + label string + wantVals []string + }{ + {name: "name_values", label: "__name__", wantVals: []string{"humidity", "temp"}}, + {name: "room_values", label: "room", wantVals: []string{"kitchen", "office"}}, + {name: "missing_label", label: "nonexistent", wantVals: []string{}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := r.LabelValues(tc.label) + assert.Equal(t, tc.wantVals, got) + }) + } +} + +func TestIndexAllPostings(t *testing.T) { + entries := []SeriesEntry{ + {Ref: 5, Labels: []labels.Label{{Name: "__name__", Value: "a"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + {Ref: 2, Labels: []labels.Label{{Name: "__name__", Value: "b"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + {Ref: 8, Labels: []labels.Label{{Name: "__name__", Value: "c"}}, Chunks: []ChunkMeta{{MinT: 0, MaxT: 1, Ref: 0}}}, + } + + data := writeIndex(t, entries) + r, err := NewReader(data) + require.NoError(t, err) + + refs := r.AllPostings() + assert.Equal(t, []uint64{2, 5, 8}, refs) +} + func TestIndexCorruptData(t *testing.T) { tests := []struct { name string diff --git a/internal/index/reader.go b/internal/index/reader.go index 8bc08cc..1d5898d 100644 --- a/internal/index/reader.go +++ b/internal/index/reader.go @@ -3,6 +3,7 @@ package index import ( "encoding/binary" "hash/crc32" + "sort" "git.dvdt.dev/david/ingot/labels" ) @@ -213,3 +214,29 @@ func (r *Reader) SeriesByRef(ref uint64) (SeriesEntry, bool) { func (r *Reader) Postings(name, value string) []uint64 { return r.postings[labelPair{name, value}] } + +// LabelValues returns sorted unique values for the given label name. +func (r *Reader) LabelValues(name string) []string { + seen := make(map[string]struct{}) + for key := range r.postings { + if key.name == name { + seen[key.value] = struct{}{} + } + } + vals := make([]string, 0, len(seen)) + for v := range seen { + vals = append(vals, v) + } + sort.Strings(vals) + return vals +} + +// AllPostings returns sorted refs for all series in the index. +func (r *Reader) AllPostings() []uint64 { + refs := make([]uint64, len(r.series)) + for i, s := range r.series { + refs[i] = s.Ref + } + sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] }) + return refs +} diff --git a/internal/postings/postings.go b/internal/postings/postings.go new file mode 100644 index 0000000..2a0df32 --- /dev/null +++ b/internal/postings/postings.go @@ -0,0 +1,91 @@ +// Package postings provides set operations on sorted uint64 slices, +// used for combining postings lists from index lookups. +package postings + +// Intersect returns the sorted intersection of all input lists. +// An empty input returns nil. +func Intersect(lists ...[]uint64) []uint64 { + if len(lists) == 0 { + return nil + } + if len(lists) == 1 { + return lists[0] + } + result := lists[0] + for _, b := range lists[1:] { + result = intersectTwo(result, b) + } + return result +} + +func intersectTwo(a, b []uint64) []uint64 { + var out []uint64 + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] < b[j]: + i++ + case a[i] > b[j]: + j++ + default: + out = append(out, a[i]) + i++ + j++ + } + } + return out +} + +// Union returns the sorted union of all input lists. +func Union(lists ...[]uint64) []uint64 { + if len(lists) == 0 { + return nil + } + if len(lists) == 1 { + return lists[0] + } + result := lists[0] + for _, b := range lists[1:] { + result = unionTwo(result, b) + } + return result +} + +func unionTwo(a, b []uint64) []uint64 { + out := make([]uint64, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] < b[j]: + out = append(out, a[i]) + i++ + case a[i] > b[j]: + out = append(out, b[j]) + j++ + default: + out = append(out, a[i]) + i++ + j++ + } + } + out = append(out, a[i:]...) + out = append(out, b[j:]...) + return out +} + +// Without returns elements in full that are not in remove. +// Both inputs must be sorted. +func Without(full, remove []uint64) []uint64 { + var out []uint64 + j := 0 + for _, v := range full { + for j < len(remove) && remove[j] < v { + j++ + } + if j < len(remove) && remove[j] == v { + continue + } + out = append(out, v) + } + return out +} diff --git a/internal/postings/postings_test.go b/internal/postings/postings_test.go new file mode 100644 index 0000000..46f8238 --- /dev/null +++ b/internal/postings/postings_test.go @@ -0,0 +1,79 @@ +package postings + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntersect(t *testing.T) { + tests := []struct { + name string + lists [][]uint64 + want []uint64 + }{ + {name: "empty_input", lists: nil, want: nil}, + {name: "single_list", lists: [][]uint64{{1, 2, 3}}, want: []uint64{1, 2, 3}}, + {name: "full_overlap", lists: [][]uint64{{1, 2, 3}, {1, 2, 3}}, want: []uint64{1, 2, 3}}, + {name: "partial_overlap", lists: [][]uint64{{1, 2, 3, 4}, {2, 3, 5}}, want: []uint64{2, 3}}, + {name: "no_overlap", lists: [][]uint64{{1, 2}, {3, 4}}, want: nil}, + {name: "one_empty", lists: [][]uint64{{1, 2, 3}, {}}, want: nil}, + {name: "three_lists", lists: [][]uint64{{1, 2, 3, 4, 5}, {2, 3, 4}, {3, 4, 5}}, want: []uint64{3, 4}}, + {name: "single_element", lists: [][]uint64{{5}, {5}}, want: []uint64{5}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Intersect(tc.lists...) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestUnion(t *testing.T) { + tests := []struct { + name string + lists [][]uint64 + want []uint64 + }{ + {name: "empty_input", lists: nil, want: nil}, + {name: "single_list", lists: [][]uint64{{1, 2, 3}}, want: []uint64{1, 2, 3}}, + {name: "full_overlap", lists: [][]uint64{{1, 2, 3}, {1, 2, 3}}, want: []uint64{1, 2, 3}}, + {name: "no_overlap", lists: [][]uint64{{1, 2}, {3, 4}}, want: []uint64{1, 2, 3, 4}}, + {name: "partial_overlap", lists: [][]uint64{{1, 3, 5}, {2, 3, 4}}, want: []uint64{1, 2, 3, 4, 5}}, + {name: "one_empty", lists: [][]uint64{{1, 2}, {}}, want: []uint64{1, 2}}, + {name: "both_empty", lists: [][]uint64{{}, {}}, want: []uint64{}}, + {name: "three_lists", lists: [][]uint64{{1}, {2}, {3}}, want: []uint64{1, 2, 3}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Union(tc.lists...) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestWithout(t *testing.T) { + tests := []struct { + name string + full []uint64 + remove []uint64 + want []uint64 + }{ + {name: "empty_full", full: nil, remove: []uint64{1, 2}, want: nil}, + {name: "empty_remove", full: []uint64{1, 2, 3}, remove: nil, want: []uint64{1, 2, 3}}, + {name: "both_empty", full: nil, remove: nil, want: nil}, + {name: "remove_subset", full: []uint64{1, 2, 3, 4, 5}, remove: []uint64{2, 4}, want: []uint64{1, 3, 5}}, + {name: "remove_all", full: []uint64{1, 2, 3}, remove: []uint64{1, 2, 3}, want: nil}, + {name: "remove_none", full: []uint64{1, 2, 3}, remove: []uint64{4, 5}, want: []uint64{1, 2, 3}}, + {name: "remove_superset", full: []uint64{2, 3}, remove: []uint64{1, 2, 3, 4}, want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Without(tc.full, tc.remove) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/labels/matchers.go b/labels/matchers.go new file mode 100644 index 0000000..685524e --- /dev/null +++ b/labels/matchers.go @@ -0,0 +1,95 @@ +package labels + +import ( + "fmt" + "regexp" +) + +// MatchType identifies the type of a label matcher. +type MatchType int + +const ( + MatchEqual MatchType = iota + MatchNotEqual + MatchRegexp + MatchNotRegexp +) + +func (m MatchType) String() string { + switch m { + case MatchEqual: + return "=" + case MatchNotEqual: + return "!=" + case MatchRegexp: + return "=~" + case MatchNotRegexp: + return "!~" + default: + return "??" + } +} + +// Matcher matches label values against a pattern. +type Matcher struct { + Type MatchType + Name string + Value string + re *regexp.Regexp // compiled for MatchRegexp/MatchNotRegexp +} + +// NewMatcher creates a new Matcher. For regex types, the value is compiled +// as a full-match regular expression (anchored with ^(?:...)$). +func NewMatcher(typ MatchType, name, value string) (*Matcher, error) { + m := &Matcher{Type: typ, Name: name, Value: value} + if typ == MatchRegexp || typ == MatchNotRegexp { + re, err := regexp.Compile("^(?:" + value + ")$") + if err != nil { + return nil, fmt.Errorf("labels: bad matcher regex %q: %w", value, err) + } + m.re = re + } + return m, nil +} + +// MustNewMatcher is like NewMatcher but panics on error. +func MustNewMatcher(typ MatchType, name, value string) *Matcher { + m, err := NewMatcher(typ, name, value) + if err != nil { + panic(err) + } + return m +} + +// Matches reports whether the given label value satisfies this matcher. +func (m *Matcher) Matches(v string) bool { + switch m.Type { + case MatchEqual: + return v == m.Value + case MatchNotEqual: + return v != m.Value + case MatchRegexp: + return m.re.MatchString(v) + case MatchNotRegexp: + return !m.re.MatchString(v) + default: + return false + } +} + +func (m *Matcher) String() string { + return fmt.Sprintf("%s%s%q", m.Name, m.Type, m.Value) +} + +// FromStrings creates a sorted label set from alternating name/value pairs. +// Panics if an odd number of strings is provided. +func FromStrings(ss ...string) []Label { + if len(ss)%2 != 0 { + panic("labels.FromStrings: odd number of arguments") + } + ls := make([]Label, len(ss)/2) + for i := 0; i < len(ss); i += 2 { + ls[i/2] = Label{Name: ss[i], Value: ss[i+1]} + } + return Sort(ls) +} diff --git a/labels/matchers_test.go b/labels/matchers_test.go new file mode 100644 index 0000000..80c0d9a --- /dev/null +++ b/labels/matchers_test.go @@ -0,0 +1,107 @@ +package labels + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatcherMatches(t *testing.T) { + tests := []struct { + name string + typ MatchType + pattern string + value string + want bool + }{ + // MatchEqual + {name: "equal_match", typ: MatchEqual, pattern: "foo", value: "foo", want: true}, + {name: "equal_no_match", typ: MatchEqual, pattern: "foo", value: "bar", want: false}, + {name: "equal_empty", typ: MatchEqual, pattern: "", value: "", want: true}, + {name: "equal_empty_vs_nonempty", typ: MatchEqual, pattern: "", value: "x", want: false}, + + // MatchNotEqual + {name: "not_equal_match", typ: MatchNotEqual, pattern: "foo", value: "bar", want: true}, + {name: "not_equal_no_match", typ: MatchNotEqual, pattern: "foo", value: "foo", want: false}, + {name: "not_equal_empty", typ: MatchNotEqual, pattern: "", value: "x", want: true}, + + // MatchRegexp + {name: "regexp_exact", typ: MatchRegexp, pattern: "foo", value: "foo", want: true}, + {name: "regexp_no_match", typ: MatchRegexp, pattern: "foo", value: "foobar", want: false}, + {name: "regexp_alternation", typ: MatchRegexp, pattern: "foo|bar", value: "bar", want: true}, + {name: "regexp_alternation_no_match", typ: MatchRegexp, pattern: "foo|bar", value: "baz", want: false}, + {name: "regexp_wildcard", typ: MatchRegexp, pattern: "fo.*", value: "foobar", want: true}, + {name: "regexp_prefix_anchored", typ: MatchRegexp, pattern: "fo", value: "foo", want: false}, + {name: "regexp_dot_plus", typ: MatchRegexp, pattern: ".+", value: "anything", want: true}, + {name: "regexp_dot_plus_empty", typ: MatchRegexp, pattern: ".+", value: "", want: false}, + + // MatchNotRegexp + {name: "not_regexp_match", typ: MatchNotRegexp, pattern: "foo", value: "bar", want: true}, + {name: "not_regexp_no_match", typ: MatchNotRegexp, pattern: "foo", value: "foo", want: false}, + {name: "not_regexp_alternation", typ: MatchNotRegexp, pattern: "foo|bar", value: "baz", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, err := NewMatcher(tc.typ, "__name__", tc.pattern) + require.NoError(t, err) + assert.Equal(t, tc.want, m.Matches(tc.value)) + }) + } +} + +func TestNewMatcherBadRegexp(t *testing.T) { + _, err := NewMatcher(MatchRegexp, "__name__", "[invalid") + assert.Error(t, err) +} + +func TestMustNewMatcherPanics(t *testing.T) { + assert.Panics(t, func() { + MustNewMatcher(MatchRegexp, "__name__", "[invalid") + }) +} + +func TestFromStrings(t *testing.T) { + tests := []struct { + name string + args []string + want []Label + }{ + { + name: "single_pair", + args: []string{"__name__", "temp"}, + want: []Label{{Name: "__name__", Value: "temp"}}, + }, + { + name: "multiple_pairs_sorted", + args: []string{"room", "office", "__name__", "temp"}, + want: []Label{{Name: "__name__", Value: "temp"}, {Name: "room", Value: "office"}}, + }, + { + name: "empty", + args: []string{}, + want: []Label{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := FromStrings(tc.args...) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFromStringsPanicsOnOdd(t *testing.T) { + assert.Panics(t, func() { + FromStrings("__name__") + }) +} + +func TestMatchTypeString(t *testing.T) { + assert.Equal(t, "=", MatchEqual.String()) + assert.Equal(t, "!=", MatchNotEqual.String()) + assert.Equal(t, "=~", MatchRegexp.String()) + assert.Equal(t, "!~", MatchNotRegexp.String()) +}