Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,8 @@ type builder struct {
// With workers > 1, blocks are built in parallel while maintaining
// the streaming API and O(W × block_size) memory.
func newBuilder(ctx context.Context, output string, totalKeys uint64, opts ...BuildOption) (*builder, error) {
if totalKeys == 0 {
return nil, sherr.ErrEmptyIndex
}

// totalKeys == 0 is allowed: numBlocks() floors at 2, so a zero-key build
// emits empty blocks and finalizes to a valid empty index.
if totalKeys > maxKeys {
return nil, sherr.ErrTooManyKeys
}
Expand Down Expand Up @@ -128,6 +126,11 @@ func newBuilder(ctx context.Context, output string, totalKeys uint64, opts ...Bu
if workers > int(numBlocks) {
workers = int(numBlocks)
}
// A zero-key build has nothing to parallelize; force single-threaded so it
// skips the unused parallel writer pipeline.
if totalKeys == 0 {
workers = 1
}

b := &builder{
ctx: ctx,
Expand Down Expand Up @@ -293,7 +296,12 @@ func (b *builder) finishSingleThreaded() error {
}
}

// Emit trailing empty blocks
return b.commitTrailingEmptyBlocksAndFinalize()
}

// commitTrailingEmptyBlocksAndFinalize emits empty blocks until blockCount
// reaches numBlocks, then finalizes.
func (b *builder) commitTrailingEmptyBlocksAndFinalize() error {
for b.iw.blockCount < b.numBlocks {
if err := b.commitEmptyBlock(); err != nil {
return errors.Join(err, b.cleanup())
Expand Down Expand Up @@ -377,6 +385,9 @@ type SortedBuilder struct {
// NewSortedBuilder creates a builder for sorted input.
// Keys must be added in block-sorted order via AddKey.
// Use WithWorkers(N) to parallelize block building during Finish.
//
// totalKeys may be 0: the result is a valid empty index whose every QueryRank
// returns ErrNotFound.
Comment thread
tamirms marked this conversation as resolved.
func NewSortedBuilder(ctx context.Context, output string, totalKeys uint64, opts ...BuildOption) (*SortedBuilder, error) {
b, err := newBuilder(ctx, output, totalKeys, opts...)
if err != nil {
Expand Down
9 changes: 9 additions & 0 deletions builder_unsorted.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@ type UnsortedBuilder struct {
// not recommended at scale since it stores data in RAM/swap.
//
// Use WithWorkers(N) to parallelize block building during Finish.
//
// totalKeys may be 0: the result is a valid empty index whose every QueryRank
// returns ErrNotFound.
Comment thread
tamirms marked this conversation as resolved.
func NewUnsortedBuilder(ctx context.Context, output string, totalKeys uint64, tempDir string, opts ...BuildOption) (*UnsortedBuilder, error) {
b, err := newBuilder(ctx, output, totalKeys, opts...)
if err != nil {
Expand Down Expand Up @@ -770,6 +773,12 @@ func (ub *UnsortedBuilder) Finish() error {
return errors.Join(primaryErr, ub.cleanupAll())
}

// With no keys added, the lazily-created unsorted buffer is still nil and
// finishUnsorted would dereference it. Emit the empty index directly.
if ub.unsortedBuf == nil {
return b.commitTrailingEmptyBlocksAndFinalize()
}

return ub.finishUnsorted()
}

Expand Down
166 changes: 166 additions & 0 deletions empty_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package streamhash

import (
"context"
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"

"github.com/stellar/streamhash/internal/sherr"
)

// buildEmptyIndex builds a zero-key index via the requested builder type and
// options, returning the file path.
func buildEmptyIndex(t *testing.T, unsorted bool, opts ...BuildOption) string {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "empty.idx")

if unsorted {
b, err := NewUnsortedBuilder(ctx, path, 0, dir, opts...)
if err != nil {
t.Fatalf("NewUnsortedBuilder(0): %v", err)
}
if err := b.Finish(); err != nil {
t.Fatalf("unsorted Finish: %v", err)
}
return path
}

b, err := NewSortedBuilder(ctx, path, 0, opts...)
if err != nil {
t.Fatalf("NewSortedBuilder(0): %v", err)
}
if err := b.Finish(); err != nil {
t.Fatalf("sorted Finish: %v", err)
}
return path
}

// TestEmptyIndexWithWorkers checks that a zero-key build requested with
// WithWorkers still succeeds: newBuilder forces single-threaded for an empty
// build (nothing to parallelize), so it must not reach the parallel finish
// paths, which assume real block work.
func TestEmptyIndexWithWorkers(t *testing.T) {
for _, unsorted := range []bool{false, true} {
name := "sorted"
if unsorted {
name = "unsorted"
}
t.Run(name, func(t *testing.T) {
path := buildEmptyIndex(t, unsorted, WithWorkers(4))
idx, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer idx.Close()
if got := idx.NumKeys(); got != 0 {
t.Errorf("NumKeys() = %d, want 0", got)
}
key := make([]byte, MinKeySize)
if _, err := idx.QueryRank(key); !errors.Is(err, sherr.ErrNotFound) {
t.Errorf("QueryRank = %v, want ErrNotFound", err)
}
})
}
}

// TestEmptyIndex pins the zero-key contract across both algorithms, both
// builder types, and with/without payload+fingerprint: the build succeeds, the
// index opens and passes integrity Verify, reports zero keys, and every
// QueryRank resolves to ErrNotFound (never a spurious hit or a decode error).
func TestEmptyIndex(t *testing.T) {
algos := []struct {
name string
opt BuildOption
}{
{"bijection", WithAlgorithm(AlgoBijection)},
{"ptrhash", WithAlgorithm(AlgoPTRHash)},
}
builders := []struct {
name string
unsorted bool
}{
{"sorted", false},
{"unsorted", true},
}
// mphf-only vs the txhash-style payload+fingerprint layout, which gives the
// header a non-zero PayloadSize/FingerprintSize even though the payload
// region is empty.
optionSets := []struct {
name string
opts []BuildOption
hasPayload bool
}{
{"mphf-only", nil, false},
{"payload+fingerprint", []BuildOption{WithPayload(8), WithFingerprint(4)}, true},
}

for _, a := range algos {
for _, bt := range builders {
for _, optSet := range optionSets {
t.Run(a.name+"/"+bt.name+"/"+optSet.name, func(t *testing.T) {
opts := append([]BuildOption{a.opt}, optSet.opts...)
path := buildEmptyIndex(t, bt.unsorted, opts...)

idx, err := Open(path)
if err != nil {
t.Fatalf("Open empty index: %v", err)
}
defer idx.Close()

if got := idx.NumKeys(); got != 0 {
t.Errorf("NumKeys() = %d, want 0", got)
}
if err := idx.Verify(); err != nil {
t.Errorf("Verify() on empty index: %v", err)
}

// Every key must miss cleanly.
for i := range 200 {
var key [16]byte
binary.LittleEndian.PutUint64(key[0:], uint64(i)*0x9e3779b97f4a7c15)
binary.LittleEndian.PutUint64(key[8:], uint64(i)^0xdeadbeef)
if _, qerr := idx.QueryRank(key[:]); !errors.Is(qerr, sherr.ErrNotFound) {
t.Fatalf("QueryRank(key %d) = %v, want ErrNotFound", i, qerr)
}
}

// The payload read path (used by the txhash cold store) must
// also miss cleanly rather than read the zero-size region.
if optSet.hasPayload {
pi, perr := OpenPayload(path)
if perr != nil {
t.Fatalf("OpenPayload empty index: %v", perr)
}
var key [16]byte
binary.LittleEndian.PutUint64(key[0:], 0x1234)
if _, _, qerr := pi.QueryPayload(key[:]); !errors.Is(qerr, sherr.ErrNotFound) {
t.Errorf("QueryPayload on empty index = %v, want ErrNotFound", qerr)
}
_ = pi.Close()
}

// The in-memory open path must accept it too.
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
bIdx, err := OpenBytes(data)
if err != nil {
t.Fatalf("OpenBytes empty index: %v", err)
}
if got := bIdx.NumKeys(); got != 0 {
t.Errorf("OpenBytes NumKeys() = %d, want 0", got)
}
if err := bIdx.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
}
}
}
28 changes: 23 additions & 5 deletions error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,34 @@ func TestBuilderInvalidPath(t *testing.T) {
}
}

// TestBuilderZeroKeys pins that a zero-key build is NOT an error: it produces a
// valid empty index whose every lookup misses. (Full cross-algorithm and
// unsorted coverage lives in TestEmptyIndex.)
func TestBuilderZeroKeys(t *testing.T) {
tmpDir := t.TempDir()
indexPath := filepath.Join(tmpDir, "zero.idx")
ctx := context.Background()
_, err := NewSortedBuilder(ctx, indexPath, 0)
if err == nil {
t.Error("Expected error for zero keys")

b, err := NewSortedBuilder(ctx, indexPath, 0)
if err != nil {
t.Fatalf("NewSortedBuilder(0): %v", err)
}
if err := b.Finish(); err != nil {
t.Fatalf("Finish zero-key build: %v", err)
}

idx, err := Open(indexPath)
if err != nil {
t.Fatalf("Open zero-key index: %v", err)
}
defer idx.Close()

if got := idx.NumKeys(); got != 0 {
t.Errorf("NumKeys() = %d, want 0", got)
}
if !errors.Is(err, sherr.ErrEmptyIndex) {
t.Errorf("Expected ErrEmptyIndex, got %v", err)
key := make([]byte, MinKeySize)
if _, err := idx.QueryRank(key); !errors.Is(err, sherr.ErrNotFound) {
t.Errorf("QueryRank on empty index = %v, want ErrNotFound", err)
}
}

Expand Down
1 change: 0 additions & 1 deletion internal/sherr/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import "errors"
// Build errors
var (
ErrBuilderClosed = errors.New("streamhash: builder is closed")
ErrEmptyIndex = errors.New("streamhash: cannot build index with zero keys")
ErrTooManyKeys = errors.New("streamhash: key count exceeds maximum (2^40 - 1)")
ErrKeyTooShort = errors.New("streamhash: key is shorter than minimum required length")
ErrKeyTooLong = errors.New("streamhash: key exceeds maximum length (65535 bytes)")
Expand Down
1 change: 0 additions & 1 deletion sentinels.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import "github.com/stellar/streamhash/internal/sherr"
// Build errors.
var (
ErrBuilderClosed = sherr.ErrBuilderClosed
ErrEmptyIndex = sherr.ErrEmptyIndex
ErrTooManyKeys = sherr.ErrTooManyKeys
ErrKeyTooShort = sherr.ErrKeyTooShort
Comment thread
tamirms marked this conversation as resolved.
ErrKeyTooLong = sherr.ErrKeyTooLong
Expand Down
3 changes: 2 additions & 1 deletion streamhash-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,6 @@ Error names are from the reference implementation; other implementations may dif

| Error | Condition |
|-------|-----------|
| `ErrEmptyIndex` | N = 0 |
| `ErrKeyTooShort` | key < 16 bytes |
| `ErrKeyTooLong` | key > 65,535 bytes |
| `ErrDuplicateKey` | Bijection: identical `k0` and `k1`. PTRHash: two keys in a bucket share `k0 ^ k1` (§7.1) |
Expand All @@ -855,6 +854,8 @@ Error names are from the reference implementation; other implementations may dif
| `ErrSplitBucketSeedSearchFailed` | Bijection split-bucket search exhausted → retry with new `globalSeed` |
| `ErrIndistinguishableHashes` | PTRHash bucket unsolvable → retry with new `globalSeed` |

An index with `N = 0` keys is not an error: it builds successfully, and every `QueryRank` / `QueryPayload` returns `ErrNotFound`.
Comment thread
tamirms marked this conversation as resolved.

**Query / open:**

| Error | Condition |
Expand Down
25 changes: 0 additions & 25 deletions test_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"testing"

intbits "github.com/stellar/streamhash/internal/bits"
"github.com/stellar/streamhash/internal/sherr"
)

// entry represents a key-payload pair for test building helpers.
Expand Down Expand Up @@ -142,10 +141,6 @@ func sortEntriesByBlock(entries []entry, opts []BuildOption) {
// buildFromSlice builds an index from a slice of entries.
// Entries are sorted by block index before building.
func buildFromSlice(ctx context.Context, output string, entries []entry, opts ...BuildOption) error {
if len(entries) == 0 {
return sherr.ErrEmptyIndex
}

cfg := defaultBuildConfig()
for _, opt := range opts {
opt(cfg)
Expand Down Expand Up @@ -178,10 +173,6 @@ func buildFromSlice(ctx context.Context, output string, entries []entry, opts ..

// buildSorted builds an index from a key iterator with []byte payloads.
func buildSorted(ctx context.Context, output string, totalKeys uint64, keys func(yield func([]byte, []byte) bool), opts ...BuildOption) error {
if totalKeys == 0 {
return sherr.ErrEmptyIndex
}

builder, err := NewSortedBuilder(ctx, output, totalKeys, opts...)
if err != nil {
return err
Expand All @@ -199,10 +190,6 @@ func buildSorted(ctx context.Context, output string, totalKeys uint64, keys func

// buildFromEntries builds an index from entries, sorting them first.
func buildFromEntries(ctx context.Context, output string, entries []entry, opts ...BuildOption) error {
if len(entries) == 0 {
return sherr.ErrEmptyIndex
}

sorted := make([]entry, len(entries))
copy(sorted, entries)
sortEntriesByBlock(sorted, opts)
Expand All @@ -225,10 +212,6 @@ func buildFromEntries(ctx context.Context, output string, entries []entry, opts
// quickBuild builds from keys (no payloads), sorting by block.
// It copies the input slice to avoid mutating the caller's data.
func quickBuild(ctx context.Context, output string, keys [][]byte, opts ...BuildOption) error {
if len(keys) == 0 {
return sherr.ErrEmptyIndex
}

sorted := make([][]byte, len(keys))
copy(sorted, keys)
keys = sorted
Expand Down Expand Up @@ -269,10 +252,6 @@ func buildUnsortedFromIter(ctx context.Context, output string, iter func(yield f
return true
})

if len(entries) == 0 {
return sherr.ErrEmptyIndex
}

sortEntriesByBlock(entries, opts)

builder, err := NewSortedBuilder(ctx, output, uint64(len(entries)), opts...)
Expand Down Expand Up @@ -300,10 +279,6 @@ func buildParallelBytes(ctx context.Context, output string, iter func(yield func
return true
})

if len(entries) == 0 {
return sherr.ErrEmptyIndex
}

sortEntriesByBlock(entries, opts)

opts = append(opts, WithWorkers(4))
Expand Down
Loading