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.
This commit is contained in:
@@ -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)
|
||||||
|
)
|
||||||
+547
@@ -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")
|
||||||
|
}
|
||||||
@@ -105,6 +105,16 @@ func (r *Reader) Labels(ref uint64) ([]labels.Label, bool) {
|
|||||||
return entry.Labels, true
|
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).
|
// Close releases all resources (munmaps chunk files).
|
||||||
func (r *Reader) Close() error {
|
func (r *Reader) Close() error {
|
||||||
return r.chunks.close()
|
return r.chunks.close()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package head
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"git.dvdt.dev/david/ingot/internal/block"
|
"git.dvdt.dev/david/ingot/internal/block"
|
||||||
@@ -214,6 +215,59 @@ func (h *Head) FlushOlderThan(maxT int64) (string, error) {
|
|||||||
return ulid, nil
|
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).
|
// DataDir returns the data directory (parent of WAL dir).
|
||||||
func (h *Head) DataDir() string {
|
func (h *Head) DataDir() string {
|
||||||
return h.dataDir
|
return h.dataDir
|
||||||
|
|||||||
@@ -609,6 +609,79 @@ func TestFlushWALTruncation(t *testing.T) {
|
|||||||
require.NoError(t, app.Commit())
|
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) {
|
func TestConcurrentAppend(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -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) {
|
func TestIndexCorruptData(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package index
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"hash/crc32"
|
"hash/crc32"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"git.dvdt.dev/david/ingot/labels"
|
"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 {
|
func (r *Reader) Postings(name, value string) []uint64 {
|
||||||
return r.postings[labelPair{name, value}]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user