Skip to content

Commit 9f6eeb7

Browse files
tamirmsclaude
andcommitted
feat: support zero-key (empty) indexes
Remove the newBuilder guard that rejected totalKeys == 0. numBlocks() floors at 2, so a zero-key build now flows through the existing empty-block machinery and produces a valid index whose every QueryRank returns ErrNotFound. Force single-threaded for a zero-key build (nothing to parallelize) and guard the unsorted Finish, whose lazily-created buffer is nil when no keys were ever added. Remove ErrEmptyIndex: zero keys is no longer an error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9e08e05 commit 9f6eeb7

7 files changed

Lines changed: 214 additions & 37 deletions

File tree

builder.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,8 @@ type builder struct {
8686
// With workers > 1, blocks are built in parallel while maintaining
8787
// the streaming API and O(W × block_size) memory.
8888
func newBuilder(ctx context.Context, output string, totalKeys uint64, opts ...BuildOption) (*builder, error) {
89-
if totalKeys == 0 {
90-
return nil, sherr.ErrEmptyIndex
91-
}
92-
89+
// totalKeys == 0 is allowed: numBlocks() floors at 2, so a zero-key build
90+
// emits empty blocks and finalizes to a valid empty index.
9391
if totalKeys > maxKeys {
9492
return nil, sherr.ErrTooManyKeys
9593
}
@@ -128,6 +126,11 @@ func newBuilder(ctx context.Context, output string, totalKeys uint64, opts ...Bu
128126
if workers > int(numBlocks) {
129127
workers = int(numBlocks)
130128
}
129+
// A zero-key build has nothing to parallelize; force single-threaded so it
130+
// skips the unused parallel writer pipeline.
131+
if totalKeys == 0 {
132+
workers = 1
133+
}
131134

132135
b := &builder{
133136
ctx: ctx,
@@ -293,7 +296,12 @@ func (b *builder) finishSingleThreaded() error {
293296
}
294297
}
295298

296-
// Emit trailing empty blocks
299+
return b.commitTrailingEmptyBlocksAndFinalize()
300+
}
301+
302+
// commitTrailingEmptyBlocksAndFinalize emits empty blocks until blockCount
303+
// reaches numBlocks, then finalizes.
304+
func (b *builder) commitTrailingEmptyBlocksAndFinalize() error {
297305
for b.iw.blockCount < b.numBlocks {
298306
if err := b.commitEmptyBlock(); err != nil {
299307
return errors.Join(err, b.cleanup())
@@ -377,6 +385,9 @@ type SortedBuilder struct {
377385
// NewSortedBuilder creates a builder for sorted input.
378386
// Keys must be added in block-sorted order via AddKey.
379387
// Use WithWorkers(N) to parallelize block building during Finish.
388+
//
389+
// totalKeys may be 0: the result is a valid empty index whose every QueryRank
390+
// returns ErrNotFound.
380391
func NewSortedBuilder(ctx context.Context, output string, totalKeys uint64, opts ...BuildOption) (*SortedBuilder, error) {
381392
b, err := newBuilder(ctx, output, totalKeys, opts...)
382393
if err != nil {

builder_unsorted.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,9 @@ type UnsortedBuilder struct {
548548
// not recommended at scale since it stores data in RAM/swap.
549549
//
550550
// Use WithWorkers(N) to parallelize block building during Finish.
551+
//
552+
// totalKeys may be 0: the result is a valid empty index whose every QueryRank
553+
// returns ErrNotFound.
551554
func NewUnsortedBuilder(ctx context.Context, output string, totalKeys uint64, tempDir string, opts ...BuildOption) (*UnsortedBuilder, error) {
552555
b, err := newBuilder(ctx, output, totalKeys, opts...)
553556
if err != nil {
@@ -770,6 +773,12 @@ func (ub *UnsortedBuilder) Finish() error {
770773
return errors.Join(primaryErr, ub.cleanupAll())
771774
}
772775

776+
// With no keys added, the lazily-created unsorted buffer is still nil and
777+
// finishUnsorted would dereference it. Emit the empty index directly.
778+
if ub.unsortedBuf == nil {
779+
return b.commitTrailingEmptyBlocksAndFinalize()
780+
}
781+
773782
return ub.finishUnsorted()
774783
}
775784

empty_index_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package streamhash
2+
3+
import (
4+
"context"
5+
"encoding/binary"
6+
"errors"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stellar/streamhash/internal/sherr"
12+
)
13+
14+
// buildEmptyIndex builds a zero-key index via the requested builder type and
15+
// options, returning the file path.
16+
func buildEmptyIndex(t *testing.T, unsorted bool, opts ...BuildOption) string {
17+
t.Helper()
18+
ctx := context.Background()
19+
dir := t.TempDir()
20+
path := filepath.Join(dir, "empty.idx")
21+
22+
if unsorted {
23+
b, err := NewUnsortedBuilder(ctx, path, 0, dir, opts...)
24+
if err != nil {
25+
t.Fatalf("NewUnsortedBuilder(0): %v", err)
26+
}
27+
if err := b.Finish(); err != nil {
28+
t.Fatalf("unsorted Finish: %v", err)
29+
}
30+
return path
31+
}
32+
33+
b, err := NewSortedBuilder(ctx, path, 0, opts...)
34+
if err != nil {
35+
t.Fatalf("NewSortedBuilder(0): %v", err)
36+
}
37+
if err := b.Finish(); err != nil {
38+
t.Fatalf("sorted Finish: %v", err)
39+
}
40+
return path
41+
}
42+
43+
// TestEmptyIndexWithWorkers checks that a zero-key build requested with
44+
// WithWorkers still succeeds: newBuilder forces single-threaded for an empty
45+
// build (nothing to parallelize), so it must not reach the parallel finish
46+
// paths, which assume real block work.
47+
func TestEmptyIndexWithWorkers(t *testing.T) {
48+
for _, unsorted := range []bool{false, true} {
49+
name := "sorted"
50+
if unsorted {
51+
name = "unsorted"
52+
}
53+
t.Run(name, func(t *testing.T) {
54+
path := buildEmptyIndex(t, unsorted, WithWorkers(4))
55+
idx, err := Open(path)
56+
if err != nil {
57+
t.Fatalf("Open: %v", err)
58+
}
59+
defer idx.Close()
60+
if got := idx.NumKeys(); got != 0 {
61+
t.Errorf("NumKeys() = %d, want 0", got)
62+
}
63+
key := make([]byte, MinKeySize)
64+
if _, err := idx.QueryRank(key); !errors.Is(err, sherr.ErrNotFound) {
65+
t.Errorf("QueryRank = %v, want ErrNotFound", err)
66+
}
67+
})
68+
}
69+
}
70+
71+
// TestEmptyIndex pins the zero-key contract across both algorithms, both
72+
// builder types, and with/without payload+fingerprint: the build succeeds, the
73+
// index opens and passes integrity Verify, reports zero keys, and every
74+
// QueryRank resolves to ErrNotFound (never a spurious hit or a decode error).
75+
func TestEmptyIndex(t *testing.T) {
76+
algos := []struct {
77+
name string
78+
opt BuildOption
79+
}{
80+
{"bijection", WithAlgorithm(AlgoBijection)},
81+
{"ptrhash", WithAlgorithm(AlgoPTRHash)},
82+
}
83+
builders := []struct {
84+
name string
85+
unsorted bool
86+
}{
87+
{"sorted", false},
88+
{"unsorted", true},
89+
}
90+
// mphf-only vs the txhash-style payload+fingerprint layout, which gives the
91+
// header a non-zero PayloadSize/FingerprintSize even though the payload
92+
// region is empty.
93+
optionSets := []struct {
94+
name string
95+
opts []BuildOption
96+
hasPayload bool
97+
}{
98+
{"mphf-only", nil, false},
99+
{"payload+fingerprint", []BuildOption{WithPayload(8), WithFingerprint(4)}, true},
100+
}
101+
102+
for _, a := range algos {
103+
for _, bt := range builders {
104+
for _, optSet := range optionSets {
105+
t.Run(a.name+"/"+bt.name+"/"+optSet.name, func(t *testing.T) {
106+
opts := append([]BuildOption{a.opt}, optSet.opts...)
107+
path := buildEmptyIndex(t, bt.unsorted, opts...)
108+
109+
idx, err := Open(path)
110+
if err != nil {
111+
t.Fatalf("Open empty index: %v", err)
112+
}
113+
defer idx.Close()
114+
115+
if got := idx.NumKeys(); got != 0 {
116+
t.Errorf("NumKeys() = %d, want 0", got)
117+
}
118+
if err := idx.Verify(); err != nil {
119+
t.Errorf("Verify() on empty index: %v", err)
120+
}
121+
122+
// Every key must miss cleanly.
123+
for i := range 200 {
124+
var key [16]byte
125+
binary.LittleEndian.PutUint64(key[0:], uint64(i)*0x9e3779b97f4a7c15)
126+
binary.LittleEndian.PutUint64(key[8:], uint64(i)^0xdeadbeef)
127+
if _, qerr := idx.QueryRank(key[:]); !errors.Is(qerr, sherr.ErrNotFound) {
128+
t.Fatalf("QueryRank(key %d) = %v, want ErrNotFound", i, qerr)
129+
}
130+
}
131+
132+
// The payload read path (used by the txhash cold store) must
133+
// also miss cleanly rather than read the zero-size region.
134+
if optSet.hasPayload {
135+
pi, perr := OpenPayload(path)
136+
if perr != nil {
137+
t.Fatalf("OpenPayload empty index: %v", perr)
138+
}
139+
var key [16]byte
140+
binary.LittleEndian.PutUint64(key[0:], 0x1234)
141+
if _, _, qerr := pi.QueryPayload(key[:]); !errors.Is(qerr, sherr.ErrNotFound) {
142+
t.Errorf("QueryPayload on empty index = %v, want ErrNotFound", qerr)
143+
}
144+
_ = pi.Close()
145+
}
146+
147+
// The in-memory open path must accept it too.
148+
data, err := os.ReadFile(path)
149+
if err != nil {
150+
t.Fatal(err)
151+
}
152+
bIdx, err := OpenBytes(data)
153+
if err != nil {
154+
t.Fatalf("OpenBytes empty index: %v", err)
155+
}
156+
if got := bIdx.NumKeys(); got != 0 {
157+
t.Errorf("OpenBytes NumKeys() = %d, want 0", got)
158+
}
159+
if err := bIdx.Close(); err != nil {
160+
t.Errorf("Close: %v", err)
161+
}
162+
})
163+
}
164+
}
165+
}
166+
}

error_test.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,16 +221,34 @@ func TestBuilderInvalidPath(t *testing.T) {
221221
}
222222
}
223223

224+
// TestBuilderZeroKeys pins that a zero-key build is NOT an error: it produces a
225+
// valid empty index whose every lookup misses. (Full cross-algorithm and
226+
// unsorted coverage lives in TestEmptyIndex.)
224227
func TestBuilderZeroKeys(t *testing.T) {
225228
tmpDir := t.TempDir()
226229
indexPath := filepath.Join(tmpDir, "zero.idx")
227230
ctx := context.Background()
228-
_, err := NewSortedBuilder(ctx, indexPath, 0)
229-
if err == nil {
230-
t.Error("Expected error for zero keys")
231+
232+
b, err := NewSortedBuilder(ctx, indexPath, 0)
233+
if err != nil {
234+
t.Fatalf("NewSortedBuilder(0): %v", err)
235+
}
236+
if err := b.Finish(); err != nil {
237+
t.Fatalf("Finish zero-key build: %v", err)
238+
}
239+
240+
idx, err := Open(indexPath)
241+
if err != nil {
242+
t.Fatalf("Open zero-key index: %v", err)
243+
}
244+
defer idx.Close()
245+
246+
if got := idx.NumKeys(); got != 0 {
247+
t.Errorf("NumKeys() = %d, want 0", got)
231248
}
232-
if !errors.Is(err, sherr.ErrEmptyIndex) {
233-
t.Errorf("Expected ErrEmptyIndex, got %v", err)
249+
key := make([]byte, MinKeySize)
250+
if _, err := idx.QueryRank(key); !errors.Is(err, sherr.ErrNotFound) {
251+
t.Errorf("QueryRank on empty index = %v, want ErrNotFound", err)
234252
}
235253
}
236254

internal/sherr/errors.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import "errors"
77
// Build errors
88
var (
99
ErrBuilderClosed = errors.New("streamhash: builder is closed")
10-
ErrEmptyIndex = errors.New("streamhash: cannot build index with zero keys")
1110
ErrTooManyKeys = errors.New("streamhash: key count exceeds maximum (2^40 - 1)")
1211
ErrKeyTooShort = errors.New("streamhash: key is shorter than minimum required length")
1312
ErrKeyTooLong = errors.New("streamhash: key exceeds maximum length (65535 bytes)")

sentinels.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import "github.com/stellar/streamhash/internal/sherr"
55
// Build errors.
66
var (
77
ErrBuilderClosed = sherr.ErrBuilderClosed
8-
ErrEmptyIndex = sherr.ErrEmptyIndex
98
ErrTooManyKeys = sherr.ErrTooManyKeys
109
ErrKeyTooShort = sherr.ErrKeyTooShort
1110
ErrKeyTooLong = sherr.ErrKeyTooLong

test_helpers_test.go

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"testing"
1313

1414
intbits "github.com/stellar/streamhash/internal/bits"
15-
"github.com/stellar/streamhash/internal/sherr"
1615
)
1716

1817
// entry represents a key-payload pair for test building helpers.
@@ -142,10 +141,6 @@ func sortEntriesByBlock(entries []entry, opts []BuildOption) {
142141
// buildFromSlice builds an index from a slice of entries.
143142
// Entries are sorted by block index before building.
144143
func buildFromSlice(ctx context.Context, output string, entries []entry, opts ...BuildOption) error {
145-
if len(entries) == 0 {
146-
return sherr.ErrEmptyIndex
147-
}
148-
149144
cfg := defaultBuildConfig()
150145
for _, opt := range opts {
151146
opt(cfg)
@@ -178,10 +173,6 @@ func buildFromSlice(ctx context.Context, output string, entries []entry, opts ..
178173

179174
// buildSorted builds an index from a key iterator with []byte payloads.
180175
func buildSorted(ctx context.Context, output string, totalKeys uint64, keys func(yield func([]byte, []byte) bool), opts ...BuildOption) error {
181-
if totalKeys == 0 {
182-
return sherr.ErrEmptyIndex
183-
}
184-
185176
builder, err := NewSortedBuilder(ctx, output, totalKeys, opts...)
186177
if err != nil {
187178
return err
@@ -199,10 +190,6 @@ func buildSorted(ctx context.Context, output string, totalKeys uint64, keys func
199190

200191
// buildFromEntries builds an index from entries, sorting them first.
201192
func buildFromEntries(ctx context.Context, output string, entries []entry, opts ...BuildOption) error {
202-
if len(entries) == 0 {
203-
return sherr.ErrEmptyIndex
204-
}
205-
206193
sorted := make([]entry, len(entries))
207194
copy(sorted, entries)
208195
sortEntriesByBlock(sorted, opts)
@@ -225,10 +212,6 @@ func buildFromEntries(ctx context.Context, output string, entries []entry, opts
225212
// quickBuild builds from keys (no payloads), sorting by block.
226213
// It copies the input slice to avoid mutating the caller's data.
227214
func quickBuild(ctx context.Context, output string, keys [][]byte, opts ...BuildOption) error {
228-
if len(keys) == 0 {
229-
return sherr.ErrEmptyIndex
230-
}
231-
232215
sorted := make([][]byte, len(keys))
233216
copy(sorted, keys)
234217
keys = sorted
@@ -269,10 +252,6 @@ func buildUnsortedFromIter(ctx context.Context, output string, iter func(yield f
269252
return true
270253
})
271254

272-
if len(entries) == 0 {
273-
return sherr.ErrEmptyIndex
274-
}
275-
276255
sortEntriesByBlock(entries, opts)
277256

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

303-
if len(entries) == 0 {
304-
return sherr.ErrEmptyIndex
305-
}
306-
307282
sortEntriesByBlock(entries, opts)
308283

309284
opts = append(opts, WithWorkers(4))

0 commit comments

Comments
 (0)