From 05b633afcae83f6240af3c4e68625887e6dd36f3 Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Sat, 4 Apr 2026 00:13:50 +0200 Subject: [PATCH 01/11] feat: new put implementation --- bucket.go | 22 ++++---- cuckoo_fuzz_test.go | 2 +- cuckoo_internal_test.go | 2 +- cuckoo_test.go | 30 +++++------ doc_example_test.go | 2 +- settings.go | 5 ++ table.go | 115 ++++++++++++++++++++++++++-------------- 7 files changed, 111 insertions(+), 67 deletions(-) diff --git a/bucket.go b/bucket.go index 6a4171c..8010f3e 100644 --- a/bucket.go +++ b/bucket.go @@ -1,12 +1,13 @@ package cuckoo -type entry[K, V any] struct { +// An Entry is a key-value pair. +type Entry[K, V any] struct { key K value V } type slot[K, V any] struct { - entry[K, V] + Entry[K, V] occupied bool } @@ -48,10 +49,13 @@ func (b *bucket[K, V]) drop(key K) (occupied bool) { return false } -func (b *bucket[K, V]) resize(capacity uint64) { - b.slots = make([]slot[K, V], capacity) - b.capacity = capacity - b.size = 0 +func (b *bucket[K, V]) resized(capacity uint64) bucket[K, V] { + return bucket[K, V]{ + slots: make([]slot[K, V], capacity), + capacity: capacity, + hash: b.hash, + compare: b.compare, + } } func (b bucket[K, V]) update(key K, value V) (updated bool) { @@ -69,7 +73,7 @@ func (b bucket[K, V]) update(key K, value V) (updated bool) { return false } -func (b *bucket[K, V]) evict(insertion entry[K, V]) (evicted entry[K, V], eviction bool) { +func (b *bucket[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], eviction bool) { if b.capacity == 0 { return insertion, true } @@ -77,7 +81,7 @@ func (b *bucket[K, V]) evict(insertion entry[K, V]) (evicted entry[K, V], evicti slot := &b.slots[b.location(insertion.key)] if !slot.occupied { - slot.entry = insertion + slot.Entry = insertion slot.occupied = true b.size++ return @@ -88,7 +92,7 @@ func (b *bucket[K, V]) evict(insertion entry[K, V]) (evicted entry[K, V], evicti return } - insertion, slot.entry = slot.entry, insertion + insertion, slot.Entry = slot.Entry, insertion return insertion, true } diff --git a/cuckoo_fuzz_test.go b/cuckoo_fuzz_test.go index f849de0..8d30423 100644 --- a/cuckoo_fuzz_test.go +++ b/cuckoo_fuzz_test.go @@ -76,7 +76,7 @@ func FuzzInsertLookup(f *testing.F) { _, err = actual.Get(step.key) assert.Error(err) } else { - err := actual.Put(step.key, step.value) + _, err := actual.Put(step.key, step.value) assert.NoError(err) expected[step.key] = step.value diff --git a/cuckoo_internal_test.go b/cuckoo_internal_test.go index f2b24a3..ec07056 100644 --- a/cuckoo_internal_test.go +++ b/cuckoo_internal_test.go @@ -23,7 +23,7 @@ func TestLoad(t *testing.T) { table := NewTable[int, bool](Capacity(8)) for i := range 16 { - err := table.Put(i, true) + _, err := table.Put(i, true) assert.NoError(err) assert.Equal(float64(table.Size())/float64(table.TotalCapacity()), table.load()) } diff --git a/cuckoo_test.go b/cuckoo_test.go index 131194a..eac1734 100644 --- a/cuckoo_test.go +++ b/cuckoo_test.go @@ -25,7 +25,7 @@ func TestAddItem(t *testing.T) { key, value := 0, true table := cuckoo.NewTable[int, bool]() - err := table.Put(key, value) + _, err := table.Put(key, value) assert.NoError(err) assert.Equal(1, table.Size()) @@ -38,7 +38,7 @@ func TestPutOverwrite(t *testing.T) { table := cuckoo.NewTable[int, int]() (table.Put(key, value)) - err := table.Put(key, newValue) + _, err := table.Put(key, newValue) assert.NoError(err) assert.Equal(1, table.Size()) @@ -52,9 +52,9 @@ func TestSameHash(t *testing.T) { hash := func(int) uint64 { return 0 } table := cuckoo.NewCustomTable[int, bool](hash, hash, cuckoo.DefaultEqualFunc[int]) - errA := table.Put(0, true) - errB := table.Put(1, true) - errC := table.Put(2, true) + _, errA := table.Put(0, true) + _, errB := table.Put(1, true) + _, errC := table.Put(2, true) assert.NoError(errA) assert.NoError(errB) @@ -76,7 +76,7 @@ func TestResizeCapacity(t *testing.T) { ) for table.TotalCapacity() == 16 { - err := table.Put(rand.Int(), true) + _, err := table.Put(rand.Int(), true) assert.NoError(err) } @@ -89,7 +89,7 @@ func TestPutMany(t *testing.T) { for i := range 1_000 { expected[i] = true - err := actual.Put(i, true) + _, err := actual.Put(i, true) assert.NoError(err) } @@ -103,7 +103,7 @@ func TestGetMany(t *testing.T) { table := cuckoo.NewTable[int, bool]() for i := range 1_000 { - err := table.Put(i, true) + _, err := table.Put(i, true) assert.NoError(err) } @@ -168,7 +168,7 @@ func TestPutNoCapacity(t *testing.T) { cuckoo.Capacity(0), ) - err := table.Put(key, value) + _, err := table.Put(key, value) assert.NoError(err) assert.Equal(1, table.Size()) @@ -184,9 +184,9 @@ func TestBadHashCapacity(t *testing.T) { cuckoo.Capacity(20), ) - err1 := table.Put(0, true) - err2 := table.Put(1, true) - err3 := table.Put(2, true) + _, err1 := table.Put(0, true) + _, err2 := table.Put(1, true) + _, err3 := table.Put(2, true) assert.NoError(err1) assert.NoError(err2) @@ -201,8 +201,8 @@ func TestDropResizeCapacity(t *testing.T) { cuckoo.Capacity(10), ) - err1 := table.Put(0, true) - err2 := table.Put(1, true) + _, err1 := table.Put(0, true) + _, err2 := table.Put(1, true) err3 := table.Drop(1) assert.NoError(errors.Join(err1, err2, err3)) @@ -221,7 +221,7 @@ func TestNewTableBy(t *testing.T) { func(u User) string { return u.id }, ) - err := table.Put(User{nil, "1", "Robert"}, true) + _, err := table.Put(User{nil, "1", "Robert"}, true) assert.NoError(err) assert.Equal(1, table.Size()) diff --git a/doc_example_test.go b/doc_example_test.go index 1fad4ff..86f8e0c 100644 --- a/doc_example_test.go +++ b/doc_example_test.go @@ -10,7 +10,7 @@ import ( func Example_basic() { table := cuckoo.NewTable[int, string]() - if err := table.Put(1, "Hello, World!"); err != nil { + if _, err := table.Put(1, "Hello, World!"); err != nil { fmt.Println("Put error:", err) } diff --git a/settings.go b/settings.go index fe0408f..425d6ba 100644 --- a/settings.go +++ b/settings.go @@ -19,6 +19,11 @@ const DefaultGrowthFactor uint64 = 2 // [libcuckoo]: https://github.com/efficient/libcuckoo/blob/656714705a055df2b7a605eb3c71586d9da1e119/libcuckoo/cuckoohash_config.hh#L21 const defaultMinimumLoad float64 = 0.05 +// defaultGrowthLimit is the maximum number of times a [Table] can grow in a +// single [Table.Put], before the library infers it will lead to a stack +// overflow. The value of '64' was chosen arbirarily. +const defaultGrowthLimit uint64 = 64 + type settings struct { growthFactor uint64 minLoadFactor float64 diff --git a/table.go b/table.go index 922aa81..e2b35ac 100644 --- a/table.go +++ b/table.go @@ -45,30 +45,61 @@ func (t Table[K, V]) load() float64 { return float64(t.Size()) / float64(t.TotalCapacity()) } -// resize clears all buckets, changes the sizes of them to a specific capacity, -// and fills them back up again. It is a helper function for [Table.grow] and -// [Table.shrink]; use them instead. -func (t *Table[K, V]) resize(capacity uint64) error { - entries := make([]entry[K, V], 0, t.Size()) - for k, v := range t.Entries() { - entries = append(entries, entry[K, V]{k, v}) +// insert attempts to put/update an entry in the table, without modifying the +// size of the table. Returns a displaced entry and 'homeless = true' if an +// entry could not be placed after exhausting evictions. +func (t *Table[K, V]) insert(entry Entry[K, V]) (displaced Entry[K, V], homeless bool) { + if t.bucketA.update(entry.key, entry.value) { + return } - t.bucketA.resize(capacity) - t.bucketB.resize(capacity) + if t.bucketB.update(entry.key, entry.value) { + return + } - for _, entry := range entries { - if err := t.Put(entry.key, entry.value); err != nil { - return err + for range t.maxEvictions() { + if entry, homeless = t.bucketA.insert(entry); !homeless { + return + } + + if entry, homeless = t.bucketB.insert(entry); !homeless { + return } } - return nil + return entry, true +} + +// resized creates an empty copy of the table, with a new capacity for each +// bucket. +func (t Table[K, V]) resized(capacity uint64) Table[K, V] { + return Table[K, V]{ + growthFactor: t.growthFactor, + minLoadFactor: t.minLoadFactor, + bucketA: t.bucketA.resized(capacity), + bucketB: t.bucketB.resized(capacity), + } +} + +// resize creates a new [Table.resized] with 'capacity', inserts all items into +// the array, and replaces the current table. It is a helper function for +// [Table.grow] and [Table.shrink]; use them instead. +func (t *Table[K, V]) resize(capacity uint64) bool { + updated := t.resized(capacity) + + for k, v := range t.Entries() { + if _, failed := updated.insert(Entry[K, V]{k, v}); failed { + return false + } + } + + *t = updated + return true } // grow increases the table's capacity by the [Table.growthFactor]. If the // capacity is 0, it increases it to 1. -func (t *Table[K, V]) grow() error { +func (t *Table[K, V]) grow() bool { var newCapacity uint64 if t.TotalCapacity() == 0 { @@ -82,7 +113,7 @@ func (t *Table[K, V]) grow() error { // shrink reduces the table's capacity by the [Table.growthFactor]. It may // reduce it down to 0. -func (t *Table[K, V]) shrink() error { +func (t *Table[K, V]) shrink() bool { return t.resize(t.bucketA.capacity / t.growthFactor) } @@ -106,36 +137,38 @@ func (t Table[K, V]) Has(key K) (exists bool) { return err == nil } -// Put sets the value for a key. Returns error if its value cannot be set. -func (t *Table[K, V]) Put(key K, value V) (err error) { - if t.bucketA.update(key, value) { - return nil - } +// Put sets the value for a key. If it cannot be set, an error is returned, +// along with the last displaced entry. +// +// On failure, the returned entry and the current table contents together +// preserve all previously inserted entries and the attempted entry. +func (t *Table[K, V]) Put(key K, value V) (displaced Entry[K, V], err error) { + var ( + entry = Entry[K, V]{key, value} + homeless bool + ) - if t.bucketB.update(key, value) { - return nil - } - - entry, eviction := entry[K, V]{key, value}, false - for range t.maxEvictions() { - if entry, eviction = t.bucketA.evict(entry); !eviction { - return nil + for range defaultGrowthLimit { + if entry, homeless = t.insert(entry); !homeless { + return } - if entry, eviction = t.bucketB.evict(entry); !eviction { - return nil + // Both this and the growth limit are necessary: this catches bad hashes + // early when the table is sparse, while the latter catches cases where + // growing never helps. + if t.load() < t.minLoadFactor { + return entry, fmt.Errorf("bad hash: resize on load %d/%d", t.Size(), t.TotalCapacity()) + } + + // It is theoretically possible to have a table with a larger capacity + // that is valid. But this chance is astronomically small, so we ignore + // it in this implementation. + if grew := t.grow(); !grew { + return entry, fmt.Errorf("bad hash: could not redistribute entries into larger table") } } - if t.load() < t.minLoadFactor { - return fmt.Errorf("bad hash: resize on load %d/%d = %f", t.Size(), t.TotalCapacity(), t.load()) - } - - if err := t.grow(); err != nil { - return err - } - - return t.Put(entry.key, entry.value) + return entry, fmt.Errorf("bad hash: could not place entry after %d resizes", defaultGrowthLimit) } // Drop removes a value for a key in the table. Returns an error if its value @@ -145,7 +178,9 @@ func (t *Table[K, V]) Drop(key K) (err error) { t.bucketB.drop(key) if t.load() < t.minLoadFactor { - return t.shrink() + // The error is not handled here, because table-shrinking is an internal + // optimization. + t.shrink() } return nil -- 2.49.1 From afead3330a48572576d951c73b4c00330cad79ad Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Sat, 4 Apr 2026 00:20:34 +0200 Subject: [PATCH 02/11] feat: drop item returns bool, whether item existed --- cuckoo_fuzz_test.go | 7 ++++--- cuckoo_test.go | 15 +++++++-------- table.go | 11 +++++------ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cuckoo_fuzz_test.go b/cuckoo_fuzz_test.go index 8d30423..51aa112 100644 --- a/cuckoo_fuzz_test.go +++ b/cuckoo_fuzz_test.go @@ -68,12 +68,13 @@ func FuzzInsertLookup(f *testing.F) { for _, step := range scenario.steps { if step.drop { - err := actual.Drop(step.key) - assert.NoError(err) + ok := actual.Drop(step.key) + _, has := expected[step.key] + assert.Equal(ok, has) delete(expected, step.key) - _, err = actual.Get(step.key) + _, err := actual.Get(step.key) assert.Error(err) } else { _, err := actual.Put(step.key, step.value) diff --git a/cuckoo_test.go b/cuckoo_test.go index eac1734..738e501 100644 --- a/cuckoo_test.go +++ b/cuckoo_test.go @@ -124,9 +124,9 @@ func TestDropExistingItem(t *testing.T) { table := cuckoo.NewTable[int, bool]() (table.Put(key, value)) - err := table.Drop(key) + had := table.Drop(key) - assert.NoError(err) + assert.True(had) assert.Equal(0, table.Size()) assert.False(table.Has(key)) } @@ -136,9 +136,9 @@ func TestDropNoItem(t *testing.T) { key := 0 table := cuckoo.NewTable[int, bool]() - err := table.Drop(key) + had := table.Drop(key) - assert.NoError(err) + assert.False(had) assert.Equal(0, table.Size()) assert.False(table.Has(key)) } @@ -152,10 +152,9 @@ func TestDropItemCapacity(t *testing.T) { ) startingCapacity := table.TotalCapacity() - err := table.Drop(key) + table.Drop(key) endingCapacity := table.TotalCapacity() - assert.NoError(err) assert.Equal(0, table.Size()) assert.Equal(uint64(128), startingCapacity) assert.Equal(uint64(64), endingCapacity) @@ -203,9 +202,9 @@ func TestDropResizeCapacity(t *testing.T) { _, err1 := table.Put(0, true) _, err2 := table.Put(1, true) - err3 := table.Drop(1) + table.Drop(1) - assert.NoError(errors.Join(err1, err2, err3)) + assert.NoError(errors.Join(err1, err2)) assert.Equal(uint64(20), table.TotalCapacity()) } diff --git a/table.go b/table.go index e2b35ac..5aa0823 100644 --- a/table.go +++ b/table.go @@ -171,11 +171,10 @@ func (t *Table[K, V]) Put(key K, value V) (displaced Entry[K, V], err error) { return entry, fmt.Errorf("bad hash: could not place entry after %d resizes", defaultGrowthLimit) } -// Drop removes a value for a key in the table. Returns an error if its value -// cannot be removed. -func (t *Table[K, V]) Drop(key K) (err error) { - t.bucketA.drop(key) - t.bucketB.drop(key) +// Drop removes a value for a key in the table. Returns whether the key had +// existed. +func (t *Table[K, V]) Drop(key K) bool { + occupied := t.bucketA.drop(key) || t.bucketB.drop(key) if t.load() < t.minLoadFactor { // The error is not handled here, because table-shrinking is an internal @@ -183,7 +182,7 @@ func (t *Table[K, V]) Drop(key K) (err error) { t.shrink() } - return nil + return occupied } // Entries returns an unordered sequence of all key-value pairs in the table. -- 2.49.1 From ca66ccd04054443f60a61bab0bb4a8aa3937c23d Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Sat, 4 Apr 2026 00:38:27 +0200 Subject: [PATCH 03/11] fix: public facing key/value fields in entry --- bucket.go | 18 +++++++++--------- table.go | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bucket.go b/bucket.go index 8010f3e..3a3b1da 100644 --- a/bucket.go +++ b/bucket.go @@ -2,8 +2,8 @@ package cuckoo // An Entry is a key-value pair. type Entry[K, V any] struct { - key K - value V + Key K + Value V } type slot[K, V any] struct { @@ -30,7 +30,7 @@ func (b bucket[K, V]) get(key K) (value V, found bool) { } slot := b.slots[b.location(key)] - return slot.value, slot.occupied && b.compare(slot.key, key) + return slot.Value, slot.occupied && b.compare(slot.Key, key) } func (b *bucket[K, V]) drop(key K) (occupied bool) { @@ -40,7 +40,7 @@ func (b *bucket[K, V]) drop(key K) (occupied bool) { slot := &b.slots[b.location(key)] - if slot.occupied && b.compare(slot.key, key) { + if slot.occupied && b.compare(slot.Key, key) { slot.occupied = false b.size-- return true @@ -65,8 +65,8 @@ func (b bucket[K, V]) update(key K, value V) (updated bool) { slot := &b.slots[b.location(key)] - if slot.occupied && b.compare(slot.key, key) { - slot.value = value + if slot.occupied && b.compare(slot.Key, key) { + slot.Value = value return true } @@ -78,7 +78,7 @@ func (b *bucket[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], evict return insertion, true } - slot := &b.slots[b.location(insertion.key)] + slot := &b.slots[b.location(insertion.Key)] if !slot.occupied { slot.Entry = insertion @@ -87,8 +87,8 @@ func (b *bucket[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], evict return } - if b.compare(slot.key, insertion.key) { - slot.value = insertion.value + if b.compare(slot.Key, insertion.Key) { + slot.Value = insertion.Value return } diff --git a/table.go b/table.go index 5aa0823..fbfde2b 100644 --- a/table.go +++ b/table.go @@ -49,11 +49,11 @@ func (t Table[K, V]) load() float64 { // size of the table. Returns a displaced entry and 'homeless = true' if an // entry could not be placed after exhausting evictions. func (t *Table[K, V]) insert(entry Entry[K, V]) (displaced Entry[K, V], homeless bool) { - if t.bucketA.update(entry.key, entry.value) { + if t.bucketA.update(entry.Key, entry.Value) { return } - if t.bucketB.update(entry.key, entry.value) { + if t.bucketB.update(entry.Key, entry.Value) { return } @@ -190,7 +190,7 @@ func (t Table[K, V]) Entries() iter.Seq2[K, V] { return func(yield func(K, V) bool) { for _, slot := range t.bucketA.slots { if slot.occupied { - if !yield(slot.key, slot.value) { + if !yield(slot.Key, slot.Value) { return } } @@ -198,7 +198,7 @@ func (t Table[K, V]) Entries() iter.Seq2[K, V] { for _, slot := range t.bucketB.slots { if slot.occupied { - if !yield(slot.key, slot.value) { + if !yield(slot.Key, slot.Value) { return } } -- 2.49.1 From 78f7d01d5f41f5ba9ba6e9da6a99b7c4a475357a Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 00:00:37 -0400 Subject: [PATCH 04/11] revert: forgot about b -> t --- subtable.go | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/subtable.go b/subtable.go index f502bcf..53a9785 100644 --- a/subtable.go +++ b/subtable.go @@ -20,52 +20,52 @@ type subtable[K, V any] struct { // location determines where in the bucket a certain key would be placed. If the // capacity is 0, this will panic. -func (b *subtable[K, V]) location(key K) uint64 { - return b.hash(key) % b.capacity +func (t *subtable[K, V]) location(key K) uint64 { + return t.hash(key) % t.capacity } -func (b *subtable[K, V]) get(key K) (value V, found bool) { - if b.capacity == 0 { +func (t *subtable[K, V]) get(key K) (value V, found bool) { + if t.capacity == 0 { return } - slot := b.slots[b.location(key)] - return slot.Value, slot.occupied && b.compare(slot.Key, key) + slot := t.slots[t.location(key)] + return slot.Value, slot.occupied && t.compare(slot.Key, key) } -func (b *subtable[K, V]) drop(key K) (occupied bool) { - if b.capacity == 0 { +func (t *subtable[K, V]) drop(key K) (occupied bool) { + if t.capacity == 0 { return } - slot := &b.slots[b.location(key)] + slot := &t.slots[t.location(key)] - if slot.occupied && b.compare(slot.Key, key) { + if slot.occupied && t.compare(slot.Key, key) { slot.occupied = false - b.size-- + t.size-- return true } return false } -func (b *subtable[K, V]) resized(capacity uint64) *subtable[K, V] { +func (t *subtable[K, V]) resized(capacity uint64) *subtable[K, V] { return &subtable[K, V]{ slots: make([]slot[K, V], capacity), capacity: capacity, - hash: b.hash, - compare: b.compare, + hash: t.hash, + compare: t.compare, } } -func (b *subtable[K, V]) update(key K, value V) (updated bool) { - if b.capacity == 0 { +func (t *subtable[K, V]) update(key K, value V) (updated bool) { + if t.capacity == 0 { return } - slot := &b.slots[b.location(key)] + slot := &t.slots[t.location(key)] - if slot.occupied && b.compare(slot.Key, key) { + if slot.occupied && t.compare(slot.Key, key) { slot.Value = value return true } @@ -73,21 +73,21 @@ func (b *subtable[K, V]) update(key K, value V) (updated bool) { return false } -func (b *subtable[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], eviction bool) { - if b.capacity == 0 { +func (t *subtable[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], eviction bool) { + if t.capacity == 0 { return insertion, true } - slot := &b.slots[b.location(insertion.Key)] + slot := &t.slots[t.location(insertion.Key)] if !slot.occupied { slot.Entry = insertion slot.occupied = true - b.size++ + t.size++ return } - if b.compare(slot.Key, insertion.Key) { + if t.compare(slot.Key, insertion.Key) { slot.Value = insertion.Value return } -- 2.49.1 From fce632545451cb4c8de26ddd14b40b45d68035e9 Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 00:01:17 -0400 Subject: [PATCH 05/11] docs: naming of subtable.location was outdated --- subtable.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subtable.go b/subtable.go index 53a9785..7ed86d9 100644 --- a/subtable.go +++ b/subtable.go @@ -18,8 +18,8 @@ type subtable[K, V any] struct { compare EqualFunc[K] } -// location determines where in the bucket a certain key would be placed. If the -// capacity is 0, this will panic. +// location determines where in the subtable a certain key would be placed. If +// the capacity is 0, this will panic. func (t *subtable[K, V]) location(key K) uint64 { return t.hash(key) % t.capacity } -- 2.49.1 From 24b646c5dc2818212e11af76b06e2f06a275352f Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 00:06:09 -0400 Subject: [PATCH 06/11] feat: use bad hash sentinel error --- table.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/table.go b/table.go index 322e950..c869814 100644 --- a/table.go +++ b/table.go @@ -173,18 +173,18 @@ func (t *Table[K, V]) Put(key K, value V) (displaced Entry[K, V], err error) { // early when the table is sparse, while the latter catches cases where // growing never helps. if t.load() < t.minLoadFactor { - return entry, fmt.Errorf("bad hash: resize on load %d/%d", t.Size(), t.TotalCapacity()) + return entry, fmt.Errorf("hash functions produced a cycle at load %d/%d: %w", t.Size(), t.TotalCapacity(), ErrBadHash) } // It is theoretically possible to have a table with a larger capacity // that is valid. But this chance is astronomically small, so we ignore // it in this implementation. if grew := t.grow(); !grew { - return entry, fmt.Errorf("bad hash: could not redistribute entries into larger table") + return entry, fmt.Errorf("could not redistribute entries into larger table: %w", ErrBadHash) } } - return entry, fmt.Errorf("bad hash: could not place entry after %d resizes", defaultGrowthLimit) + return entry, fmt.Errorf("could not place entry after %d resizes: %w", defaultGrowthLimit, ErrBadHash) } // Drop removes a value for a key in the table. Returns whether the key had -- 2.49.1 From 5452c02d4e33aedb9020ab706d39bd441f7c3598 Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 20:33:50 -0400 Subject: [PATCH 07/11] fix: assertion in fuzz test incorrect --- cuckoo_fuzz_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cuckoo_fuzz_test.go b/cuckoo_fuzz_test.go index 7c6af28..f96bc6e 100644 --- a/cuckoo_fuzz_test.go +++ b/cuckoo_fuzz_test.go @@ -75,7 +75,7 @@ func FuzzInsertLookup(f *testing.F) { delete(expected, step.key) _, ok = actual.Get(step.key) - assert.True(ok) + assert.False(ok) } else { _, err := actual.Put(step.key, step.value) assert.NoError(err) -- 2.49.1 From d13adcaa4937a4ba083b4eee5926960b7e385aab Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 21:05:43 -0400 Subject: [PATCH 08/11] docs: note that the table isnt a source of truth --- doc.go | 3 +++ table.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc.go b/doc.go index 3be8bfc..2d1f62f 100644 --- a/doc.go +++ b/doc.go @@ -5,5 +5,8 @@ // a table with any key type using [NewCustom]. Custom [Hash] functions and // key comparison are also supported. // +// NOTE: The [Table] is a look-up structure, and not a source of truth. If +// [ErrBadHash] occurs, the data cannot be restored. +// // See more: https://en.wikipedia.org/wiki/Cuckoo_hashing package cuckoo diff --git a/table.go b/table.go index c869814..105d36e 100644 --- a/table.go +++ b/table.go @@ -9,7 +9,7 @@ import ( ) // ErrBadHash occurs when the hashes given to a [Table] cause too many key -// collisions. Try rebuilding the table using: +// collisions. Discard the old table, rebuild it from your source data, and try: // // 1. Different hash seeds. Equal seeds produce equal hash functions, which // always cycle. -- 2.49.1 From 24df23218c8a550aeda54612e19cb42a60374724 Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 21:11:22 -0400 Subject: [PATCH 09/11] revert!: old put contract --- table.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/table.go b/table.go index 105d36e..1f16fdc 100644 --- a/table.go +++ b/table.go @@ -153,12 +153,8 @@ func (t *Table[K, V]) Has(key K) (exists bool) { return } -// Put sets the value for a key. If it cannot be set, an error is returned, -// along with the last displaced entry. -// -// On failure, the returned entry and the current table contents together -// preserve all previously inserted entries and the attempted entry. -func (t *Table[K, V]) Put(key K, value V) (displaced Entry[K, V], err error) { +// Put sets the value for a key. If it cannot be set, an error is returned. +func (t *Table[K, V]) Put(key K, value V) (err error) { var ( entry = Entry[K, V]{key, value} homeless bool @@ -173,18 +169,18 @@ func (t *Table[K, V]) Put(key K, value V) (displaced Entry[K, V], err error) { // early when the table is sparse, while the latter catches cases where // growing never helps. if t.load() < t.minLoadFactor { - return entry, fmt.Errorf("hash functions produced a cycle at load %d/%d: %w", t.Size(), t.TotalCapacity(), ErrBadHash) + return fmt.Errorf("hash functions produced a cycle at load %d/%d: %w", t.Size(), t.TotalCapacity(), ErrBadHash) } // It is theoretically possible to have a table with a larger capacity // that is valid. But this chance is astronomically small, so we ignore // it in this implementation. if grew := t.grow(); !grew { - return entry, fmt.Errorf("could not redistribute entries into larger table: %w", ErrBadHash) + return fmt.Errorf("could not redistribute entries into larger table: %w", ErrBadHash) } } - return entry, fmt.Errorf("could not place entry after %d resizes: %w", defaultGrowthLimit, ErrBadHash) + return fmt.Errorf("could not place entry after %d resizes: %w", defaultGrowthLimit, ErrBadHash) } // Drop removes a value for a key in the table. Returns whether the key had -- 2.49.1 From b395d6e1f47d01136fdbf4befc0d9183e6a9e8c0 Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 21:12:39 -0400 Subject: [PATCH 10/11] fix: tests were out of date --- cuckoo_fuzz_test.go | 2 +- cuckoo_internal_test.go | 2 +- cuckoo_test.go | 30 +++++++++++++++--------------- doc_example_test.go | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cuckoo_fuzz_test.go b/cuckoo_fuzz_test.go index f96bc6e..a6a5672 100644 --- a/cuckoo_fuzz_test.go +++ b/cuckoo_fuzz_test.go @@ -77,7 +77,7 @@ func FuzzInsertLookup(f *testing.F) { _, ok = actual.Get(step.key) assert.False(ok) } else { - _, err := actual.Put(step.key, step.value) + err := actual.Put(step.key, step.value) assert.NoError(err) expected[step.key] = step.value diff --git a/cuckoo_internal_test.go b/cuckoo_internal_test.go index d34852a..461f4d7 100644 --- a/cuckoo_internal_test.go +++ b/cuckoo_internal_test.go @@ -23,7 +23,7 @@ func TestLoad(t *testing.T) { table := New[int, bool](Capacity(8)) for i := range 16 { - _, err := table.Put(i, true) + err := table.Put(i, true) assert.NoError(err) assert.Equal(float64(table.Size())/float64(table.TotalCapacity()), table.load()) } diff --git a/cuckoo_test.go b/cuckoo_test.go index d0d1ae3..08394ad 100644 --- a/cuckoo_test.go +++ b/cuckoo_test.go @@ -25,7 +25,7 @@ func TestAddItem(t *testing.T) { key, value := 0, true table := cuckoo.New[int, bool]() - _, err := table.Put(key, value) + err := table.Put(key, value) assert.NoError(err) assert.Equal(1, table.Size()) @@ -38,7 +38,7 @@ func TestPutOverwrite(t *testing.T) { table := cuckoo.New[int, int]() (table.Put(key, value)) - _, err := table.Put(key, newValue) + err := table.Put(key, newValue) assert.NoError(err) assert.Equal(1, table.Size()) @@ -52,9 +52,9 @@ func TestSameHash(t *testing.T) { hash := func(int) uint64 { return 0 } table := cuckoo.NewCustom[int, bool](hash, hash, cuckoo.DefaultEqualFunc[int]) - _, errA := table.Put(0, true) - _, errB := table.Put(1, true) - _, errC := table.Put(2, true) + errA := table.Put(0, true) + errB := table.Put(1, true) + errC := table.Put(2, true) assert.NoError(errA) assert.NoError(errB) @@ -76,7 +76,7 @@ func TestResizeCapacity(t *testing.T) { ) for table.TotalCapacity() == 16 { - _, err := table.Put(rand.Int(), true) + err := table.Put(rand.Int(), true) assert.NoError(err) } @@ -89,7 +89,7 @@ func TestPutMany(t *testing.T) { for i := range 1_000 { expected[i] = true - _, err := actual.Put(i, true) + err := actual.Put(i, true) assert.NoError(err) } @@ -103,7 +103,7 @@ func TestGetMany(t *testing.T) { table := cuckoo.New[int, bool]() for i := range 1_000 { - _, err := table.Put(i, true) + err := table.Put(i, true) assert.NoError(err) } @@ -167,7 +167,7 @@ func TestPutNoCapacity(t *testing.T) { cuckoo.Capacity(0), ) - _, err := table.Put(key, value) + err := table.Put(key, value) assert.NoError(err) assert.Equal(1, table.Size()) @@ -183,9 +183,9 @@ func TestBadHashCapacity(t *testing.T) { cuckoo.Capacity(20), ) - _, err1 := table.Put(0, true) - _, err2 := table.Put(1, true) - _, err3 := table.Put(2, true) + err1 := table.Put(0, true) + err2 := table.Put(1, true) + err3 := table.Put(2, true) assert.NoError(err1) assert.NoError(err2) @@ -200,8 +200,8 @@ func TestDropResizeCapacity(t *testing.T) { cuckoo.Capacity(10), ) - _, err1 := table.Put(0, true) - _, err2 := table.Put(1, true) + err1 := table.Put(0, true) + err2 := table.Put(1, true) table.Drop(1) assert.NoError(errors.Join(err1, err2)) @@ -218,7 +218,7 @@ func TestNewTableBy(t *testing.T) { assert := assert.New(t) table := cuckoo.NewBy[User, bool](func(u User) string { return u.id }) - _, err := table.Put(User{nil, "1", "Robert"}, true) + err := table.Put(User{nil, "1", "Robert"}, true) assert.NoError(err) assert.Equal(1, table.Size()) diff --git a/doc_example_test.go b/doc_example_test.go index 63ea8f1..3671857 100644 --- a/doc_example_test.go +++ b/doc_example_test.go @@ -10,7 +10,7 @@ import ( func Example_basic() { table := cuckoo.New[int, string]() - if _, err := table.Put(1, "Hello, World!"); err != nil { + if err := table.Put(1, "Hello, World!"); err != nil { fmt.Println("Put error:", err) } -- 2.49.1 From 7b45099cea72786699319b8253dbaceeea44bbad Mon Sep 17 00:00:00 2001 From: "M.V. Hutz" Date: Thu, 16 Apr 2026 21:15:11 -0400 Subject: [PATCH 11/11] revert: Entry -> entry bc Put() doesnt use it --- subtable.go | 30 +++++++++++++++--------------- table.go | 14 +++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/subtable.go b/subtable.go index 7ed86d9..e6d591a 100644 --- a/subtable.go +++ b/subtable.go @@ -1,13 +1,13 @@ package cuckoo -// An Entry is a key-value pair. -type Entry[K, V any] struct { - Key K - Value V +// An entry is a key-value pair. +type entry[K, V any] struct { + key K + value V } type slot[K, V any] struct { - Entry[K, V] + entry[K, V] occupied bool } @@ -30,7 +30,7 @@ func (t *subtable[K, V]) get(key K) (value V, found bool) { } slot := t.slots[t.location(key)] - return slot.Value, slot.occupied && t.compare(slot.Key, key) + return slot.value, slot.occupied && t.compare(slot.key, key) } func (t *subtable[K, V]) drop(key K) (occupied bool) { @@ -40,7 +40,7 @@ func (t *subtable[K, V]) drop(key K) (occupied bool) { slot := &t.slots[t.location(key)] - if slot.occupied && t.compare(slot.Key, key) { + if slot.occupied && t.compare(slot.key, key) { slot.occupied = false t.size-- return true @@ -65,34 +65,34 @@ func (t *subtable[K, V]) update(key K, value V) (updated bool) { slot := &t.slots[t.location(key)] - if slot.occupied && t.compare(slot.Key, key) { - slot.Value = value + if slot.occupied && t.compare(slot.key, key) { + slot.value = value return true } return false } -func (t *subtable[K, V]) insert(insertion Entry[K, V]) (evicted Entry[K, V], eviction bool) { +func (t *subtable[K, V]) insert(insertion entry[K, V]) (evicted entry[K, V], eviction bool) { if t.capacity == 0 { return insertion, true } - slot := &t.slots[t.location(insertion.Key)] + slot := &t.slots[t.location(insertion.key)] if !slot.occupied { - slot.Entry = insertion + slot.entry = insertion slot.occupied = true t.size++ return } - if t.compare(slot.Key, insertion.Key) { - slot.Value = insertion.Value + if t.compare(slot.key, insertion.key) { + slot.value = insertion.value return } - insertion, slot.Entry = slot.Entry, insertion + insertion, slot.entry = slot.entry, insertion return insertion, true } diff --git a/table.go b/table.go index 1f16fdc..b0c6d78 100644 --- a/table.go +++ b/table.go @@ -57,12 +57,12 @@ func (t *Table[K, V]) load() float64 { // insert attempts to put/update an entry in the table, without modifying the // size of the table. Returns a displaced entry and 'homeless = true' if an // entry could not be placed after exhausting evictions. -func (t *Table[K, V]) insert(entry Entry[K, V]) (displaced Entry[K, V], homeless bool) { - if t.tableA.update(entry.Key, entry.Value) { +func (t *Table[K, V]) insert(entry entry[K, V]) (displaced entry[K, V], homeless bool) { + if t.tableA.update(entry.key, entry.value) { return } - if t.tableB.update(entry.Key, entry.Value) { + if t.tableB.update(entry.key, entry.value) { return } @@ -97,7 +97,7 @@ func (t *Table[K, V]) resize(capacity uint64) bool { updated := t.resized(capacity) for k, v := range t.Entries() { - if _, failed := updated.insert(Entry[K, V]{k, v}); failed { + if _, failed := updated.insert(entry[K, V]{k, v}); failed { return false } } @@ -156,7 +156,7 @@ func (t *Table[K, V]) Has(key K) (exists bool) { // Put sets the value for a key. If it cannot be set, an error is returned. func (t *Table[K, V]) Put(key K, value V) (err error) { var ( - entry = Entry[K, V]{key, value} + entry = entry[K, V]{key, value} homeless bool ) @@ -202,7 +202,7 @@ func (t *Table[K, V]) Entries() iter.Seq2[K, V] { return func(yield func(K, V) bool) { for _, slot := range t.tableA.slots { if slot.occupied { - if !yield(slot.Key, slot.Value) { + if !yield(slot.key, slot.value) { return } } @@ -210,7 +210,7 @@ func (t *Table[K, V]) Entries() iter.Seq2[K, V] { for _, slot := range t.tableB.slots { if slot.occupied { - if !yield(slot.Key, slot.Value) { + if !yield(slot.key, slot.value) { return } } -- 2.49.1