first commit.

way too late.
This commit is contained in:
didier amyot committed 2015-01-31 10:19:31 -08:00
commit dbbfaf99b6
15 files changed
+757

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
*cover.out
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.directory
+6
View File
@@ -0,0 +1,6 @@
language: go
go:
- 1.3
- 1.4
- tip
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 didier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
# Doorman [![Build Status](https://travis-ci.org/didiercrunch/doorman.svg)](https://travis-ci.org/didiercrunch/doorman)
doorman client. For the doorman server see [doorman server](https://github.com/didiercrunch/doorman server)
## what it is?
This library can be use in two ways; A/B testing and feature gating.
### A/B testing
The best way to explain *A/B testing* is via an example. Imagine that you have a button in you blog
and that you want to set its colour to maximaze the number of viewers that click on it. For simplicity,
let's assume the colour can be red or magenta.
*A/B* testing allows you to randomly select a colour for the button depending on your viewers. For example,
you could deceide to show to 10% of your users the magenta button while 90% of your user will see the button
red.
Then, you can collect data on your user choice (feature not include in this library) and make a data supported
decision to improved you blog.
### Feature gating
The best way to explain *feature gating* is to give an example. Imaging that you are using *company A* to
manage the discussion section of your blog and that you want to switch to *company B*. You would like to
make the move but you are scared that the switch will cause failure in unexpected part of your blog.
What you can do is to change graduly from *company A* to *company B*. At first, you set 1% of your
viewer to see *company A*, if no issue occure, you move to 5% then to 10% and up to 100%.
## example
There is an example at [example/main.go](https://github.com/didiercrunch/doorman/blob/master/example/main.go) that
change randomly the colour of the background by the url path.
+153
View File
@@ -0,0 +1,153 @@
package doorman
import (
"encoding/json"
"errors"
"log"
"math"
mathrand "math/rand"
"net/http"
"sync"
"time"
"github.com/dchest/siphash"
"github.com/didiercrunch/doorman/shared"
"gopkg.in/mgo.v2/bson"
)
// the smallest value that makes two floats the same. Consequently x == y
// if |x - y| < epsilon. It is a known issue that one can find three numbers
// a,b,c such that a == b, b == c and a != c. Hence, it is the programmer role
// to be very careful about equalities and inequalities.
const epsilon float64 = 0.000000001
var rand *mathrand.Rand
func initRandomSeed() {
kindOfRandomSeed := mathrand.NewSource(time.Now().Unix())
rand = mathrand.New(kindOfRandomSeed)
}
func init() {
initRandomSeed()
}
func IsZero(f float64) bool {
if math.Abs(f) > epsilon {
return false
}
return true
}
func IsEqual(f1, f2 float64) bool {
return IsZero(f1 - f2)
}
type Doorman struct {
Id bson.ObjectId // the id of the doorman
LastChangeTimestamp int64 // an always increasing int that represent the last time the doorman has beed updated
Probabilities []float64 // The probability of each cases. The sum of probabilities needs to be one
wg sync.WaitGroup // waitgroup for goroutine safety
}
func NewDoorman(id bson.ObjectId, Probabilities []float64) *Doorman {
wab := &Doorman{}
wab.Id = id
wab.Probabilities = Probabilities
return wab
}
func (w *Doorman) Length() int {
return len(w.Probabilities)
}
func (w *Doorman) UpdateHard(baseURL string) error {
r, err := http.Get(baseURL + "/" + w.Id.Hex())
if err != nil {
return err
}
message := new(shared.DoormanUpdater)
d := json.NewDecoder(r.Body)
if err := d.Decode(message); err != nil {
return err
} else {
return w.Update(message)
}
}
func (w *Doorman) Update(wu *shared.DoormanUpdater) error {
if wu.Timestamp <= w.LastChangeTimestamp {
return nil
}
if wu.Id != w.Id.Hex() {
return errors.New("bad doorman id")
}
w.wg.Add(1)
defer w.wg.Done()
w.LastChangeTimestamp = wu.Timestamp
if !IsEqual(w.sum(wu.Probabilities), 1) {
return errors.New("the sum of probabilities cannot be different than 1")
}
w.Probabilities = wu.Probabilities
log.Printf("Updated doorman %v with new probabilities %v with timestamp %v", wu.Id, wu.Probabilities, wu.Timestamp)
return nil
}
func (w *Doorman) sum(prob []float64) float64 {
var ret float64 = 0
for _, p := range prob {
ret += p
}
return ret
}
func (w *Doorman) Validate() error {
if len(w.Probabilities) == 0 {
return errors.New("not initiated")
}
if !IsZero(w.sum(w.Probabilities) - 1.0) {
return errors.New("The sum of probabilities is not one")
}
return nil
}
func (w *Doorman) GetCase(choosenRandomPosition float64) uint {
w.wg.Wait()
var prob float64 = 0
for i, p := range w.Probabilities {
prob += p
if choosenRandomPosition-epsilon < prob {
return uint(i)
}
}
panic("cannot have a probability above 1")
}
func (w *Doorman) GenerateRandomProbabilityFromInteger(data uint64) float64 {
var ret float64 = 0
for i := 0; data > 0; i++ {
if data&1 == 1 {
ret += 1.0 / math.Pow(2, float64(i+1))
}
data >>= 1
}
return ret
}
func (w *Doorman) Hash(data ...[]byte) uint64 {
h := siphash.New(make([]byte, 16))
for _, datum := range data {
h.Write(datum)
}
return h.Sum64()
}
func (w *Doorman) GetCaseFromData(data ...[]byte) uint {
random := w.GenerateRandomProbabilityFromInteger(w.Hash(data...))
return w.GetCase(random)
}
func (w *Doorman) GetRandomCase() uint {
return w.GetCase(rand.Float64())
}
+215
View File
@@ -0,0 +1,215 @@
package doorman
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/didiercrunch/doorman/shared"
"gopkg.in/mgo.v2/bson"
)
func assertIsAbout(t *testing.T, expected, received float64) {
if math.Abs(expected-received) > epsilon {
t.Error("received", received, "but expected", expected)
}
}
func assertIsNotAbout(t *testing.T, expected, received float64) {
if math.Abs(expected-received) < epsilon {
t.Error("received", received, "but expected something different than", expected)
}
}
var oid = "507f1f77bcf86cd799439011"
func TestIsZero(t *testing.T) {
if !IsZero(epsilon / 2) {
t.Error()
}
if !IsZero(-epsilon / 2) {
t.Error()
}
if IsZero(epsilon * 1.5) {
t.Error()
}
if IsZero(-epsilon * 1.5) {
t.Error()
}
}
func TestIsEqual(t *testing.T) {
if !IsEqual(1.0, 1.0+epsilon/2) {
t.Error()
}
if IsEqual(1.0, 1.0+epsilon*1.5) {
t.Error()
}
}
func TestValidate(t *testing.T) {
w := &Doorman{}
if w.Validate().Error() != "not initiated" {
t.Error()
}
w = &Doorman{Probabilities: []float64{0.5, 0.75}}
if w.Validate().Error() != "The sum of probabilities is not one" {
t.Error()
}
w = &Doorman{Probabilities: []float64{0.25, 0.75}}
if w.Validate() != nil {
t.Error()
}
w = &Doorman{Probabilities: []float64{0.2500000000000002, 0.75}}
if w.Validate() != nil {
t.Error()
}
}
func TestGetCase(t *testing.T) {
w := NewDoorman(bson.NewObjectId(), []float64{0.25, 0.5, 0.25})
if c := w.GetCase(0); c != 0 {
t.Error("expected 0 but received", c)
}
if c := w.GetCase(1); c != 2 {
t.Error("expected 2 but received", c)
}
}
func TestGetCaseCoroutineSafety(t *testing.T) {
w := NewDoorman(bson.NewObjectId(), []float64{0.25, 0.5, 0.25})
i := 0
w.wg.Add(1)
go func() {
i++
w.wg.Done()
}()
w.GetCase(0)
if i != 1 {
t.Error("error with goroutine safety")
}
}
func TestGenerateRandomProbabilityFromBitSlice(t *testing.T) {
w := new(Doorman)
assertIsAbout(t, 0.5, w.GenerateRandomProbabilityFromInteger(1))
assertIsAbout(t, 0.5+0*0.25+1*0.125, w.GenerateRandomProbabilityFromInteger(5))
assertIsAbout(t, 1, w.GenerateRandomProbabilityFromInteger(math.MaxUint64))
}
func TestHash(t *testing.T) {
m := []byte("doormen are great")
w := new(Doorman)
var expt uint64 = 0x3973fc1b3e324215
result := w.Hash(m)
if result != expt {
t.Error(fmt.Sprintf("bad hashing, expected: %x, result: %x", expt, result))
}
}
func TestUpdateTimestamp(t *testing.T) {
w := &Doorman{LastChangeTimestamp: 10, Id: bson.ObjectIdHex(oid)}
m := &shared.DoormanUpdater{Timestamp: 9}
w.Update(m)
if w.LastChangeTimestamp != 10 {
t.Error()
}
m = &shared.DoormanUpdater{Timestamp: 11, Probabilities: []float64{}, Id: oid}
w.Update(m)
if w.LastChangeTimestamp != 11 {
t.Error()
}
}
func TestUpdateProbabilities(t *testing.T) {
w := &Doorman{LastChangeTimestamp: 0, Id: bson.ObjectIdHex(oid)}
m := &shared.DoormanUpdater{Timestamp: 0, Probabilities: []float64{0.5, 0.5}, Id: oid}
w.Update(m)
if len(w.Probabilities) != 0 {
t.Error()
}
m = &shared.DoormanUpdater{Timestamp: 2, Probabilities: []float64{0.5, 0.5}, Id: oid}
if err := w.Update(m); err != nil {
t.Error(err)
} else if !reflect.DeepEqual(w.Probabilities, []float64{0.5, 0.5}) {
t.Error("bad prob")
}
m = &shared.DoormanUpdater{Timestamp: 3, Probabilities: []float64{0.5, 0.25}, Id: oid}
if err := w.Update(m); err == nil {
t.Error("should received an error")
} else if !reflect.DeepEqual(w.Probabilities, []float64{0.5, 0.5}) {
t.Error("bad prob")
}
}
func randomBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(i % 256)
}
return b
}
func TestUpdateHard(t *testing.T) {
wab := NewDoorman(bson.ObjectIdHex(oid), []float64{})
createMessage := func() string {
msg := &shared.DoormanUpdater{Timestamp: 2, Probabilities: []float64{0.5, 0.25, 0.25}, Id: oid}
if ret, err := json.Marshal(msg); err != nil {
panic(err)
} else {
return string(ret)
}
}
handler := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/"+wab.Id.Hex() {
t.Error("bad url", r.URL)
}
fmt.Fprint(w, createMessage())
}
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()
if err := wab.UpdateHard(ts.URL); err != nil {
t.Error(err)
}
if wab.Length() != 3 {
t.Error()
}
}
func TestGetRandomCase(t *testing.T) {
wab := NewDoorman(bson.NewObjectId(), []float64{0.5, 0.5})
var ret uint = 0
for i := 0; i < 10000; i++ {
ret += wab.GetRandomCase()
}
if ret < 4900 || ret > 5100 {
t.Error("very improbable result of ", ret)
}
}
func BenchmarkGetCaseFromData(b *testing.B) {
var data = randomBytes(1024 * 1024)
w := NewDoorman(bson.NewObjectId(), []float64{0.1, 0.4, 0.4, 0.05, 0.05})
for i := 0; i < b.N; i++ {
w.GetCaseFromData(data)
}
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"testing"
"github.com/didiercrunch/doorman"
"gopkg.in/mgo.v2/bson"
)
func TestDoormanDistribution(t *testing.T) {
wab = doorman.NewDoorman(bson.NewObjectId(), []float64{0.25, 0.5, 0.10, 0.05, 0.1})
n := 10000
cases := make([]float64, wab.Length())
for i := 0; i <= n; i++ {
cases[wab.GetRandomCase()] += 1
}
expt := make([]float64, wab.Length())
for i, p := range wab.Probabilities {
expt[i] = p * float64(n)
}
// Todo: use chi square test to assert the results are not absurde
//if err := AssertTwoDistributionAreProbabliNotDifferent(expt, cases, 0.001); err != nil {
// t.Error(err)
//}
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
"github.com/didiercrunch/doorman"
"gopkg.in/mgo.v2/bson"
)
var wab = doorman.NewDoorman(bson.ObjectIdHex("54b33d5ef6fb5e3b77000001"), []float64{0.25, 0.5, 0.10, 0.05, 0.1})
func init() {
if err := wab.NanoMsgSubscriber("tcp://127.0.0.1:40899"); err != nil {
panic(err)
}
}
func GetDoormanValue(r *http.Request) (string, int) {
switch wab.GetCaseFromData([]byte(r.URL.String())) {
case 0:
return "blue", 0
case 1:
return "red", 1
case 2:
return "pink", 2
case 3:
return "yellow", 3
case 4:
return "magenta", 4
}
log.Panic("impossible value returned by doorman")
return "", 0
}
var tmpl = `
<!DOCTYPE html>
<html>
<head></head>
<body style="background:{{ .color }};width:100%;height:100%">
<h1 style="text-align:center;">the doorman value is {{ .value }}</h1>
<p style="text-align:center;">
this doorman depend solely on the url. If you hit different url you
will have different doorman results.
</p>
</body>
</html>
`
func Handler(w http.ResponseWriter, r *http.Request) {
t, err := template.New("foo").Parse(tmpl)
if err != nil {
panic(err)
}
m := make(map[string]interface{})
m["color"], m["value"] = GetDoormanValue(r)
if err := t.Execute(w, m); err != nil {
panic(err)
}
}
func getPort() string {
if len(os.Args) > 1 {
return os.Args[1]
} else {
return "8080"
}
}
func main() {
http.HandleFunc("/", Handler)
port := getPort()
log.Println("serving on port", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
+59
View File
@@ -0,0 +1,59 @@
package nanomsgsubscriber
import (
"encoding/json"
"errors"
"log"
"github.com/didiercrunch/doorman/shared"
"github.com/gdamore/mangos"
"github.com/gdamore/mangos/protocol/sub"
"github.com/gdamore/mangos/transport/ipc"
"github.com/gdamore/mangos/transport/tcp"
"gopkg.in/mgo.v2/bson"
)
type NanoMsgSubscriber struct {
Url string
}
func (s *NanoMsgSubscriber) callUpdateHandlerFunction(f shared.UpdateHandlerFunc, data []byte) error {
wu := &shared.DoormanUpdater{}
if err := json.Unmarshal(data, wu); err != nil {
return err
} else {
return f(wu)
}
}
func (s *NanoMsgSubscriber) Subscribe(abtestId bson.ObjectId, update shared.UpdateHandlerFunc) error {
var sock mangos.Socket
var err error
var msg []byte
if sock, err = sub.NewSocket(); err != nil {
return errors.New("can't get new sub socket: " + err.Error())
}
sock.AddTransport(ipc.NewTransport())
sock.AddTransport(tcp.NewTransport())
if err = sock.Dial(s.Url); err != nil {
return errors.New("can't dial on sub socket: " + err.Error())
}
// Empty byte array effectively subscribes to everything
if err = sock.SetOption(mangos.OptionSubscribe, []byte("")); err != nil {
return errors.New("cannot subscribe: " + err.Error())
}
go func(s *NanoMsgSubscriber, sock mangos.Socket) {
for {
if msg, err = sock.Recv(); err != nil {
err := errors.New("Cannot recv: " + err.Error())
log.Println(err)
} else if err := s.callUpdateHandlerFunction(update, msg); err != nil {
log.Println("cannot update abtest with received data: ", err)
}
}
}(s, sock)
return nil
}
@@ -0,0 +1,27 @@
package nanomsgsubscriber
import (
"encoding/json"
"testing"
"github.com/didiercrunch/doorman/shared"
)
func TestCallUpdateHandlerFunction(t *testing.T) {
f := func(m *shared.DoormanUpdater) error {
if m.Id != "foo" {
t.Error("bad doorman id", m.Id)
}
return nil
}
data, err := json.Marshal(&shared.DoormanUpdater{Id: "foo"})
if err != nil {
t.Error(err)
return
}
if new(NanoMsgSubscriber).callUpdateHandlerFunction(f, data); err != nil {
t.Error(err)
}
}
+15
View File
@@ -0,0 +1,15 @@
package nsqsubscriber
import "github.com/bitly/go-nsq"
type NSQMessage struct {
*nsq.Message
}
func (m *NSQMessage) Body() []byte {
return m.Message.Body
}
func (m *NSQMessage) Timestamp() int64 {
return m.Message.Timestamp
}
+40
View File
@@ -0,0 +1,40 @@
package nsqsubscriber
import (
"encoding/json"
"code.google.com/p/go-uuid/uuid"
"github.com/bitly/go-nsq"
"github.com/didiercrunch/doorman/shared"
"gopkg.in/mgo.v2/bson"
)
var UUID = uuid.New()
type NSQSubscriber struct {
NSQLookupURL string
}
func toNSQHandlerFunc(update shared.UpdateHandlerFunc) nsq.HandlerFunc {
return func(message *nsq.Message) error {
wu := &shared.DoormanUpdater{}
if err := json.Unmarshal(message.Body, wu); err != nil {
return err
}
wu.Timestamp = message.Timestamp
return update(wu)
}
}
func (sub *NSQSubscriber) Subscribe(doormanId bson.ObjectId, update shared.UpdateHandlerFunc) error {
config := nsq.NewConfig()
q, err := nsq.NewConsumer(doormanId.Hex(), UUID, config)
if err != nil {
return err
}
q.AddHandler(nsq.HandlerFunc(toNSQHandlerFunc(update)))
if err := q.ConnectToNSQLookupd(sub.NSQLookupURL); err != nil {
return err
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package nsqsubscriber
import (
"testing"
"github.com/bitly/go-nsq"
)
func TestNSQMessage(t *testing.T) {
m := &nsq.Message{Body: []byte("hello world"), Timestamp: 10}
nsqM := &NSQMessage{m}
if string(nsqM.Body()) != "hello world" {
t.Error()
}
if nsqM.Timestamp() != 10 {
t.Error()
}
}
+9
View File
@@ -0,0 +1,9 @@
package shared
type DoormanUpdater struct {
Id string `json:"id"`
Timestamp int64 `json:"timestamp"`
Probabilities []float64 `json:"probabilities"`
}
type UpdateHandlerFunc func(m *DoormanUpdater) error
+26
View File
@@ -0,0 +1,26 @@
package doorman
import (
"github.com/didiercrunch/doorman/nanomsgsubscriber"
"github.com/didiercrunch/doorman/nsqsubscriber"
"github.com/didiercrunch/doorman/shared"
"gopkg.in/mgo.v2/bson"
)
type Subscriber interface {
Subscribe(doormanId bson.ObjectId, update shared.UpdateHandlerFunc) error
}
func (w *Doorman) Subscribe(sub Subscriber) error {
return sub.Subscribe(w.Id, w.Update)
}
func (w *Doorman) NSQSubscriber(NSQLookupdURl string) error {
sub := &nsqsubscriber.NSQSubscriber{NSQLookupdURl}
return w.Subscribe(sub)
}
func (w *Doorman) NanoMsgSubscriber(NanoMsgUrlLookupdURl string) error {
sub := &nanomsgsubscriber.NanoMsgSubscriber{NanoMsgUrlLookupdURl}
return w.Subscribe(sub)
}