Files
zzg 6251b96e0a Add the query engine: narrow SQL grammar over a parallel shard scan
Hand-written lexer and parser for the supported subset. The time range is
mandatory and hoisted out of WHERE, because it is what selects shards and
a store whose only index is the time partition cannot afford to make an
unbounded scan easy to write by accident.

Execution is one goroutine per shard fanning into a single merge -- Scuba's
leaf/aggregator tree collapsed onto one machine. Predicates compile against
each chunk's physical layout so string literals resolve to dictionary codes
once rather than per row, and an equality literal absent from a chunk's
dictionary skips that chunk entirely.

Aggregates compensate for sample_rate the way Scuba does, including for
percentiles. Doing that surfaced a real bug: SampleRate was float32, so a
rate of 0.1 round-tripped as 0.100000001 and count(*) reported 9.99999985
instead of 10. Widened to float64 end to end and added a float64 column
type.
2026-09-15 21:38:59 -07:00

392 lines
9.3 KiB
Go

package store
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
)
// ColType is a column's physical encoding.
type ColType uint8
const (
ColDict ColType = iota // uint32 codes into the chunk dictionary
ColInt64 // timestamps, counts, attribute offsets
ColUint8 // small enums: kind, status, severity
ColFloat32 // reserved: no column currently uses it
ColBlobRef // offset+length into the shard's .blob sidecar
ColFloat64 // sample rate, where float32's 1e-7 relative
// error would show up as 9.99999985 in a count
)
// BlobRef points at large text in the shard's blob sidecar. Length zero means
// absent, which is the common case: most spans carry no input or output.
type BlobRef struct {
Off int64
Len uint32
}
// Column is one physical column. Exactly one of the typed slices is populated,
// selected by Type. A tagged union would be tidier, but Go's version costs an
// interface dispatch per value in the scan's hot loop, which is the one loop in
// this program worth keeping tight.
type Column struct {
Name string
Type ColType
Codes []uint32
I64 []int64
U8 []uint8
F32 []float32
F64 []float64
Blobs []BlobRef
}
func (c *Column) len() int {
switch c.Type {
case ColDict:
return len(c.Codes)
case ColInt64:
return len(c.I64)
case ColUint8:
return len(c.U8)
case ColFloat32:
return len(c.F32)
case ColFloat64:
return len(c.F64)
case ColBlobRef:
return len(c.Blobs)
}
return 0
}
// Attribute columns are ordinary Int64 columns holding a row's window into the
// chunk-wide attribute side table. Keeping them as normal columns rather than
// special-casing them means the scanner has one code path, not two.
const (
colAttrStart = "_attr_start"
colAttrCount = "_attr_count"
)
// Chunk is a self-contained, independently decodable batch of rows.
//
// Self-contained is the important property. Each chunk carries its own
// dictionary and its own complete column data, so a reader can decode any chunk
// without having seen the ones before it, and a writer can append one without
// revising anything already on disk. That is what makes the shard file
// append-only and what makes a torn write recoverable by discarding a single
// trailing chunk.
type Chunk struct {
Dict *Dict
Cols []Column
Rows int
// Attribute side table, indexed by the window in _attr_start/_attr_count.
AttrKeys []uint32
AttrVals []uint32
AttrTypes []uint8
byName map[string]int
}
// Col returns a column by name.
func (c *Chunk) Col(name string) (*Column, bool) {
if c.byName == nil {
c.byName = make(map[string]int, len(c.Cols))
for i := range c.Cols {
c.byName[c.Cols[i].Name] = i
}
}
i, ok := c.byName[name]
if !ok {
return nil, false
}
return &c.Cols[i], true
}
const (
chunkMagic = "FTHC"
chunkVersion = 1
// maxColumns and maxRows bound allocation when decoding a header whose
// length prefixes may be garbage.
maxColumns = 256
maxRows = 1 << 24
)
// Encode writes the chunk body. The caller frames it with a length and CRC;
// see shard.go.
func (c *Chunk) Encode(w io.Writer) error {
var scratch [binary.MaxVarintLen64]byte
putU := func(v uint64) error {
n := binary.PutUvarint(scratch[:], v)
_, err := w.Write(scratch[:n])
return err
}
if _, err := io.WriteString(w, chunkMagic); err != nil {
return err
}
if err := putU(chunkVersion); err != nil {
return err
}
if err := putU(uint64(c.Rows)); err != nil {
return err
}
if err := c.Dict.encode(w); err != nil {
return err
}
if err := putU(uint64(len(c.Cols))); err != nil {
return err
}
for i := range c.Cols {
col := &c.Cols[i]
if got := col.len(); got != c.Rows {
return fmt.Errorf("column %q has %d values, chunk has %d rows", col.Name, got, c.Rows)
}
if err := putU(uint64(len(col.Name))); err != nil {
return err
}
if _, err := io.WriteString(w, col.Name); err != nil {
return err
}
if err := putU(uint64(col.Type)); err != nil {
return err
}
if err := encodeColumnData(w, col, scratch[:]); err != nil {
return err
}
}
// Attribute side table.
if err := putU(uint64(len(c.AttrKeys))); err != nil {
return err
}
for i := range c.AttrKeys {
if err := putU(uint64(c.AttrKeys[i])); err != nil {
return err
}
if err := putU(uint64(c.AttrVals[i])); err != nil {
return err
}
if _, err := w.Write([]byte{c.AttrTypes[i]}); err != nil {
return err
}
}
return nil
}
func encodeColumnData(w io.Writer, col *Column, scratch []byte) error {
putU := func(v uint64) error {
n := binary.PutUvarint(scratch, v)
_, err := w.Write(scratch[:n])
return err
}
switch col.Type {
case ColDict:
for _, v := range col.Codes {
if err := putU(uint64(v)); err != nil {
return err
}
}
case ColInt64:
// Zig-zag: deltas and negatives are both common here (parent offsets,
// clock-skewed durations), and varint alone would spend ten bytes on
// every negative value.
for _, v := range col.I64 {
if err := putU(uint64(zigzag(v))); err != nil {
return err
}
}
case ColUint8:
if _, err := w.Write(col.U8); err != nil {
return err
}
case ColFloat32:
var b [4]byte
for _, v := range col.F32 {
binary.LittleEndian.PutUint32(b[:], f32bits(v))
if _, err := w.Write(b[:]); err != nil {
return err
}
}
case ColFloat64:
var b [8]byte
for _, v := range col.F64 {
binary.LittleEndian.PutUint64(b[:], math.Float64bits(v))
if _, err := w.Write(b[:]); err != nil {
return err
}
}
case ColBlobRef:
for _, v := range col.Blobs {
if err := putU(uint64(v.Off)); err != nil {
return err
}
if err := putU(uint64(v.Len)); err != nil {
return err
}
}
default:
return fmt.Errorf("unknown column type %d for column %q", col.Type, col.Name)
}
return nil
}
// DecodeChunk reads a chunk body produced by Encode.
func DecodeChunk(b []byte) (*Chunk, error) {
r := &reader{b: b}
magic, err := r.fixed(len(chunkMagic))
if err != nil {
return nil, err
}
if !bytes.Equal(magic, []byte(chunkMagic)) {
return nil, fmt.Errorf("bad chunk magic %q", magic)
}
ver, err := r.uvarint()
if err != nil {
return nil, err
}
if ver != chunkVersion {
return nil, fmt.Errorf("chunk version %d, this build understands %d", ver, chunkVersion)
}
rows, err := r.uvarint()
if err != nil {
return nil, err
}
if rows > maxRows {
return nil, fmt.Errorf("chunk claims %d rows, over the %d cap", rows, maxRows)
}
c := &Chunk{Rows: int(rows)}
if c.Dict, err = decodeDict(r); err != nil {
return nil, err
}
ncols, err := r.uvarint()
if err != nil {
return nil, err
}
if ncols > maxColumns {
return nil, fmt.Errorf("chunk claims %d columns, over the %d cap", ncols, maxColumns)
}
c.Cols = make([]Column, 0, ncols)
for i := uint64(0); i < ncols; i++ {
name, err := r.str()
if err != nil {
return nil, err
}
t, err := r.uvarint()
if err != nil {
return nil, err
}
col := Column{Name: name, Type: ColType(t)}
if err := decodeColumnData(r, &col, int(rows)); err != nil {
return nil, fmt.Errorf("column %q: %w", name, err)
}
c.Cols = append(c.Cols, col)
}
nattrs, err := r.uvarint()
if err != nil {
return nil, err
}
if nattrs > maxRows*maxColumns {
return nil, fmt.Errorf("chunk claims %d attribute pairs, implausible", nattrs)
}
c.AttrKeys = make([]uint32, 0, nattrs)
c.AttrVals = make([]uint32, 0, nattrs)
c.AttrTypes = make([]uint8, 0, nattrs)
for i := uint64(0); i < nattrs; i++ {
k, err := r.uvarint()
if err != nil {
return nil, err
}
v, err := r.uvarint()
if err != nil {
return nil, err
}
t, err := r.byte()
if err != nil {
return nil, err
}
c.AttrKeys = append(c.AttrKeys, uint32(k))
c.AttrVals = append(c.AttrVals, uint32(v))
c.AttrTypes = append(c.AttrTypes, t)
}
return c, nil
}
func decodeColumnData(r *reader, col *Column, rows int) error {
switch col.Type {
case ColDict:
col.Codes = make([]uint32, rows)
for i := range col.Codes {
v, err := r.uvarint()
if err != nil {
return err
}
col.Codes[i] = uint32(v)
}
case ColInt64:
col.I64 = make([]int64, rows)
for i := range col.I64 {
v, err := r.uvarint()
if err != nil {
return err
}
col.I64[i] = unzigzag(v)
}
case ColUint8:
b, err := r.fixed(rows)
if err != nil {
return err
}
col.U8 = make([]uint8, rows)
copy(col.U8, b)
case ColFloat32:
col.F32 = make([]float32, rows)
for i := range col.F32 {
b, err := r.fixed(4)
if err != nil {
return err
}
col.F32[i] = f32frombits(binary.LittleEndian.Uint32(b))
}
case ColFloat64:
col.F64 = make([]float64, rows)
for i := range col.F64 {
b, err := r.fixed(8)
if err != nil {
return err
}
col.F64[i] = math.Float64frombits(binary.LittleEndian.Uint64(b))
}
case ColBlobRef:
col.Blobs = make([]BlobRef, rows)
for i := range col.Blobs {
off, err := r.uvarint()
if err != nil {
return err
}
n, err := r.uvarint()
if err != nil {
return err
}
col.Blobs[i] = BlobRef{Off: int64(off), Len: uint32(n)}
}
default:
return fmt.Errorf("unknown column type %d", col.Type)
}
return nil
}
func zigzag(v int64) uint64 { return uint64(v<<1) ^ uint64(v>>63) }
func unzigzag(v uint64) int64 { return int64(v>>1) ^ -int64(v&1) }
func f32bits(f float32) uint32 { return math.Float32bits(f) }
func f32frombits(u uint32) float32 { return math.Float32frombits(u) }