forked from kamens/gae_bingo
-
Notifications
You must be signed in to change notification settings - Fork 0
/
gae_bingo.py
228 lines (153 loc) · 8.18 KB
/
gae_bingo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
import logging
import hashlib
import os
import cgi
import time
from google.appengine.api import memcache
from .cache import BingoCache, bingo_and_identity_cache
from .models import create_experiment_and_alternatives
from .identity import identity
def ab_test(canonical_name, alternative_params = None, conversion_name = None):
bingo_cache, bingo_identity_cache = bingo_and_identity_cache()
if canonical_name not in bingo_cache.experiments:
# Creation logic w/ high concurrency protection
client = memcache.Client()
lock_key = "_gae_bingo_test_creation_lock"
got_lock = False
try:
# Make sure only one experiment gets created
while not got_lock:
locked = client.gets(lock_key)
while locked is None:
# Initialize the lock if necessary
client.set(lock_key, False)
locked = client.gets(lock_key)
if not locked:
# Lock looks available, try to take it with compare and set (expiration of 10 seconds)
got_lock = client.cas(lock_key, True, time=10)
if not got_lock:
# If we didn't get it, wait a bit and try again
time.sleep(0.1)
# We have the lock, go ahead and create the experiment if still necessary
if canonical_name not in BingoCache.get().experiments:
# Handle multiple conversions for a single experiment by just quietly
# creating multiple experiments for each conversion
conversion_names = conversion_name if type(conversion_name) == list else [conversion_name]
for i, conversion_name in enumerate(conversion_names):
unique_experiment_name = canonical_name if i == 0 else "%s (%s)" % (canonical_name, i + 1)
exp, alts = create_experiment_and_alternatives(
unique_experiment_name,
canonical_name,
alternative_params,
conversion_name
)
bingo_cache.add_experiment(exp, alts)
bingo_cache.store_if_dirty()
finally:
if got_lock:
# Release the lock
client.set(lock_key, False)
# We might have multiple experiments connected to this single canonical experiment name
# if it was started w/ multiple conversion possibilities.
experiments, alternative_lists = bingo_cache.experiments_and_alternatives_from_canonical_name(canonical_name)
if not experiments or not alternative_lists:
raise Exception("Could not find experiment or alternatives with experiment_name %s" % canonical_name)
returned_content = None
for i in range(len(experiments)):
experiment, alternatives = experiments[i], alternative_lists[i]
if not experiment.live:
# Experiment has ended. Short-circuit and use selected winner before user has had a chance to remove relevant ab_test code.
returned_content = experiment.short_circuit_content
else:
alternative = find_alternative_for_user(canonical_name, alternatives)
if experiment.name not in bingo_identity_cache.participating_tests:
bingo_identity_cache.participate_in(experiment.name)
alternative.increment_participants()
bingo_cache.update_alternative(alternative)
# It shouldn't matter which experiment's alternative content we send back --
# alternative N should be the same across all experiments w/ same canonical name.
returned_content = alternative.content
return returned_content
def bingo(param):
if type(param) == list:
# Bingo for all conversions in list
for experiment_name in param:
bingo(experiment_name)
return
else:
conversion_name = str(param)
canonical_name = None
bingo_cache = BingoCache.get()
# Bingo for all experiments associated with this conversion
for experiment_name in bingo_cache.get_experiment_names_by_conversion_name(conversion_name):
if not canonical_name:
experiment = bingo_cache.get_experiment(experiment_name)
canonical_name = experiment.canonical_name
score_conversion(experiment_name, canonical_name)
def score_conversion(experiment_name, canonical_name):
bingo_cache, bingo_identity_cache = bingo_and_identity_cache()
if experiment_name not in bingo_identity_cache.participating_tests:
return
if experiment_name in bingo_identity_cache.converted_tests:
return
experiment = bingo_cache.get_experiment(experiment_name)
if not experiment or not experiment.live:
# Don't count conversions for short-circuited experiments that are no longer live
return
alternative = find_alternative_for_user(canonical_name, bingo_cache.get_alternatives(experiment_name))
alternative.increment_conversions()
bingo_cache.update_alternative(alternative)
bingo_identity_cache.convert_in(experiment_name)
def choose_alternative(experiment_name, alternative_number):
bingo_cache = BingoCache.get()
experiment = bingo_cache.get_experiment(experiment_name)
# Need to end all experiments that may have been kicked off
# by an experiment with multiple conversions
experiments, alternative_lists = bingo_cache.experiments_and_alternatives_from_canonical_name(experiment.canonical_name)
if not experiments or not alternative_lists:
return
for i in range(len(experiments)):
experiment, alternatives = experiments[i], alternative_lists[i]
alternative_chosen = filter(lambda alternative: alternative.number == alternative_number , alternatives)
if len(alternative_chosen) == 1:
experiment.live = False
experiment.set_short_circuit_content(alternative_chosen[0].content)
bingo_cache.update_experiment(experiment)
def delete_experiment(experiment_name):
bingo_cache = BingoCache.get()
experiment = bingo_cache.get_experiment(experiment_name)
if experiment.live:
raise Exception("Cannot delete a live experiment")
bingo_cache.delete_experiment_and_alternatives(experiment)
def resume_experiment(experiment_name):
bingo_cache = BingoCache.get()
experiment = bingo_cache.get_experiment(experiment_name)
# Need to resume all experiments that may have been kicked off
# by an experiment with multiple conversions
experiments, alternative_lists = bingo_cache.experiments_and_alternatives_from_canonical_name(experiment.canonical_name)
if not experiments or not alternative_lists:
return
for i in range(len(experiments)):
experiment, alternatives = experiments[i], alternative_lists[i]
experiment.live = True
bingo_cache.update_experiment(experiment)
def find_alternative_for_user(experiment_name, alternatives):
if os.environ["SERVER_SOFTWARE"].startswith('Development'):
# If dev server, allow possible override of alternative
qs_dict = cgi.parse_qs(os.environ.get("QUERY_STRING") or "")
alternative_number_override = qs_dict.get("gae_bingo_alternative_number")
if alternative_number_override:
matches = filter(lambda alternative: alternative.number == int(alternative_number_override[0]), alternatives)
if len(matches) == 1:
return matches[0]
return modulo_choose(experiment_name, alternatives)
def modulo_choose(experiment_name, alternatives):
alternatives_weight = sum(map(lambda alternative: alternative.weight, alternatives))
sig = hashlib.md5(experiment_name + str(identity())).hexdigest()
sig_num = int(sig, base=16)
index_weight = sig_num % alternatives_weight
current_weight = alternatives_weight
for alternative in sorted(alternatives, key=lambda alternative: alternative.weight, reverse=True):
current_weight -= alternative.weight
if index_weight >= current_weight:
return alternative