Skip to content
Draft
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
96 changes: 96 additions & 0 deletions internal/tags/hash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package tags

import "hash/maphash"

// Two independently-seeded hashes are combined into a 128-bit key. The
// seeds are randomized once per process (see hash/maphash), which is fine
// since the result is only ever used as an in-memory map key, never
// persisted or compared across processes.
var (
hashSeed1 = maphash.MakeSeed()
hashSeed2 = maphash.MakeSeed()
)

// HashTags returns a 128-bit hash that uniquely identifies the canonical
// serialized form of name+tags -- the same string SerializeTags(name, tags)
// would produce, tags sorted the same way and pairs with an empty key or
// value discarded the same way -- without allocating for the common case.
//
// Unlike SerializeTags, HashTags never needs to materialize the serialized
// string itself, so for name+tags that serialize to 512 bytes or less (the
// overwhelming majority in practice) it performs zero heap allocations.
// Longer serialized forms fall back to a single exact-size heap allocation.
func HashTags(name string, tags map[string]string) (hi, lo uint64) {
numValid := numValidTags(tags)
if numValid == 0 {
return hashSerialize(name, nil)
}

// Gather into a small stack array for the common case; only tag sets
// larger than this need a heap allocation for the gather itself.
var arr [serializeStackTags]Tag
var dst TagSet
if numValid <= len(arr) {
dst = arr[:0]
} else {
dst = make(TagSet, 0, numValid)
}
return hashSerialize(name, gatherValidTags(dst, tags))
}

// Hash returns a 128-bit hash that uniquely identifies the canonical
// serialized form of name+t (the same string t.Serialize(name) would
// produce). t must already be sorted, as with all other TagSet methods.
// It performs zero heap allocations as long as the serialized form is 512
// bytes or less.
func (t TagSet) Hash(name string) (hi, lo uint64) {
return hashSerialize(name, t)
}

// hashSerialize builds the canonical ".__key=value"-joined serialized form
// of name+pairs into a stack-resident buffer sized to fit the common case,
// falling back to a single exact-size heap allocation if needed, and hashes
// the result. It never returns or retains the buffer, so as long as the
// stack tier is used the buffer itself never escapes to the heap.
func hashSerialize(name string, pairs []Tag) (hi, lo uint64) {
const prefix = ".__"
const sep = "="

n := len(name)
for _, p := range pairs {
n += len(prefix) + len(sep) + len(p.Key) + len(p.Value)
}

switch {
case n <= 128:
var arr [128]byte
b := appendSerialized(arr[:0], name, pairs)
return hashBytes(b)
case n <= 256:
var arr [256]byte
b := appendSerialized(arr[:0], name, pairs)
return hashBytes(b)
case n <= 512:
var arr [512]byte
b := appendSerialized(arr[:0], name, pairs)
return hashBytes(b)
default:
b := appendSerialized(make([]byte, 0, n), name, pairs)
return hashBytes(b)
}
}

func appendSerialized(b []byte, name string, pairs []Tag) []byte {
b = append(b, name...)
for _, p := range pairs {
b = append(b, '.', '_', '_')
b = append(b, p.Key...)
b = append(b, '=')
b = append(b, p.Value...)
}
return b
}

func hashBytes(b []byte) (hi, lo uint64) {
return maphash.Bytes(hashSeed1, b), maphash.Bytes(hashSeed2, b)
}
94 changes: 94 additions & 0 deletions internal/tags/hash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package tags

import (
"fmt"
"hash/maphash"
"testing"
)

// referenceHash hashes the canonical serialized form produced by the
// existing (already well-tested) SerializeTags, as an independent oracle
// for HashTags.
func referenceHash(name string, tags map[string]string) (uint64, uint64) {
s := SerializeTags(name, tags)
b := []byte(s)
return maphash.Bytes(hashSeed1, b), maphash.Bytes(hashSeed2, b)
}

func TestHashTagsMatchesSerializeTags(t *testing.T) {
const name = "prefix"
makeTags := func(n int) map[string]string {
m := make(map[string]string, n)
for i := 0; i < n; i++ {
k := fmt.Sprintf("key%d", i)
v := fmt.Sprintf("val%d", i)
m[k] = v
}
return m
}
for i := 0; i < 100; i++ {
tags := makeTags(i)
wantHi, wantLo := referenceHash(name, tags)
gotHi, gotLo := HashTags(name, tags)
if gotHi != wantHi || gotLo != wantLo {
t.Errorf("%d: HashTags = (%x,%x), want (%x,%x)", i, gotHi, gotLo, wantHi, wantLo)
}
}
}

func TestHashTagsInvalidKeyValue(t *testing.T) {
tags := map[string]string{
"": "invalid_key",
"invalid_value": "",
"1": "1",
}
wantHi, wantLo := referenceHash("name", tags)
gotHi, gotLo := HashTags("name", tags)
if gotHi != wantHi || gotLo != wantLo {
t.Errorf("HashTags = (%x,%x), want (%x,%x)", gotHi, gotLo, wantHi, wantLo)
}
}

func TestTagSetHashMatchesSerialize(t *testing.T) {
const name = "prefix"
for i := 0; i < 100; i++ {
tags := make(map[string]string, i)
for j := 0; j < i; j++ {
tags[fmt.Sprintf("key%d", j)] = fmt.Sprintf("val%d", j)
}
ts := NewTagSet(tags)
s := ts.Serialize(name)
wantHi, wantLo := maphash.Bytes(hashSeed1, []byte(s)), maphash.Bytes(hashSeed2, []byte(s))
gotHi, gotLo := ts.Hash(name)
if gotHi != wantHi || gotLo != wantLo {
t.Errorf("%d: TagSet.Hash = (%x,%x), want (%x,%x)", i, gotHi, gotLo, wantHi, wantLo)
}
}
}

func TestHashTagsAllocs(t *testing.T) {
tags := map[string]string{"region": "us-east-1", "az": "1a", "shard": "17"}
n := testing.AllocsPerRun(1000, func() {
HashTags("stat.name", tags)
})
if n > 0 {
t.Errorf("expected 0 allocs for small tag set, got %v", n)
}
}

func TestHashTagsOverflowAllocs(t *testing.T) {
tags := make(map[string]string, 64)
for i := 0; i < 64; i++ {
tags[fmt.Sprintf("key%02d", i)] = fmt.Sprintf("value-%02d-xxxxx", i)
}
n := testing.AllocsPerRun(1000, func() {
HashTags("stat.name", tags)
})
// one alloc for the pairs gather (>4 tags), one for sort.Sort's
// interface boxing of TagSet (>8 tags; pre-existing cost, also paid
// by SerializeTags's equivalent default branch), and one for the
// heap overflow buffer (>512 bytes serialized).
if n > 3 {
t.Errorf("expected at most 3 allocs for oversized tag set, got %v", n)
}
}
Loading