From 7e35a1f8c737a2f4206552fc0df1fec4f7bb4bac Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 7 Jul 2023 09:46:22 -0400 Subject: [PATCH] Add method Fingerprint to data.Labels (#712) * Introduce data.Fingerprint alias for uint64 and implement Stringer interface for it that represents the number as hex string * add method Fingerprint to data.Labels that calculates a fingerprint of the labels and returns data.Fingerprint --- data/labels.go | 33 ++++++++++++++++++++++++++++++ data/labels_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/data/labels.go b/data/labels.go index 5c194d2bc..f8bf8c37d 100644 --- a/data/labels.go +++ b/data/labels.go @@ -3,6 +3,7 @@ package data import ( "encoding/json" "fmt" + "hash/fnv" "sort" "strings" "unsafe" @@ -80,6 +81,38 @@ func (l Labels) String() string { return sb.String() } +type Fingerprint uint64 + +func (f Fingerprint) String() string { + return fmt.Sprintf("%016x", uint64(f)) +} + +// Fingerprint calculates a 64-bit FNV-1 hash of the labels. Labels are sorted by key to make sure the hash is stable. +func (l Labels) Fingerprint() Fingerprint { + h := fnv.New64() + if len(l) == 0 { + return Fingerprint(h.Sum64()) + } + // maps do not guarantee predictable sequence of keys. + // Therefore, to make hash stable, we need to sort keys + keys := make([]string, 0, len(l)) + for labelName := range l { + keys = append(keys, labelName) + } + sort.Strings(keys) + for _, name := range keys { + // avoid an extra allocation of a slice of bytes using unsafe conversions. + // The internal structure of the string is almost like a slice (except capacity). + _, _ = h.Write(unsafe.Slice(unsafe.StringData(name), len(name))) + // ignore errors returned by Write method because fnv never returns them. + _, _ = h.Write([]byte{255}) // use an invalid utf-8 sequence as separator + value := l[name] + _, _ = h.Write(unsafe.Slice(unsafe.StringData(value), len(value))) + _, _ = h.Write([]byte{255}) + } + return Fingerprint(h.Sum64()) +} + // LabelsFromString() parses string into a Label object. // Input string needs to follow the k=v convention, // e.g. `{service="users-directory"}`, "method=GET", or real JSON diff --git a/data/labels_test.go b/data/labels_test.go index e0877e65e..1ee5cf11d 100644 --- a/data/labels_test.go +++ b/data/labels_test.go @@ -4,9 +4,10 @@ import ( "encoding/json" "testing" - "github.com/grafana/grafana-plugin-sdk-go/data" jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-plugin-sdk-go/data" ) // Equals returns true if the argument has the same k=v pairs as the receiver. @@ -83,3 +84,49 @@ func TestLabelsFromString(t *testing.T) { require.NoError(t, err) require.Equal(t, result, data.Labels{"method": "GET"}) } + +func TestLabelsFingerprint(t *testing.T) { + testCases := []struct { + name string + labels data.Labels + fingerprint data.Fingerprint + }{ + { + name: "should work if nil", + labels: nil, + fingerprint: data.Fingerprint(0xcbf29ce484222325), + }, + { + name: "should work if empty", + labels: make(data.Labels), + fingerprint: data.Fingerprint(0xcbf29ce484222325), + }, + { + name: "should calculate hash", + labels: data.Labels{"a": "AAA", "b": "BBB", "c": "CCC", "d": "DDD"}, + fingerprint: data.Fingerprint(0xfb4532f90d896635), + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.fingerprint, testCase.labels.Fingerprint()) + }) + } +} + +func TestLabelsFingerprintString(t *testing.T) { + testCases := []struct { + name string + fingerprint data.Fingerprint + expected string + }{ + {"simple", data.Fingerprint(0x1234567890abcdef), "1234567890abcdef"}, + {"zero", data.Fingerprint(0), "0000000000000000"}, + {"max", data.Fingerprint(0xffffffffffffffff), "ffffffffffffffff"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.expected, testCase.fingerprint.String()) + }) + } +}