Skip to content

Commit 19fa032

Browse files
yasirfolio3Michael Ng
authored andcommitted
refact(bucketer): Separated out murmurhash-bucketer logic. (#192)
1 parent ad65d9e commit 19fa032

File tree

5 files changed

+158
-107
lines changed

5 files changed

+158
-107
lines changed

pkg/decision/bucketer/experiment_bucketer.go

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,40 @@
1818
package bucketer
1919

2020
import (
21-
"math"
22-
2321
"github.com/optimizely/go-sdk/pkg/decision/reasons"
2422
"github.com/optimizely/go-sdk/pkg/entities"
25-
"github.com/optimizely/go-sdk/pkg/logging"
26-
"github.com/twmb/murmur3"
2723
)
2824

29-
var logger = logging.GetLogger("ExperimentBucketer")
30-
var maxHashValue = float32(math.Pow(2, 32))
31-
32-
// DefaultHashSeed is the hash seed to use for murmurhash
33-
const DefaultHashSeed = 1
34-
const maxTrafficValue = 10000
35-
3625
// ExperimentBucketer is used to bucket the user into a particular entity in the experiment's traffic alloc range
3726
type ExperimentBucketer interface {
3827
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error)
3928
}
4029

41-
// MurmurhashBucketer buckets the user using the mmh3 algorightm
42-
type MurmurhashBucketer struct {
43-
hashSeed uint32
30+
// MurmurhashExperimentBucketer buckets the user using the mmh3 algorightm
31+
type MurmurhashExperimentBucketer struct {
32+
bucketer Bucketer
4433
}
4534

46-
// NewMurmurhashBucketer returns a new instance of the experiment bucketer
47-
func NewMurmurhashBucketer(hashSeed uint32) *MurmurhashBucketer {
48-
return &MurmurhashBucketer{
49-
hashSeed: hashSeed,
35+
// NewMurmurhashExperimentBucketer returns a new instance of the murmurhash experiment bucketer
36+
func NewMurmurhashExperimentBucketer(hashSeed uint32) *MurmurhashExperimentBucketer {
37+
return &MurmurhashExperimentBucketer{
38+
bucketer: MurmurhashBucketer{hashSeed: hashSeed},
5039
}
5140
}
5241

5342
// Bucket buckets the user into the given experiment
54-
func (b MurmurhashBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
43+
func (b MurmurhashExperimentBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
5544
if experiment.GroupID != "" && group.Policy == "random" {
5645
bucketKey := bucketingID + group.ID
57-
bucketedExperimentID := b.bucketToEntity(bucketKey, group.TrafficAllocation)
46+
bucketedExperimentID := b.bucketer.BucketToEntity(bucketKey, group.TrafficAllocation)
5847
if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
5948
// User is not bucketed into provided experiment in mutex group
6049
return nil, reasons.NotBucketedIntoVariation, nil
6150
}
6251
}
6352

6453
bucketKey := bucketingID + experiment.ID
65-
bucketedVariationID := b.bucketToEntity(bucketKey, experiment.TrafficAllocation)
54+
bucketedVariationID := b.bucketer.BucketToEntity(bucketKey, experiment.TrafficAllocation)
6655
if bucketedVariationID == "" {
6756
// User is not bucketed into a variation in the experiment, return nil variation
6857
return nil, reasons.NotBucketedIntoVariation, nil
@@ -74,27 +63,3 @@ func (b MurmurhashBucketer) Bucket(bucketingID string, experiment entities.Exper
7463

7564
return nil, reasons.BucketedVariationNotFound, nil
7665
}
77-
78-
func (b MurmurhashBucketer) bucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {
79-
bucketValue := b.generateBucketValue(bucketKey)
80-
81-
var currentEndOfRange int
82-
for _, trafficAllocationRange := range trafficAllocations {
83-
currentEndOfRange = trafficAllocationRange.EndOfRange
84-
if bucketValue < currentEndOfRange {
85-
return trafficAllocationRange.EntityID
86-
}
87-
}
88-
89-
return ""
90-
}
91-
92-
func (b MurmurhashBucketer) generateBucketValue(bucketingKey string) int {
93-
hasher := murmur3.SeedNew32(b.hashSeed)
94-
if _, err := hasher.Write([]byte(bucketingKey)); err != nil {
95-
logger.Error("", err)
96-
}
97-
hashCode := hasher.Sum32()
98-
ratio := float32(hashCode) / maxHashValue
99-
return int(ratio * maxTrafficValue)
100-
}

pkg/decision/bucketer/experiment_bucketer_test.go

Lines changed: 1 addition & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package bucketer
22

33
import (
4-
"fmt"
54
"testing"
65

76
"github.com/optimizely/go-sdk/pkg/decision/reasons"
@@ -10,65 +9,6 @@ import (
109
"github.com/stretchr/testify/assert"
1110
)
1211

13-
func TestGenerateBucketValue(t *testing.T) {
14-
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
15-
16-
// copied from unit tests in the other SDKs
17-
experimentID := "1886780721"
18-
experimentID2 := "1886780722"
19-
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
20-
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
21-
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
22-
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
23-
24-
assert.Equal(t, 5254, bucketer.generateBucketValue(bucketingKey1))
25-
assert.Equal(t, 4299, bucketer.generateBucketValue(bucketingKey2))
26-
assert.Equal(t, 2434, bucketer.generateBucketValue(bucketingKey3))
27-
assert.Equal(t, 5439, bucketer.generateBucketValue(bucketingKey4))
28-
}
29-
30-
func TestBucketToEntity(t *testing.T) {
31-
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
32-
33-
experimentID := "1886780721"
34-
experimentID2 := "1886780722"
35-
36-
// bucket value 5254
37-
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
38-
// bucket value 4299
39-
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
40-
// bucket value 2434
41-
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
42-
// bucket value 5439
43-
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
44-
45-
variation1 := "1234567123"
46-
variation2 := "5949300123"
47-
trafficAlloc := []entities.Range{
48-
entities.Range{
49-
EntityID: "",
50-
EndOfRange: 2500,
51-
},
52-
entities.Range{
53-
EntityID: variation1,
54-
EndOfRange: 4999,
55-
},
56-
entities.Range{
57-
EntityID: variation2,
58-
EndOfRange: 5399,
59-
},
60-
}
61-
62-
assert.Equal(t, variation2, bucketer.bucketToEntity(bucketingKey1, trafficAlloc))
63-
assert.Equal(t, variation1, bucketer.bucketToEntity(bucketingKey2, trafficAlloc))
64-
65-
// bucket to empty variation range
66-
assert.Equal(t, "", bucketer.bucketToEntity(bucketingKey3, trafficAlloc))
67-
68-
// bucket outside of range (not in experiment)
69-
assert.Equal(t, "", bucketer.bucketToEntity(bucketingKey4, trafficAlloc))
70-
}
71-
7212
func TestBucketExclusionGroups(t *testing.T) {
7313
experiment1 := entities.Experiment{
7414
ID: "1886780721",
@@ -106,7 +46,7 @@ func TestBucketExclusionGroups(t *testing.T) {
10646
},
10747
}
10848

109-
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
49+
bucketer := NewMurmurhashExperimentBucketer(DefaultHashSeed)
11050
// ppid2 + 1886780722 (groupId) will generate bucket value of 2434 which maps to experiment 1
11151
bucketedVariation, reason, _ := bucketer.Bucket("ppid2", experiment1, exclusionGroup)
11252
assert.Equal(t, experiment1.Variations["22222"], *bucketedVariation)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package bucketer //
18+
package bucketer
19+
20+
import (
21+
"fmt"
22+
"math"
23+
24+
"github.com/optimizely/go-sdk/pkg/entities"
25+
"github.com/optimizely/go-sdk/pkg/logging"
26+
"github.com/twmb/murmur3"
27+
)
28+
29+
var logger = logging.GetLogger("MurmurhashBucketer")
30+
var maxHashValue = float32(math.Pow(2, 32))
31+
32+
// DefaultHashSeed is the hash seed to use for murmurhash
33+
const DefaultHashSeed = 1
34+
const maxTrafficValue = 10000
35+
36+
// Bucketer is used to generate bucket value using bucketing key
37+
type Bucketer interface {
38+
Generate(bucketingKey string) int
39+
BucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string)
40+
}
41+
42+
// MurmurhashBucketer generates the bucketing value using the mmh3 algorightm
43+
type MurmurhashBucketer struct {
44+
hashSeed uint32
45+
}
46+
47+
// NewMurmurhashBucketer returns a new instance of the murmurhash bucketer
48+
func NewMurmurhashBucketer(hashSeed uint32) *MurmurhashBucketer {
49+
return &MurmurhashBucketer{
50+
hashSeed: hashSeed,
51+
}
52+
}
53+
54+
// Generate returns a bucketing value for bucketing key
55+
func (b MurmurhashBucketer) Generate(bucketingKey string) int {
56+
hasher := murmur3.SeedNew32(b.hashSeed)
57+
if _, err := hasher.Write([]byte(bucketingKey)); err != nil {
58+
logger.Error(fmt.Sprintf("Unable to generate a hash for the bucketing key=%s", bucketingKey), err)
59+
}
60+
hashCode := hasher.Sum32()
61+
ratio := float32(hashCode) / maxHashValue
62+
return int(ratio * maxTrafficValue)
63+
}
64+
65+
// BucketToEntity buckets into a traffic against given bucketKey
66+
func (b MurmurhashBucketer) BucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {
67+
bucketValue := b.Generate(bucketKey)
68+
69+
var currentEndOfRange int
70+
for _, trafficAllocationRange := range trafficAllocations {
71+
currentEndOfRange = trafficAllocationRange.EndOfRange
72+
if bucketValue < currentEndOfRange {
73+
return trafficAllocationRange.EntityID
74+
}
75+
}
76+
77+
return ""
78+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package bucketer
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/optimizely/go-sdk/pkg/entities"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestBucketToEntity(t *testing.T) {
12+
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
13+
14+
experimentID := "1886780721"
15+
experimentID2 := "1886780722"
16+
17+
// bucket value 5254
18+
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
19+
// bucket value 4299
20+
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
21+
// bucket value 2434
22+
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
23+
// bucket value 5439
24+
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
25+
26+
variation1 := "1234567123"
27+
variation2 := "5949300123"
28+
trafficAlloc := []entities.Range{
29+
entities.Range{
30+
EntityID: "",
31+
EndOfRange: 2500,
32+
},
33+
entities.Range{
34+
EntityID: variation1,
35+
EndOfRange: 4999,
36+
},
37+
entities.Range{
38+
EntityID: variation2,
39+
EndOfRange: 5399,
40+
},
41+
}
42+
43+
assert.Equal(t, variation2, bucketer.BucketToEntity(bucketingKey1, trafficAlloc))
44+
assert.Equal(t, variation1, bucketer.BucketToEntity(bucketingKey2, trafficAlloc))
45+
46+
// bucket to empty variation range
47+
assert.Equal(t, "", bucketer.BucketToEntity(bucketingKey3, trafficAlloc))
48+
49+
// bucket outside of range (not in experiment)
50+
assert.Equal(t, "", bucketer.BucketToEntity(bucketingKey4, trafficAlloc))
51+
}
52+
53+
func TestGenerateBucketValue(t *testing.T) {
54+
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
55+
56+
// copied from unit tests in the other SDKs
57+
experimentID := "1886780721"
58+
experimentID2 := "1886780722"
59+
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
60+
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
61+
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
62+
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
63+
64+
assert.Equal(t, 5254, bucketer.Generate(bucketingKey1))
65+
assert.Equal(t, 4299, bucketer.Generate(bucketingKey2))
66+
assert.Equal(t, 2434, bucketer.Generate(bucketingKey3))
67+
assert.Equal(t, 5439, bucketer.Generate(bucketingKey4))
68+
}

pkg/decision/experiment_bucketer_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func NewExperimentBucketerService() *ExperimentBucketerService {
4141
// @TODO(mng): add experiment override service
4242
return &ExperimentBucketerService{
4343
audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(),
44-
bucketer: *bucketer.NewMurmurhashBucketer(bucketer.DefaultHashSeed),
44+
bucketer: *bucketer.NewMurmurhashExperimentBucketer(bucketer.DefaultHashSeed),
4545
}
4646
}
4747

0 commit comments

Comments
 (0)