strview

package
v0.0.40 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

Documentation

Overview

Package strview implements the Arrow variable size binary view layout, which is how kuma stores String and Binary columns.

The classic Arrow layout for a string column is a buffer of offsets and a buffer of bytes, where element i runs from offsets[i] to offsets[i+1]. That is compact and it is a pointer chase for every single comparison, which is most of what an engine does to a string column.

The view layout replaces the offsets with a fixed sixteen byte record per element:

a value of 12 bytes or fewer, kept inline
  bytes 0 to 3    length
  bytes 4 to 15   the value, zero padded

a value that is longer, kept in a data block
  bytes 0 to 3    length
  bytes 4 to 7    the first four bytes of the value
  bytes 8 to 11   which data block the value is in
  bytes 12 to 15  where in that block it starts

Three things fall out of that. A value of twelve bytes or fewer is entirely inside its own view, and most real string data is short, so most values are read with no second memory access at all. Every view carries the first four bytes of its value in the same place whether it is short or long, so two values that differ in the first four bytes are ordered without touching the data at all. And a scan over a column becomes a dense walk over fixed width records, which vectorizes, where a walk over offsets does not.

The cost is memory. Sixteen bytes per element against four or eight for an offset, and long values are never deduplicated the way a shared offsets buffer can leave them. Dictionary encoding is the answer for the columns where that matters, and it is a bigger win than either layout.

Arrow calls the data blocks the data buffers and calls the block number the buffer index. They are blocks here because this repository already has a buffer package, and a buffer index into a list of buffers held in a buffer is one word too many.

Everything is little endian, on every machine, because that is what Arrow writes on the wire and converting at the boundary is cheaper than converting on every access. On the machines anyone runs this on, that is a plain load.

Stability: tier 1, stable.

Index

Examples

Constants

View Source
const (
	// Size is the width of one view in bytes.
	Size = 16
	// MaxInline is the longest value that fits inside a view. A value of this
	// length or shorter needs no data block.
	MaxInline = 12
	// PrefixLen is how many leading bytes of a value every view carries,
	// whether the value is inline or not.
	PrefixLen = 4
	// MaxValue is the longest value the layout can describe, and the highest
	// block number and offset it can name. The length, the block number and the
	// offset are all signed 32 bit fields, which is Arrow's choice rather than
	// this package's.
	MaxValue = math.MaxInt32
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder accumulates values into a Data.

Short values go straight into their views. Long values are copied into a data block, so the builder owns its bytes and the caller may reuse the slice it passed in, which is what makes it safe to build a column out of a scanner's reused read buffer.

The zero Builder is empty and ready to use.

Example
package main

import (
	"fmt"

	"github.com/tamnd/kuma/strview"
)

func main() {
	var b strview.Builder
	b.AppendString("kuma")
	b.AppendString("a value that is too long to live inside its view")

	d := b.Finish()
	for i := range d.Len() {
		fmt.Printf("%d bytes, inline %v: %s\n", d.View(i).Len(), d.View(i).IsInline(), d.At(i))
	}
}
Output:
4 bytes, inline true: kuma
48 bytes, inline false: a value that is too long to live inside its view

func (*Builder) Append

func (b *Builder) Append(p []byte)

Append adds p, copying it if it is too long to live in its view.

It panics if p is longer than MaxValue, or if the column has grown past what the block and offset fields can name. MakeRef is where that is enforced, rather than here as well, because a limit checked in two places is a limit that gets changed in one.

func (*Builder) AppendString

func (b *Builder) AppendString(s string)

AppendString adds s. It is Append without the conversion, since a string to byte slice conversion of a long value would be a copy this package is about to make anyway.

func (*Builder) Finish

func (b *Builder) Finish() *Data

Finish returns the values appended so far and resets the builder.

The Data takes the builder's memory rather than a copy of it, which is the only reason building a column is one allocation per block instead of two. Finish is what makes that safe: the builder comes back empty, so there is no way to write through it into a column that has already been handed out.

func (*Builder) Grow

func (b *Builder) Grow(n int)

Grow makes room for n more values without adding them.

func (*Builder) Len

func (b *Builder) Len() int

Len returns how many values have been appended.

func (*Builder) Reset

func (b *Builder) Reset()

Reset drops everything and leaves a builder ready to use again.

It gives up the memory rather than keeping it, because the memory may have been handed to a Data by Finish and writing into it again would change a column somebody else is reading.

type Data

type Data struct {
	// contains filtered or unexported fields
}

Data is the value part of a String or Binary column: the views, and the blocks the long ones point into.

It carries no validity bitmap and no dtype. Those belong to the array that holds this, because a null is not a value and this type is only about values.

func NewData

func NewData(views []View, blocks []*buffer.Buffer) (*Data, error)

NewData returns a column over views and blocks after checking that every view describes something that is actually there. It does not copy either argument, and the caller must not modify them afterwards.

The check is the point. Views that came from this process were built by Builder and are correct by construction, but views that arrived in an Arrow IPC message were written by something else, and a view that points past the end of a block is a read of somebody else's memory rather than a wrong answer. Validating once at the boundary is what lets every read after it be a slice with no bounds thinking in it.

Example

ExampleNewData opens a column that was built somewhere else, which is what receiving one over Arrow IPC amounts to. The views are checked against the blocks before anything is read, so a view that points past the end of its block is an error here rather than a wrong answer later.

package main

import (
	"fmt"

	"github.com/tamnd/kuma/strview"
)

func main() {
	var b strview.Builder
	b.AppendString("a value that does not fit inline")
	d := b.Finish()

	views, blocks := d.Views(), d.Blocks()
	views[0][12] = 0xff // move the value's offset out past the end of its block

	if _, err := strview.NewData(views, blocks); err != nil {
		fmt.Println(err)
	}
}
Output:
strview: view 0: wants bytes 255 to 287 of a block that is 32 long

func (*Data) At

func (d *Data) At(i int) []byte

At returns value i.

The result aliases the column, either the view itself for a short value or a data block for a long one, and the caller must not modify it. Copying here would be a copy per element on the hottest path there is.

func (*Data) Blocks

func (d *Data) Blocks() []*buffer.Buffer

Blocks returns the data blocks, which alias the column.

func (*Data) Compare

func (d *Data) Compare(i, j int) int

Compare returns a negative number, zero or a positive number as value i sorts before, with or after value j, ordering by bytes the way bytes.Compare does.

Prefixes settle it whenever they differ, which for real string data is nearly always, and they are four bytes sitting in a record the caller already had to load. Comparing zero padded prefixes gives the same answer as comparing the values: if the two prefixes first differ at a position past the end of the shorter value, then the shorter value is a prefix of the longer one and sorts first, which is what its pad byte of zero says.

Example
package main

import (
	"fmt"
	"slices"

	"github.com/tamnd/kuma/strview"
)

func main() {
	var b strview.Builder
	for _, s := range []string{"pear", "apple", "a rather longer name than the others", "banana"} {
		b.AppendString(s)
	}
	d := b.Finish()

	order := make([]int, d.Len())
	for i := range order {
		order[i] = i
	}
	slices.SortFunc(order, d.Compare)

	for _, i := range order {
		fmt.Println(string(d.At(i)))
	}
}
Output:
a rather longer name than the others
apple
banana
pear

func (*Data) Equal

func (d *Data) Equal(i, j int) bool

Equal reports whether values i and j are the same bytes.

Most calls are answered by the first two lines. Two views that are equal as sixteen raw bytes hold the same value, whether that is because both carry it inline or because both point at the same place. Two views with different lengths or different prefixes hold different values. Only values that are the same length and start the same way reach a byte comparison.

func (*Data) EqualValue

func (d *Data) EqualValue(i int, p []byte) bool

EqualValue reports whether value i is p. It is the probe side of a hash join or a group by lookup, where one side is a value already in hand.

Example
package main

import (
	"fmt"

	"github.com/tamnd/kuma/strview"
)

func main() {
	var b strview.Builder
	b.AppendString("tokyo")
	b.AppendString("kumamoto")
	d := b.Finish()

	for i := range d.Len() {
		fmt.Println(string(d.At(i)), d.EqualValue(i, []byte("kumamoto")))
	}
}
Output:
tokyo false
kumamoto true

func (*Data) Len

func (d *Data) Len() int

Len returns the number of values.

func (*Data) View

func (d *Data) View(i int) View

View returns the view for value i.

func (*Data) Views

func (d *Data) Views() []View

Views returns the views, which alias the column. Modifying them modifies it.

type View

type View [Size]byte

View describes one value in a String or Binary column.

It is a byte array rather than a struct of fields, so that a slice of views is exactly the bytes Arrow puts on the wire and can be handed across the C data interface without being rewritten. The accessors read little endian integers out of it.

The zero View is a valid empty value, which means a column of views that has been sized but not filled reads as empty strings rather than as anything surprising.

Example

ExampleView shows the layout of a short value. The first four bytes are the length and the rest is the value, zero padded, so nothing else has to be read to get it back.

package main

import (
	"fmt"

	"github.com/tamnd/kuma/strview"
)

func main() {
	v := strview.MakeInline([]byte("kuma"))
	fmt.Printf("%v\n", v[:])
	fmt.Println(v)
}
Output:
[4 0 0 0 107 117 109 97 0 0 0 0 0 0 0 0]
inline("kuma")
Example (Prefix)

ExampleView_prefix shows the other half of the layout. A long value keeps its first four bytes in the view, in the same place a short value keeps the start of its own, so a comparison that is settled in the first four bytes never reaches a data block.

package main

import (
	"fmt"

	"github.com/tamnd/kuma/strview"
)

func main() {
	var b strview.Builder
	b.AppendString("kuma")
	b.AppendString("kumamoto prefecture, in the south")
	d := b.Finish()

	for i := range d.Len() {
		p := d.View(i).Prefix()
		fmt.Printf("%q\n", p[:])
	}
}
Output:
"kuma"
"kuma"

func MakeInline

func MakeInline(value []byte) View

MakeInline returns a view holding value inside itself. It panics if the value is longer than MaxInline.

The bytes past the value are left zero, and they have to be: it is what makes two views of the same short value identical as raw bytes, so equality can be a sixteen byte comparison rather than a length check and a loop.

func MakeRef

func MakeRef(value []byte, block, offset int) View

MakeRef returns a view for a value that lives at offset in block. It panics if the value is short enough to inline, since a column with two ways to spell the same value is a column where equality has to know about both. It also panics if the value, the block number or the offset is out of range, rather than truncating to 32 bits and describing a value that is not there.

func (View) Block

func (v View) Block() int

Block returns which data block holds the value. It is meaningless for an inline view.

func (*View) Inline

func (v *View) Inline() []byte

Inline returns the bytes of an inline value, aliasing the view itself. The caller must not modify the result. It panics if the view is not inline.

The receiver is a pointer so that the result aliases the caller's view rather than a copy of it, which is what keeps this from allocating.

func (View) IsInline

func (v View) IsInline() bool

IsInline reports whether the value is inside the view rather than in a data block.

func (View) Len

func (v View) Len() int

Len returns the length of the value in bytes.

The length is a signed 32 bit field in the Arrow layout, so a view that came from somewhere else can claim a negative length. This returns what it claims. Validate is what refuses it.

func (View) Offset

func (v View) Offset() int

Offset returns where in its block the value starts. It is meaningless for an inline view.

func (View) Prefix

func (v View) Prefix() [PrefixLen]byte

Prefix returns the first PrefixLen bytes of the value, zero padded if the value is shorter than that.

This is the reason the layout exists. Two values whose prefixes differ are ordered by their prefixes alone, with no access to any data block, and for real string data that settles the overwhelming majority of comparisons.

func (View) String

func (v View) String() string

String returns a description of the view for debugging. It does not return the value, since a long view cannot reach its own data.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL