chore: sync with node requirements, add bytecache package

This commit is contained in:
2026-01-12 14:44:47 -08:00
parent 8c43f5b82c
commit d7ae35c977
18 changed files with 1295 additions and 8 deletions
+32
View File
@@ -0,0 +1,32 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
var (
require = require.New(t)
count = 10000
p = 0.1
)
numHashes, numEntries := OptimalParameters(count, p)
f, err := New(numHashes, numEntries)
require.NoError(err)
require.NotNil(f)
salt := []byte("test salt")
Add(f, []byte("hello"), salt)
contains := Contains(f, []byte("hello"), salt)
require.True(contains, "should have contained the key")
contains = Contains(f, []byte("bye"), salt)
require.False(contains, "shouldn't have contained the key")
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
// BloomFilter is the interface for bloom filter implementations that support
// adding and checking raw byte slices
type BloomFilter interface {
// Add adds to filter, assumed thread safe
Add(...[]byte)
// Check checks filter, assumed thread safe
Check([]byte) bool
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"math/bits"
"sync"
)
const (
minHashes = 1
maxHashes = 16 // Supports a false positive probability of 2^-16 when using optimal size values
minEntries = 1
bitsPerByte = 8
bytesPerUint64 = 8
hashRotation = 17
)
var (
errInvalidNumHashes = errors.New("invalid num hashes")
errTooFewHashes = errors.New("too few hashes")
errTooManyHashes = errors.New("too many hashes")
errTooFewEntries = errors.New("too few entries")
)
type Filter struct {
// numBits is always equal to [bitsPerByte * len(entries)]
numBits uint64
lock sync.RWMutex
hashSeeds []uint64
entries []byte
count int
}
// New creates a new Filter with the specified number of hashes and bytes for
// entries. The returned bloom filter is safe for concurrent usage.
func New(numHashes, numEntries int) (*Filter, error) {
if numEntries < minEntries {
return nil, errTooFewEntries
}
hashSeeds, err := newHashSeeds(numHashes)
if err != nil {
return nil, err
}
return &Filter{
numBits: uint64(numEntries * bitsPerByte),
hashSeeds: hashSeeds,
entries: make([]byte, numEntries),
count: 0,
}, nil
}
func (f *Filter) Add(hash uint64) {
f.lock.Lock()
defer f.lock.Unlock()
_ = 1 % f.numBits // hint to the compiler that numBits is not 0
for _, seed := range f.hashSeeds {
hash = bits.RotateLeft64(hash, hashRotation) ^ seed
index := hash % f.numBits
byteIndex := index / bitsPerByte
bitIndex := index % bitsPerByte
f.entries[byteIndex] |= 1 << bitIndex
}
f.count++
}
// Count returns the number of elements that have been added to the bloom
// filter.
func (f *Filter) Count() int {
f.lock.RLock()
defer f.lock.RUnlock()
return f.count
}
func (f *Filter) Contains(hash uint64) bool {
f.lock.RLock()
defer f.lock.RUnlock()
return contains(f.hashSeeds, f.entries, hash)
}
func (f *Filter) Marshal() []byte {
f.lock.RLock()
defer f.lock.RUnlock()
return marshal(f.hashSeeds, f.entries)
}
func newHashSeeds(count int) ([]uint64, error) {
switch {
case count < minHashes:
return nil, fmt.Errorf("%w: %d < %d", errTooFewHashes, count, minHashes)
case count > maxHashes:
return nil, fmt.Errorf("%w: %d > %d", errTooManyHashes, count, maxHashes)
}
bytes := make([]byte, count*bytesPerUint64)
if _, err := rand.Reader.Read(bytes); err != nil {
return nil, err
}
seeds := make([]uint64, count)
for i := range seeds {
seeds[i] = binary.BigEndian.Uint64(bytes[i*bytesPerUint64:])
}
return seeds, nil
}
func contains(hashSeeds []uint64, entries []byte, hash uint64) bool {
var (
numBits = bitsPerByte * uint64(len(entries))
_ = 1 % numBits // hint to the compiler that numBits is not 0
accumulator byte = 1
)
for seedIndex := 0; seedIndex < len(hashSeeds) && accumulator != 0; seedIndex++ {
hash = bits.RotateLeft64(hash, hashRotation) ^ hashSeeds[seedIndex]
index := hash % numBits
byteIndex := index / bitsPerByte
bitIndex := index % bitsPerByte
accumulator &= entries[byteIndex] >> bitIndex
}
return accumulator != 0
}
func marshal(hashSeeds []uint64, entries []byte) []byte {
numHashes := len(hashSeeds)
entriesOffset := 1 + numHashes*bytesPerUint64
bytes := make([]byte, entriesOffset+len(entries))
bytes[0] = byte(numHashes)
for i, seed := range hashSeeds {
binary.BigEndian.PutUint64(bytes[1+i*bytesPerUint64:], seed)
}
copy(bytes[entriesOffset:], entries)
return bytes
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"encoding/binary"
"github.com/spaolacci/murmur3"
)
// marshalCommon serializes [hashSeeds] and [entries] into a byte slice.
func marshalCommon(hashSeeds []uint64, entries []byte) []byte {
bytes := make([]byte, 1+len(hashSeeds)*bytesPerUint64+len(entries))
bytes[0] = byte(len(hashSeeds))
offset := 1
for _, seed := range hashSeeds {
binary.BigEndian.PutUint64(bytes[offset:], seed)
offset += bytesPerUint64
}
copy(bytes[offset:], entries)
return bytes
}
// containsCommon returns true if [hash] is in the filter defined by [hashSeeds] and [entries].
func containsCommon(hashSeeds []uint64, entries []byte, hash uint64) bool {
for _, seed := range hashSeeds {
if !containsWithSeed(entries, hash, seed) {
return false
}
}
return true
}
func containsWithSeed(entries []byte, hash, seed uint64) bool {
index := getIndex(entries, hash, seed)
byteIndex := index / bitsPerByte
bitIndex := index % bitsPerByte
return entries[byteIndex]&(1<<bitIndex) != 0
}
func getIndex(entries []byte, hash, seed uint64) uint64 {
// If the filter has L entries, we only want to use hashes in the range
// [0, L). We achieve this by incrementally shifting the hash to use
// bits that we haven't used before. If we run out of bits, we
// perform a "hash extension" by rehashing the original hash and the
// seed.
//
// If the size of the bloom filter approaches MaxUint64, the hash may
// overflow back to the beginning of the range. This means that the
// same index can be used more than once for a single value.
// This is OK, it just means the filter isn't as good as it could be
// if we had more bits in the hash.
numEntries := uint64(len(entries))
entriesMask := numEntries*bitsPerByte - 1
hash += seed
// Note: It may be significantly more performant to use:
// https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
// over the modulo operation.
index := hash & entriesMask
for index >= numEntries*bitsPerByte {
hash = hash >> hashRotation
// If the hash is zero, we have run out of bits and need to
// rehash.
if hash == 0 {
hash = uint64(murmur3.Sum64(binary.BigEndian.AppendUint64(binary.BigEndian.AppendUint64(nil, hash), seed)))
}
index = hash & entriesMask
}
return index
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"math/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
)
func TestNewErrors(t *testing.T) {
tests := []struct {
numHashes int
numEntries int
err error
}{
{
numHashes: 0,
numEntries: 1,
err: errTooFewHashes,
},
{
numHashes: 17,
numEntries: 1,
err: errTooManyHashes,
},
{
numHashes: 8,
numEntries: 0,
err: errTooFewEntries,
},
}
for _, test := range tests {
t.Run(test.err.Error(), func(t *testing.T) {
_, err := New(test.numHashes, test.numEntries)
require.ErrorIs(t, err, test.err)
})
}
}
func TestNormalUsage(t *testing.T) {
require := require.New(t)
toAdd := make([]uint64, 1024)
for i := range toAdd {
toAdd[i] = rand.Uint64() //#nosec G404
}
initialNumHashes, initialNumBytes := OptimalParameters(1024, 0.01)
filter, err := New(initialNumHashes, initialNumBytes)
require.NoError(err)
for i, elem := range toAdd {
filter.Add(elem)
for _, elem := range toAdd[:i] {
require.True(filter.Contains(elem))
}
}
require.Equal(len(toAdd), filter.Count())
filterBytes := filter.Marshal()
parsedFilter, err := Parse(filterBytes)
require.NoError(err)
for _, elem := range toAdd {
require.True(parsedFilter.Contains(elem))
}
parsedFilterBytes := parsedFilter.Marshal()
require.Equal(filterBytes, parsedFilterBytes)
}
func BenchmarkAdd(b *testing.B) {
f, err := New(8, 16*constants.KiB)
require.NoError(b, err)
b.ResetTimer()
for i := 0; i < b.N; i++ {
f.Add(1)
}
}
func BenchmarkMarshal(b *testing.B) {
f, err := New(OptimalParameters(10_000, .01))
require.NoError(b, err)
b.ResetTimer()
for i := 0; i < b.N; i++ {
f.Marshal()
}
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"crypto/sha256"
"encoding/binary"
)
func Add(f *Filter, key, salt []byte) {
f.Add(Hash(key, salt))
}
func Contains(c Checker, key, salt []byte) bool {
return c.Contains(Hash(key, salt))
}
type Checker interface {
Contains(hash uint64) bool
}
func Hash(key, salt []byte) uint64 {
hash := sha256.New()
// sha256.Write never returns errors
_, _ = hash.Write(key)
_, _ = hash.Write(salt)
output := make([]byte, 0, sha256.Size)
return binary.BigEndian.Uint64(hash.Sum(output))
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
)
func TestCollisionResistance(t *testing.T) {
require := require.New(t)
f, err := New(8, 16*constants.KiB)
require.NoError(err)
Add(f, []byte("hello world?"), []byte("so salty"))
collision := Contains(f, []byte("hello world!"), []byte("so salty"))
require.False(collision)
}
func BenchmarkHash(b *testing.B) {
key := ids.GenerateTestID()
salt := ids.GenerateTestID()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Hash(key[:], salt[:])
}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"sync"
"github.com/luxfi/math/set"
)
type mapFilter struct {
lock sync.RWMutex
values set.Set[string]
}
func NewMap() BloomFilter {
return &mapFilter{}
}
func (m *mapFilter) Add(bl ...[]byte) {
m.lock.Lock()
defer m.lock.Unlock()
for _, b := range bl {
m.values.Add(string(b))
}
}
func (m *mapFilter) Check(b []byte) bool {
m.lock.RLock()
defer m.lock.RUnlock()
return m.values.Contains(string(b))
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import "github.com/luxfi/metric"
// Metrics is a collection of commonly useful metrics when using a long-lived
// bloom filter.
type Metrics struct {
Count metric.Gauge
NumHashes metric.Gauge
NumEntries metric.Gauge
MaxCount metric.Gauge
ResetCount metric.Counter
}
func NewMetrics(
namespace string,
registry metric.Registry,
) (*Metrics, error) {
metricsInstance := metric.NewWithRegistry(namespace, registry)
m := &Metrics{
Count: metricsInstance.NewGauge(
"count",
"Number of additions that have been performed to the bloom",
),
NumHashes: metricsInstance.NewGauge(
"hashes",
"Number of hashes in the bloom",
),
NumEntries: metricsInstance.NewGauge(
"entries",
"Number of bytes allocated to slots in the bloom",
),
MaxCount: metricsInstance.NewGauge(
"max_count",
"Maximum number of additions that should be performed to the bloom before resetting",
),
ResetCount: metricsInstance.NewCounter(
"reset_count",
"Number times the bloom has been reset",
),
}
return m, nil
}
// Reset the metrics to align with the provided bloom filter and max count.
func (m *Metrics) Reset(newFilter *Filter, maxCount int) {
m.Count.Set(float64(newFilter.Count()))
m.NumHashes.Set(float64(len(newFilter.hashSeeds)))
m.NumEntries.Set(float64(len(newFilter.entries)))
m.MaxCount.Set(float64(maxCount))
m.ResetCount.Inc()
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import "math"
const ln2Squared = math.Ln2 * math.Ln2
// OptimalParameters calculates the optimal [numHashes] and [numEntries] that
// should be allocated for a bloom filter which will contain [count] and target
// [falsePositiveProbability].
func OptimalParameters(count int, falsePositiveProbability float64) (int, int) {
numEntries := OptimalEntries(count, falsePositiveProbability)
numHashes := OptimalHashes(numEntries, count)
return numHashes, numEntries
}
// OptimalHashes calculates the number of hashes which will minimize the false
// positive probability of a bloom filter with [numEntries] after [count]
// additions.
//
// It is guaranteed to return a value in the range [minHashes, maxHashes].
//
// ref: https://en.wikipedia.org/wiki/Bloom_filter
func OptimalHashes(numEntries, count int) int {
switch {
case numEntries < minEntries:
return minHashes
case count <= 0:
return maxHashes
}
numHashes := math.Ceil(float64(numEntries) * bitsPerByte * math.Ln2 / float64(count))
// Converting a floating-point value to an int produces an undefined value
// if the floating-point value cannot be represented as an int. To avoid
// this undefined behavior, we explicitly check against MaxInt here.
//
// ref: https://go.dev/ref/spec#Conversions
if numHashes >= maxHashes {
return maxHashes
}
return max(int(numHashes), minHashes)
}
// OptimalEntries calculates the optimal number of entries to use when creating
// a new Bloom filter when targenting a size of [count] with
// [falsePositiveProbability] assuming that the optimal number of hashes is
// used.
//
// It is guaranteed to return a value in the range [minEntries, MaxInt].
//
// ref: https://en.wikipedia.org/wiki/Bloom_filter
func OptimalEntries(count int, falsePositiveProbability float64) int {
switch {
case count <= 0:
return minEntries
case falsePositiveProbability >= 1:
return minEntries
case falsePositiveProbability <= 0:
return math.MaxInt
}
entriesInBits := -float64(count) * math.Log(falsePositiveProbability) / ln2Squared
entries := (entriesInBits + bitsPerByte - 1) / bitsPerByte
// Converting a floating-point value to an int produces an undefined value
// if the floating-point value cannot be represented as an int. To avoid
// this undefined behavior, we explicitly check against MaxInt here.
//
// ref: https://go.dev/ref/spec#Conversions
if entries >= math.MaxInt {
return math.MaxInt
}
return max(int(entries), minEntries)
}
// EstimateCount estimates the number of additions a bloom filter with
// [numHashes] and [numEntries] must have to reach [falsePositiveProbability].
// This is derived by inversing a lower-bound on the probability of false
// positives. For values where numBits >> numHashes, the predicted probability
// is fairly accurate.
//
// It is guaranteed to return a value in the range [0, MaxInt].
//
// ref: https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=903775
func EstimateCount(numHashes, numEntries int, falsePositiveProbability float64) int {
switch {
case numHashes < minHashes:
return 0
case numEntries < minEntries:
return 0
case falsePositiveProbability <= 0:
return 0
case falsePositiveProbability >= 1:
return math.MaxInt
}
invNumHashes := 1 / float64(numHashes)
numBits := float64(numEntries * 8)
exp := 1 - math.Pow(falsePositiveProbability, invNumHashes)
count := math.Ceil(-math.Log(exp) * numBits * invNumHashes)
// Converting a floating-point value to an int produces an undefined value
// if the floating-point value cannot be represented as an int. To avoid
// this undefined behavior, we explicitly check against MaxInt here.
//
// ref: https://go.dev/ref/spec#Conversions
if count >= math.MaxInt {
return math.MaxInt
}
return int(count)
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"fmt"
"math"
"testing"
"github.com/stretchr/testify/require"
)
const largestFloat64LessThan1 float64 = 1 - 1e-16
func TestOptimalHashes(t *testing.T) {
tests := []struct {
numEntries int
count int
expectedHashes int
}{
{ // invalid params
numEntries: 0,
count: 1024,
expectedHashes: minHashes,
},
{ // invalid params
numEntries: 1024,
count: 0,
expectedHashes: maxHashes,
},
{
numEntries: math.MaxInt,
count: 1,
expectedHashes: maxHashes,
},
{
numEntries: 1,
count: math.MaxInt,
expectedHashes: minHashes,
},
{
numEntries: 1024,
count: 1024,
expectedHashes: 6,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d_%d", test.numEntries, test.count), func(t *testing.T) {
hashes := OptimalHashes(test.numEntries, test.count)
require.Equal(t, test.expectedHashes, hashes)
})
}
}
func TestOptimalEntries(t *testing.T) {
tests := []struct {
count int
falsePositiveProbability float64
expectedEntries int
}{
{ // invalid params
count: 0,
falsePositiveProbability: .5,
expectedEntries: minEntries,
},
{ // invalid params
count: 1,
falsePositiveProbability: 0,
expectedEntries: math.MaxInt,
},
{ // invalid params
count: 1,
falsePositiveProbability: 1,
expectedEntries: minEntries,
},
{
count: math.MaxInt,
falsePositiveProbability: math.SmallestNonzeroFloat64,
expectedEntries: math.MaxInt,
},
{
count: 1024,
falsePositiveProbability: largestFloat64LessThan1,
expectedEntries: minEntries,
},
{
count: 1024,
falsePositiveProbability: .01,
expectedEntries: 1227,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d_%f", test.count, test.falsePositiveProbability), func(t *testing.T) {
entries := OptimalEntries(test.count, test.falsePositiveProbability)
require.Equal(t, test.expectedEntries, entries)
})
}
}
func TestEstimateEntries(t *testing.T) {
tests := []struct {
numHashes int
numEntries int
falsePositiveProbability float64
expectedEntries int
}{
{ // invalid params
numHashes: 0,
numEntries: 2_048,
falsePositiveProbability: .5,
expectedEntries: 0,
},
{ // invalid params
numHashes: 1,
numEntries: 0,
falsePositiveProbability: .5,
expectedEntries: 0,
},
{ // invalid params
numHashes: 1,
numEntries: 1,
falsePositiveProbability: 2,
expectedEntries: math.MaxInt,
},
{ // invalid params
numHashes: 1,
numEntries: 1,
falsePositiveProbability: -1,
expectedEntries: 0,
},
{
numHashes: 8,
numEntries: 2_048,
falsePositiveProbability: 0,
expectedEntries: 0,
},
{ // params from OptimalParameters(10_000, .01)
numHashes: 7,
numEntries: 11_982,
falsePositiveProbability: .01,
expectedEntries: 9_993,
},
{ // params from OptimalParameters(100_000, .001)
numHashes: 10,
numEntries: 179_720,
falsePositiveProbability: .001,
expectedEntries: 100_000,
},
{ // params from OptimalParameters(10_000, .01)
numHashes: 7,
numEntries: 11_982,
falsePositiveProbability: .05,
expectedEntries: 14_449,
},
{ // params from OptimalParameters(10_000, .01)
numHashes: 7,
numEntries: 11_982,
falsePositiveProbability: 1,
expectedEntries: math.MaxInt,
},
{ // params from OptimalParameters(10_000, .01)
numHashes: 7,
numEntries: 11_982,
falsePositiveProbability: math.SmallestNonzeroFloat64,
expectedEntries: 0,
},
{ // params from OptimalParameters(10_000, .01)
numHashes: 7,
numEntries: 11_982,
falsePositiveProbability: largestFloat64LessThan1,
expectedEntries: math.MaxInt,
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%d_%d_%f", test.numHashes, test.numEntries, test.falsePositiveProbability), func(t *testing.T) {
entries := EstimateCount(test.numHashes, test.numEntries, test.falsePositiveProbability)
require.Equal(t, test.expectedEntries, entries)
})
}
}
func FuzzOptimalHashes(f *testing.F) {
f.Fuzz(func(t *testing.T, numEntries, count int) {
hashes := OptimalHashes(numEntries, count)
require.GreaterOrEqual(t, hashes, minHashes)
require.LessOrEqual(t, hashes, maxHashes)
})
}
func FuzzOptimalEntries(f *testing.F) {
f.Fuzz(func(t *testing.T, count int, falsePositiveProbability float64) {
entries := OptimalEntries(count, falsePositiveProbability)
require.GreaterOrEqual(t, entries, minEntries)
})
}
func FuzzEstimateEntries(f *testing.F) {
f.Fuzz(func(t *testing.T, numHashes, numEntries int, falsePositiveProbability float64) {
entries := EstimateCount(numHashes, numEntries, falsePositiveProbability)
require.GreaterOrEqual(t, entries, 0)
})
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"encoding/binary"
"fmt"
)
var (
EmptyFilter = &ReadFilter{
hashSeeds: make([]uint64, minHashes),
entries: make([]byte, minEntries),
}
FullFilter = &ReadFilter{
hashSeeds: make([]uint64, minHashes),
entries: make([]byte, minEntries),
}
)
func init() {
for i := range FullFilter.entries {
FullFilter.entries[i] = 0xFF
}
}
type ReadFilter struct {
hashSeeds []uint64
entries []byte
}
// Parse [bytes] into a read-only bloom filter.
func Parse(bytes []byte) (*ReadFilter, error) {
if len(bytes) == 0 {
return nil, errInvalidNumHashes
}
numHashes := bytes[0]
entriesOffset := 1 + int(numHashes)*bytesPerUint64
switch {
case numHashes < minHashes:
return nil, fmt.Errorf("%w: %d < %d", errTooFewHashes, numHashes, minHashes)
case numHashes > maxHashes:
return nil, fmt.Errorf("%w: %d > %d", errTooManyHashes, numHashes, maxHashes)
case len(bytes) < entriesOffset+minEntries: // numEntries = len(bytes) - entriesOffset
return nil, errTooFewEntries
}
f := &ReadFilter{
hashSeeds: make([]uint64, numHashes),
entries: bytes[entriesOffset:],
}
for i := range f.hashSeeds {
f.hashSeeds[i] = binary.BigEndian.Uint64(bytes[1+i*bytesPerUint64:])
}
return f, nil
}
func (f *ReadFilter) Contains(hash uint64) bool {
return contains(f.hashSeeds, f.entries, hash)
}
func (f *ReadFilter) Marshal() []byte {
return marshal(f.hashSeeds, f.entries)
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bloom
import (
"math"
"testing"
"github.com/stretchr/testify/require"
)
func NewMaliciousFilter(numHashes, numEntries int) *Filter {
f := &Filter{
numBits: uint64(numEntries * bitsPerByte),
hashSeeds: make([]uint64, numHashes),
entries: make([]byte, numEntries),
count: 0,
}
for i := range f.entries {
f.entries[i] = math.MaxUint8
}
return f
}
func TestParseErrors(t *testing.T) {
tests := []struct {
bytes []byte
err error
}{
{
bytes: nil,
err: errInvalidNumHashes,
},
{
bytes: NewMaliciousFilter(0, 1).Marshal(),
err: errTooFewHashes,
},
{
bytes: NewMaliciousFilter(17, 1).Marshal(),
err: errTooManyHashes,
},
{
bytes: NewMaliciousFilter(1, 0).Marshal(),
err: errTooFewEntries,
},
{
bytes: []byte{
0x01, // num hashes = 1
},
err: errTooFewEntries,
},
}
for _, test := range tests {
t.Run(test.err.Error(), func(t *testing.T) {
_, err := Parse(test.bytes)
require.ErrorIs(t, err, test.err)
})
}
}
func BenchmarkParse(b *testing.B) {
f, err := New(OptimalParameters(10_000, .01))
require.NoError(b, err)
bytes := f.Marshal()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Parse(bytes)
}
}
func BenchmarkContains(b *testing.B) {
f := NewMaliciousFilter(maxHashes, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
f.Contains(1)
}
}
func FuzzParseThenMarshal(f *testing.F) {
f.Fuzz(func(t *testing.T, bytes []byte) {
f, err := Parse(bytes)
if err != nil {
return
}
marshalledBytes := marshal(f.hashSeeds, f.entries)
require.Equal(t, bytes, marshalledBytes)
})
}
func FuzzMarshalThenParse(f *testing.F) {
f.Fuzz(func(t *testing.T, numHashes int, entries []byte) {
require := require.New(t)
hashSeeds, err := newHashSeeds(numHashes)
if err != nil {
return
}
if len(entries) < minEntries {
return
}
marshalledBytes := marshal(hashSeeds, entries)
rf, err := Parse(marshalledBytes)
require.NoError(err)
require.Equal(hashSeeds, rf.hashSeeds)
require.Equal(entries, rf.entries)
})
}
+279
View File
@@ -0,0 +1,279 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bytecache
import (
"sync"
"sync/atomic"
)
const (
numShards = 256
shardMask = numShards - 1
)
// Stats contains cache performance metrics.
type Stats struct {
EntriesCount uint64
BytesSize uint64
Collisions uint64
GetCalls uint64
SetCalls uint64
Misses uint64
}
// Cache is a high-performance sharded LRU byte cache.
// It provides O(1) lookups with minimal lock contention.
type Cache struct {
shards [numShards]*byteShard
maxBytes int64
getCalls uint64
setCalls uint64
misses uint64
}
type byteShard struct {
mu sync.RWMutex
items map[string]*byteEntry
head, tail *byteEntry
currentSize int64
maxSize int64
}
type byteEntry struct {
key string
value []byte
size int
prev, next *byteEntry
}
// New creates a new byte cache with the given max size in bytes.
func New(maxBytes int) *Cache {
if maxBytes <= 0 {
maxBytes = 1
}
c := &Cache{maxBytes: int64(maxBytes)}
perShard := int64(maxBytes) / numShards
if perShard < 1 {
perShard = 1
}
for i := range c.shards {
c.shards[i] = &byteShard{
items: make(map[string]*byteEntry),
maxSize: perShard,
}
}
return c
}
func (c *Cache) shard(key []byte) *byteShard {
h := uint8(0)
for _, b := range key {
h ^= b
}
return c.shards[h&shardMask]
}
// Reset clears all cached entries.
func (c *Cache) Reset() {
for _, s := range c.shards {
s.mu.Lock()
s.items = make(map[string]*byteEntry)
s.head, s.tail = nil, nil
s.currentSize = 0
s.mu.Unlock()
}
}
// Del removes a key from the cache.
func (c *Cache) Del(key []byte) {
s := c.shard(key)
k := string(key)
s.mu.Lock()
if e, ok := s.items[k]; ok {
s.unlink(e)
s.currentSize -= int64(e.size)
delete(s.items, k)
}
s.mu.Unlock()
}
// Has reports whether a key exists.
func (c *Cache) Has(key []byte) bool {
s := c.shard(key)
k := string(key)
s.mu.RLock()
_, ok := s.items[k]
s.mu.RUnlock()
return ok
}
// HasGet returns the value and whether it exists.
func (c *Cache) HasGet(dst, key []byte) ([]byte, bool) {
atomic.AddUint64(&c.getCalls, 1)
s := c.shard(key)
k := string(key)
s.mu.Lock()
e, ok := s.items[k]
if ok {
s.moveToFront(e)
val := e.value
s.mu.Unlock()
if dst == nil {
return append([]byte(nil), val...), true
}
return append(dst[:0], val...), true
}
s.mu.Unlock()
atomic.AddUint64(&c.misses, 1)
if dst == nil {
return nil, false
}
return dst[:0], false
}
// Get looks up a value by key, copying into dst if provided.
func (c *Cache) Get(dst, key []byte) []byte {
atomic.AddUint64(&c.getCalls, 1)
s := c.shard(key)
k := string(key)
s.mu.Lock()
e, ok := s.items[k]
if ok {
s.moveToFront(e)
val := e.value
s.mu.Unlock()
if dst == nil {
return append([]byte(nil), val...)
}
return append(dst[:0], val...)
}
s.mu.Unlock()
atomic.AddUint64(&c.misses, 1)
if dst == nil {
return nil
}
return dst[:0]
}
// GetBig is an alias for Get (compatibility).
func (c *Cache) GetBig(dst, key []byte) []byte {
return c.Get(dst, key)
}
// Set stores a key/value pair.
func (c *Cache) Set(key, value []byte) {
atomic.AddUint64(&c.setCalls, 1)
s := c.shard(key)
k := string(key)
v := append([]byte(nil), value...)
entrySize := len(k) + len(v)
s.mu.Lock()
defer s.mu.Unlock()
// Entry too large for shard
if int64(entrySize) > s.maxSize {
return
}
// Update existing
if e, ok := s.items[k]; ok {
s.currentSize -= int64(e.size)
e.value = v
e.size = entrySize
s.currentSize += int64(entrySize)
s.moveToFront(e)
return
}
// Evict until we have space
for s.currentSize+int64(entrySize) > s.maxSize && s.tail != nil {
old := s.tail
s.unlink(old)
s.currentSize -= int64(old.size)
delete(s.items, old.key)
}
// Insert new entry
e := &byteEntry{key: k, value: v, size: entrySize}
s.items[k] = e
s.pushFront(e)
s.currentSize += int64(entrySize)
}
// SetBig is an alias for Set (compatibility).
func (c *Cache) SetBig(key, value []byte) {
c.Set(key, value)
}
// UpdateStats populates the provided stats struct.
func (c *Cache) UpdateStats(s *Stats) {
if s == nil {
return
}
var entries, size uint64
for _, sh := range c.shards {
sh.mu.RLock()
entries += uint64(len(sh.items))
size += uint64(sh.currentSize)
sh.mu.RUnlock()
}
s.EntriesCount = entries
s.BytesSize = size
s.Collisions = 0
s.GetCalls = atomic.LoadUint64(&c.getCalls)
s.SetCalls = atomic.LoadUint64(&c.setCalls)
s.Misses = atomic.LoadUint64(&c.misses)
}
// Doubly-linked list operations for LRU
func (s *byteShard) pushFront(e *byteEntry) {
e.prev = nil
e.next = s.head
if s.head != nil {
s.head.prev = e
}
s.head = e
if s.tail == nil {
s.tail = e
}
}
func (s *byteShard) unlink(e *byteEntry) {
if e.prev != nil {
e.prev.next = e.next
} else {
s.head = e.next
}
if e.next != nil {
e.next.prev = e.prev
} else {
s.tail = e.prev
}
e.prev, e.next = nil, nil
}
func (s *byteShard) moveToFront(e *byteEntry) {
if s.head == e {
return
}
s.unlink(e)
s.pushFront(e)
}
// SaveToFileConcurrent is a no-op for compatibility with fastcache API.
func (c *Cache) SaveToFileConcurrent(filePath string, concurrency int) error {
return nil
}
// LoadFromFile is a no-op for compatibility with fastcache API.
func (c *Cache) LoadFromFile(filePath string) error {
return nil
}
+1 -1
View File
@@ -17,7 +17,7 @@ type DualMapCache[K comparable, V any] struct {
}
// NewDualMapCache creates a new DualMapCache. Metrics are optional.
func NewDualMapCache[K comparable, V any](_ *metric.MetricsRegistry) *DualMapCache[K, V] {
func NewDualMapCache[K comparable, V any](_ metric.Registry) *DualMapCache[K, V] {
return &DualMapCache[K, V]{
items: make(map[K]V),
}
+4 -3
View File
@@ -4,8 +4,10 @@ go 1.25.5
require (
github.com/luxfi/container v0.0.2
github.com/luxfi/ids v1.2.7
github.com/luxfi/math v1.2.3
github.com/luxfi/metric v1.4.10
github.com/spaolacci/murmur3 v1.1.0
github.com/stretchr/testify v1.11.1
)
@@ -14,11 +16,9 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/luxfi/crypto v1.17.36 // indirect
github.com/luxfi/ids v1.2.7 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/sampler v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -30,6 +30,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/sys v0.39.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+3 -3
View File
@@ -2,7 +2,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -27,10 +26,9 @@ github.com/luxfi/math v1.2.3 h1:BgvIFw/srPXFLbcqtoDhLJOfmBsn86GPA1iWgsoyUb4=
github.com/luxfi/math v1.2.3/go.mod h1:C8STnF2H+D6rqBPt248CiWY2TGuJgdtv/+4UqrT15iM=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/metric v1.4.5 h1:fD6gtpzB5ebGB06/m4K7e5+tOV5RiVHSZCV+Zb8ZDxk=
github.com/luxfi/metric v1.4.5/go.mod h1:AUQ7NSNz9WndAcr/SKnOkP7XSFFnBXOa+ihtJYfDaQY=
github.com/luxfi/metric v1.4.10 h1:uWeLupMLxK7YK1c99gY23cT+hornWg7OmZgHNK4HNbw=
github.com/luxfi/metric v1.4.10/go.mod h1:3OIQ+e6mciesm3cik3Q4CSOAvTlBy4JA0ZoKzCB9APo=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -47,6 +45,7 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -59,6 +58,7 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -105,7 +105,7 @@ func (c *Cache[K, V]) Flush() {
func (c *Cache[K, V]) PortionFilled() float64 {
c.mu.Lock()
defer c.mu.Unlock()
current := float64(c.Len())
current := float64(c.containerCache.Len())
capacity := float64(c.capacity)
if capacity == 0 {
return 0