experimental 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,15 +1,7 @@
1
1
  ---
2
- !binary "U0hBMQ==":
3
- metadata.gz: !binary |-
4
- Zjc0ZjhjZGU1OTRjYzA0YjY2MjM2NDVhOTQ2NjY1MGJjNjFiZWUyYg==
5
- data.tar.gz: !binary |-
6
- N2Q1ZDM5OTk4NWYyMzNmYjY5OWNlOWVjNzFlMTUxYzZjMjRlZTUyNg==
7
- !binary "U0hBNTEy":
8
- metadata.gz: !binary |-
9
- ZGVmMGFjYWNkZWExMGJmNWRiNmVmMjRiZWQzMTUyNGFhZGRmNThiODI2MTg3
10
- NTQzMjI4NzBmZGJkNmFlNDI5YTM2MThmMmJkY2JiZWVkODBhZTcyZmM0OWVm
11
- NzQzNGUwOGMwMGVjZjhiM2QzM2NhNmQxYTAyMDJhNTI3NzIyNjE=
12
- data.tar.gz: !binary |-
13
- N2Q3ZjM1OWRjN2NiMzA4ZWExYWU5ODY0ZDcxMjA5NTgzOWI4OWE2OWIzNmJk
14
- MjM1Njk1NTk4Mjc1NWRmNzM4N2YwM2M1YjViZmRlODFlMDIxOWRkM2I1NmU3
15
- MWNiODNhMmEzOGZhMDBkM2JlMDY1MjRkN2Q5MTNjYWEyZDQ1ZmU=
2
+ SHA1:
3
+ metadata.gz: 1c78c9b7d6c365503d9c865f63b3e24f7abbdec5
4
+ data.tar.gz: adc2a47c070863a3a87432d4eca968614155d3a9
5
+ SHA512:
6
+ metadata.gz: 9eee13366381e3d96a44abc8da8b003c405850a140f96091ec7d8410dad321d3501b4ac3577976217d5a46b279b6cdbc7704cd219dedf6ab1633a05431b480b8
7
+ data.tar.gz: 755c66a9193290985db2a6852a68c01edd3780fc94672d9691743fc6b02b3407051242ef30c8882b6753e98184e1f9a94850913976b878ba7a3b6cd76541b36d
data/README.markdown CHANGED
@@ -12,135 +12,134 @@ be removed make the site explode
12
12
 
13
13
  ## Installation
14
14
 
15
- ```rails g experimental```
15
+ `rails g experimental`
16
16
 
17
17
  ### Routes
18
18
 
19
- ````
20
- resources :experiments, :only => [:index, :new, :create] do
19
+ ```ruby
20
+ resources :experiments, only: [:index, :new, :create] do
21
+ collection do
22
+ get :inactive
23
+ post :set_winner
24
+ end
25
+ end
26
+
27
+ namespace :singles_admin do
28
+ resources :experiments, only: [:index, :new, :create] do
21
29
  collection do
22
30
  get :inactive
23
31
  post :set_winner
24
32
  end
25
33
  end
26
- ````
27
-
28
- ````
29
- namespace :singles_admin do
30
- resources :experiments, :only => [:index, :new, :create] do
31
- collection do
32
- get :inactive
33
- post :set_winner
34
- end
35
- end
36
- end
37
- ````
34
+ end
35
+ end
36
+ ```
38
37
 
39
38
  ### Admin Frontend
40
39
 
41
40
  #### Create your own admin controller:
42
- ````
43
- class Admin::ExperimentsController < ApplicationController
44
- include Experimental::ControllerActions
45
-
46
- alias_method :index, :experiments_index
47
- alias_method :new, :experiments_new
48
- alias_method :set_winner, :experiments_set_winner
49
-
50
- def create
51
- if experiments_create
52
- redirect_to admin_experiments_path
53
- else
54
- render :new
55
- end
56
- end
57
-
58
- def base_resource_name
59
- "singles_admin_experiment"
60
- end
61
-
41
+ ```ruby
42
+ class Admin::ExperimentsController < ApplicationController
43
+ include Experimental::ControllerActions
44
+
45
+ alias_method :index, :experiments_index
46
+ alias_method :new, :experiments_new
47
+ alias_method :set_winner, :experiments_set_winner
48
+
49
+ def create
50
+ if experiments_create
51
+ redirect_to admin_experiments_path
52
+ else
53
+ render :new
62
54
  end
63
- ````
64
-
65
- #### Using ActiveAdmin:
66
-
67
- ``` rails g active_admin:resource Experiment```
68
-
69
- ````
70
- require 'experimental/controller_actions'
55
+ end
71
56
 
72
- ActiveAdmin.register Experimental::Experiment, as: "Experiment" do
73
- actions :index, :new, :create
74
- filter :name
75
-
76
- controller do
77
- class_eval do
78
- include Experimental::ControllerActions
79
- end
80
-
81
- def base_resource_name
82
- "admin_experiment"
83
- end
57
+ def base_resource_name
58
+ "singles_admin_experiment"
59
+ end
60
+ end
61
+ ```
84
62
 
85
- def create
86
- if experiments_create
87
- redirect_to admin_experiments_path
88
- else
89
- render :new
90
- end
91
- end
63
+ #### Using ActiveAdmin:
92
64
 
93
- def new
94
- experiments_new
95
- end
96
- end
65
+ `rails g active_admin:resource Experiment`
97
66
 
98
- # collection_actions force active_admin to create a route
99
- collection_action :set_winner, method: :post do
100
- experiments_set_winner
101
- end
67
+ ```ruby
68
+ require 'experimental/controller_actions'
102
69
 
103
- # can do this instead of the ended_or_removed scope below
104
- # you will need to add a link to inactive_admins_experiments_path
105
- # in your view
106
- collection_action :inactive do
107
- experiments_inactive
108
- render template: 'admin/experiments/index'
109
- end
70
+ ActiveAdmin.register Experimental::Experiment, as: "Experiment" do
71
+ actions :index, :new, :create
72
+ filter :name
110
73
 
111
- scope :in_progress, :default => true do |experiments|
112
- experiments.in_progress
113
- end
74
+ controller do
75
+ class_eval do
76
+ include Experimental::ControllerActions
77
+ end
114
78
 
115
- scope :ended_or_removed do |experiments|
116
- @include_inactive = true
117
- experiments.ended_or_removed
118
- end
79
+ def base_resource_name
80
+ "admin_experiment"
81
+ end
119
82
 
120
- index do
121
- render template: 'admin/experiments/index'
83
+ def create
84
+ if experiments_create
85
+ redirect_to admin_experiments_path
86
+ else
87
+ render :new
122
88
  end
123
-
124
- form :partial => 'new'
125
89
  end
126
90
 
127
- ````
91
+ def new
92
+ experiments_new
93
+ end
94
+ end
95
+
96
+ # collection_actions force active_admin to create a route
97
+ collection_action :set_winner, method: :post do
98
+ experiments_set_winner
99
+ end
100
+
101
+ # can do this instead of the ended_or_removed scope below
102
+ # you will need to add a link to inactive_admins_experiments_path
103
+ # in your view
104
+ collection_action :inactive do
105
+ experiments_inactive
106
+ render template: 'admin/experiments/index'
107
+ end
108
+
109
+ scope :in_progress, :default => true do |experiments|
110
+ experiments.in_progress
111
+ end
112
+
113
+ scope :ended_or_removed do |experiments|
114
+ @include_inactive = true
115
+ experiments.ended_or_removed
116
+ end
117
+
118
+ index do
119
+ render template: 'admin/experiments/index'
120
+ end
121
+
122
+ form partial: 'new'
123
+ end
124
+ ```
128
125
 
129
126
  #### Views
130
127
 
131
128
  create an index and new view in appropriate view folder, i.e.
132
129
 
133
- ``` app/views/admin/experiments/index.html.erb ```
134
- ````
135
- <%= render partial: 'experimental/links' %>
136
- <%= render partial: 'experimental/index' %>
137
- ````
130
+ `app/views/admin/experiments/index.html.erb`
138
131
 
139
- ``` app/views/admin/experiments/new.html.erb ```
140
- ````
141
- <%= render partial: 'experimental/links' %>
142
- <%= render partial: 'experimental/new' %>
143
- ````
132
+ ```erb
133
+ <%= render partial: 'experimental/links' %>
134
+ <%= render partial: 'experimental/index' %>
135
+ ```
136
+
137
+ `app/views/admin/experiments/new.html.erb`
138
+
139
+ ```erb
140
+ <%= render partial: 'experimental/links' %>
141
+ <%= render partial: 'experimental/new' %>
142
+ ```
144
143
 
145
144
  *Note: ActiveAdmin users will not need to include the links
146
145
  partials*
@@ -149,20 +148,20 @@ create an index and new view in appropriate view folder, i.e.
149
148
 
150
149
  For the class you'd like to be the subject of experiments, include the
151
150
  Experimental::Subject module in a model with an id and timestamps
152
- ````
153
- class User < ActiveRecord::Base
154
- include Experimental::Subject
155
- ...
156
- ````
157
-
151
+ ```ruby
152
+ class User < ActiveRecord::Base
153
+ include Experimental::Subject
154
+ # ...
155
+ end
156
+ ```
158
157
 
159
158
  ## Usage
160
159
 
161
160
  ### Create an experiment
162
161
 
163
- In `config/experiments.yml`, add the name, num_buckets, and notes of the
162
+ In `config/experimental.yml`, add the name, num_buckets, and notes of the
164
163
  experiment under in_code:
165
- ````
164
+ ```yaml
166
165
  in_code:
167
166
  -
168
167
  name: :price_experiment
@@ -171,22 +170,22 @@ in_code:
171
170
  0: $22
172
171
  1: $19.99
173
172
 
174
- ````
175
- Then run `rake experiments:sync`
173
+ ```
174
+ Then run `rake experimental:sync`
176
175
 
177
176
  ### Using the experiment
178
177
 
179
178
  To see if a user is in the experiment population AND in a bucket:
180
- ````
181
- #checks if the user is in the my_experiment population
182
- # and if they are in bucket 0
183
- user.in_bucket?(:my_experiment, 0)
184
- ````
179
+ ```ruby
180
+ # checks if the user is in the my_experiment population
181
+ # and if they are in bucket 0
182
+ user.in_bucket?(:my_experiment, 0)
183
+ ```
185
184
 
186
185
  To see if a user is in the experiment population **ONLY**
187
- ````
188
- user.in_experiment?(:my_experiment)
189
- ````
186
+ ```ruby
187
+ user.in_experiment?(:my_experiment)
188
+ ```
190
189
 
191
190
  ### Ending an experiment
192
191
 
@@ -203,128 +202,124 @@ A removed experiment is an experiment that is not referenced
203
202
  anywhere in code. In fact, the framework will throw an exception
204
203
  if you reference an experiment that is not in code.
205
204
 
206
- Moving an experiment from the in_code to the removed section of
207
- `config/experiments.yml` and running `rake experiments:sync` will
208
- remove the experiment and expire the cache.
205
+ Removing an experiment from `config/experimental.yml` and running `rake
206
+ experimental:sync` will remove the experiment and expire the cache.
209
207
 
210
- ````
208
+ ```yaml
211
209
  removed:
212
210
  -
213
211
  name: :price_experiment
214
- ````
215
- Then run `rake experiments:sync`
216
-
217
-
212
+ ```
213
+ Then run `rake experimental:sync`
218
214
 
219
215
  ## Testing
220
216
 
221
217
  ### Setup
222
- in ```spec_helper.rb``` (after inclusion of ActiveSupport)
218
+ in `spec_helper.rb` (after inclusion of ActiveSupport)
223
219
 
224
- ````
225
- require 'experimental/rspec_helpers'
226
- ````
220
+ ```ruby
221
+ require 'experimental/rspec_helpers'
222
+ ```
227
223
 
228
224
  *You may want to force experiments off for all tests by default*
229
- ````
230
- config.before(:each) do
231
- User.any_instance.stub(:in_experiment?).and_return(false)
232
- end
233
- ````
225
+ ```ruby
226
+ config.before(:each) do
227
+ User.any_instance.stub(:in_experiment?).and_return(false)
228
+ end
229
+ ```
234
230
 
235
231
  ### Testing experiments
236
232
 
237
233
  Include the Rspec helpers in your spec class or spec_helper
238
- ````
239
- include Experimental::RspecHelpers
240
- ````
234
+ ```ruby
235
+ include Experimental::RspecHelpers
236
+ ```
241
237
 
242
238
  Shared contexts are available for in_experiment? and in_bucket?
243
- ````
244
- include_context "in experiment"
245
- include_context "not in experiment"
246
-
247
- include_context "in experiment bucket 0"
248
- include_context "in experiment bucket 1"
239
+ ```ruby
240
+ include_context "in experiment"
241
+ include_context "not in experiment"
242
+
243
+ include_context "in experiment bucket 0"
244
+ include_context "in experiment bucket 1"
249
245
  ```
250
246
 
251
247
  Helper methods are also available:
252
248
 
253
249
  **is_in_experiment**
254
- ````
255
- # first param is true for in experiment, false for not in
256
- experiment
257
- # second param is the experiment name
258
- # third param is the subject object
259
- is_in_experiment(true, :my_experiment, my_subject)
260
-
261
- # if user and experiment_name are defined, you can do
262
- let(:experiment_name) { :my_experiment }
263
- let(:user) { User.new }
264
- is_in_experiment # true if in experiment
265
- is_in_experiment(false) # true if NOT in experiment
266
- ````
250
+ ```ruby
251
+ # first param is true for in experiment, false for not in experiment
252
+ # second param is the experiment name
253
+ # third param is the subject object
254
+ is_in_experiment(true, :my_experiment, my_subject)
255
+
256
+ # if user and experiment_name are defined, you can do
257
+ let(:experiment_name) { :my_experiment }
258
+ let(:user) { User.new }
259
+ is_in_experiment # true if in experiment
260
+ is_in_experiment(false) # true if NOT in experiment
261
+ ```
267
262
 
268
263
  **is_not_in_experiment**
269
- ````
270
- # first param is name of experiment
271
- # second param is subject object
272
- is_not_in_experiment(:my_experiment, my_subject)
273
-
274
- # if user and experiment_name are defined, you can do
275
- let(:experiment_name) { :my_experiment }
276
- let(:user) { User.new }
277
- is_not_in_experiment
278
- ````
264
+ ```ruby
265
+ # first param is name of experiment
266
+ # second param is subject object
267
+ is_not_in_experiment(:my_experiment, my_subject)
268
+
269
+ # if user and experiment_name are defined, you can do
270
+ let(:experiment_name) { :my_experiment }
271
+ let(:user) { User.new }
272
+ is_not_in_experiment
273
+ ```
279
274
 
280
275
  **has_experiment_bucket**
281
- ````
282
- has_experiment_bucket(1, :my_experiment, my_subject)
283
-
284
- # if user and experiment_name are defined, you can do
285
- let(:experiment_name) { :my_experiment }
286
- let(:user) { User.new }
287
- has_experiment_bucket(1)
288
-
289
- ````
290
-
276
+ ```ruby
277
+ has_experiment_bucket(1, :my_experiment, my_subject)
291
278
 
279
+ # if user and experiment_name are defined, you can do
280
+ let(:experiment_name) { :my_experiment }
281
+ let(:user) { User.new }
282
+ has_experiment_bucket(1)
283
+ ```
292
284
 
293
285
  ## Developer Workflow
294
286
 
295
- Experiments *can* be defined in config/experiments.yml
296
- Running the rake task `rake experiments:sync` will load those
287
+ Experiments *can* be defined in `config/experimental.yml`
288
+ Running the rake task `rake experimental:sync` will load those
297
289
  experiments under 'in_code' into the database and set removed_at
298
290
  timestamp for those under 'removed'
299
291
 
300
292
  You will likely want to automate the running of `rake
301
- experiments:sync` by adding to your deploy file.
293
+ experimental:sync` by adding to your deploy file.
302
294
 
303
295
  ### Capistrano
304
296
  In `config/deploy.rb`:
305
297
 
306
298
  Create a namespace to run the task:
307
- ````
308
- namespace :database do
309
- desc "Sync experiments"
310
- task :sync_from_app, roles: :db, only: { primary: true } do
311
- run "cd #{current_path} && RAILS_ENV=#{rails_env} bundle exec rake experiments:sync"
312
- end
313
- end
314
- ````
299
+ ```ruby
300
+ namespace :database do
301
+ desc "Sync experiments"
302
+ task :sync_from_app, roles: :db, only: { primary: true } do
303
+ run "cd #{current_path} && RAILS_ENV=#{rails_env} bundle exec rake experimental:sync"
304
+ end
305
+ end
306
+ ```
315
307
 
316
308
  Include that in the deploy:default task:
317
- ````
318
- namespace :deploy do
319
- ...
320
- task :default do
321
- begin
322
- update_code
323
- migrate
324
- database.sync_from_app
325
- restart
326
- ...
327
- ````
309
+ ```ruby
310
+ namespace :deploy do
311
+ #...
312
+ task :default do
313
+ begin
314
+ update_code
315
+ migrate
316
+ database.sync_from_app
317
+ restart
318
+ #...
319
+ end
320
+ end
321
+ end
322
+ ```
328
323
 
329
324
  ### Admin created experiments
330
325
 
@@ -332,6 +327,5 @@ The purpose of Admin created experiments are for experiments
332
327
  that will flow through to another system, such as an email provider.
333
328
  They likely start with a known string and are dynamically sent in
334
329
  code.
335
- Otherwise, Admin created experiments will do nothing as their is no
330
+ Otherwise, Admin created experiments will do nothing as there is no
336
331
  code attached to them.
337
-
@@ -2,10 +2,7 @@ module Experimental
2
2
  class Experiment < ActiveRecord::Base
3
3
  extend Population::Filter
4
4
 
5
- cattr_accessor :use_cache
6
- @@use_cache = true
7
-
8
- attr_accessible :name, :num_buckets, :notes
5
+ attr_accessible :name, :num_buckets, :notes, :population
9
6
 
10
7
  validates_presence_of :name, :num_buckets
11
8
  validates_numericality_of :num_buckets, :greater_than_or_equal_to => 2
@@ -14,8 +11,6 @@ module Experimental
14
11
  :less_than => :num_buckets,
15
12
  :if => :ended?
16
13
 
17
- after_create :expire_cache
18
-
19
14
  def self.in_code
20
15
  where(:removed_at => nil)
21
16
  end
@@ -37,38 +32,43 @@ module Experimental
37
32
  end
38
33
 
39
34
  def self.[](experiment_name)
40
- if use_cache
41
- Cache.get(experiment_name)
42
- else
43
- find_by_name(experiment_name.to_s)
44
- end
45
- end
46
-
47
- def self.expire_cache
48
- Cache.expire_last_updated if use_cache
49
- end
50
-
51
- def expire_cache
52
- Experiment.expire_cache
35
+ Experimental.source[experiment_name.to_s]
53
36
  end
54
37
 
55
38
  def bucket(subject)
56
- (ended? || removed?) ? winning_bucket : bucket_number(subject)
39
+ if ended? || removed?
40
+ winning_bucket
41
+ elsif Experimental.overrides.include?(subject, name)
42
+ Experimental.overrides[subject, name]
43
+ else
44
+ bucket_number(subject)
45
+ end
57
46
  end
58
47
 
59
48
  def in?(subject)
60
- return false if removed?
61
- population_filter.in?(subject, self)
49
+ if removed?
50
+ false
51
+ elsif Experimental.overrides.include?(subject, name)
52
+ !!Experimental.overrides[subject, name]
53
+ else
54
+ population_filter.in?(subject, self)
55
+ end
62
56
  end
63
57
 
64
58
  def end(winning_num)
65
59
  self.winning_bucket = winning_num
66
60
  self.end_date = Time.now
67
- result = save
61
+ save
62
+ end
68
63
 
69
- Experiment.expire_cache if result
64
+ def restart
65
+ return unless ended?
70
66
 
71
- result
67
+ self.winning_bucket = nil
68
+ self.start_date = Time.now
69
+ self.end_date = nil
70
+
71
+ save
72
72
  end
73
73
 
74
74
  def remove
@@ -78,7 +78,6 @@ module Experimental
78
78
  result = update_attributes(
79
79
  { removed_at: Time.now }, without_protection: true
80
80
  )
81
- expire_cache if result
82
81
  end
83
82
 
84
83
  result
@@ -96,6 +95,10 @@ module Experimental
96
95
  !removed? && !ended?
97
96
  end
98
97
 
98
+ def self.active
99
+ where(['removed_at IS NULL AND (end_date IS NULL OR ? <= end_date)', Time.now])
100
+ end
101
+
99
102
  def to_sql_formula(subject_table = "users")
100
103
  "CONV(SUBSTR(SHA1(CONCAT(\"#{name}\",#{subject_table}.id)),1,8),16,10) % #{num_buckets}"
101
104
  end
@@ -1,7 +1,7 @@
1
1
  module Experimental
2
2
  module Subject
3
3
  def in_experiment?(name)
4
- Experiment[name].try { |e| e.in?(self) }
4
+ Experimental.source[name].try { |e| e.in?(self) }
5
5
  end
6
6
 
7
7
  def not_in_experiment?(name)
@@ -9,7 +9,7 @@ module Experimental
9
9
  end
10
10
 
11
11
  def experiment_bucket(name)
12
- Experiment[name].try { |e| e.bucket(self) }
12
+ Experimental.source[name].try { |e| e.in?(self) ? e.bucket(self) : nil }
13
13
  end
14
14
 
15
15
  def in_bucket?(name, bucket)
@@ -0,0 +1,34 @@
1
+ require 'logger'
2
+
3
+ module Experimental
4
+ class Loader
5
+ def initialize(options = {})
6
+ @logger = options[:logger] || Logger.new('/dev/null')
7
+ end
8
+
9
+ attr_reader :logger
10
+
11
+ def sync
12
+ logger.info "Synchronizing experiments..."
13
+
14
+ Experimental::Experiment.transaction do
15
+ active = Experimental.experiment_data.map do |name, attributes|
16
+ experiment = Experimental::Experiment.find_or_initialize_by_name(name)
17
+ logger.info " * #{experiment.id ? 'updating' : 'creating'} #{name}"
18
+ experiment.assign_attributes(attributes)
19
+ experiment.start_date ||= Time.now
20
+ experiment.tap(&:save!)
21
+ end
22
+
23
+ scope = Experimental::Experiment.in_code
24
+ scope = scope.where('id NOT IN (?)', active.map(&:id)) unless active.empty?
25
+ scope.find_each do |experiment|
26
+ logger.info " * removing #{experiment.name}"
27
+ experiment.remove
28
+ end
29
+ end
30
+
31
+ logger.info "Done."
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ module Experimental
2
+ class Overrides
3
+ def initialize
4
+ @overrides ||= Hash.new do |experiment_overrides, experiment_name|
5
+ experiment_overrides[experiment_name] = {}
6
+ end
7
+ end
8
+
9
+ def include?(subject, experiment_name)
10
+ experiment_name = experiment_name.to_s
11
+ @overrides.key?(experiment_name) && @overrides[experiment_name].key?(subject)
12
+ end
13
+
14
+ def [](subject, experiment_name)
15
+ experiment_name = experiment_name.to_s
16
+ @overrides[experiment_name][subject]
17
+ end
18
+
19
+ def []=(subject, experiment_name, bucket)
20
+ experiment_name = experiment_name.to_s
21
+ @overrides[experiment_name][subject] = bucket
22
+ end
23
+
24
+ def reset
25
+ @overrides.clear
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ module Experimental
2
+ class Railtie < Rails::Railtie
3
+ initializer "experimental.initialize" do
4
+ config_path = "#{Rails.root}/config/experimental.yml"
5
+ if File.exist?(config_path)
6
+ full = YAML.load_file(config_path)
7
+ configuration = full[Rails.env] || {}
8
+ configuration.update(full.slice('experiments'))
9
+ Experimental.configure(configuration)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module Experimental
2
+ module Source
3
+ class ActiveRecord < Base
4
+ def [](name)
5
+ Experiment.find_by_name(name)
6
+ end
7
+
8
+ def active
9
+ Experiment.active.all
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ module Experimental
2
+ module Source
3
+ class Base
4
+ # Return the experiment with the given name (Symbol or String)
5
+ def [](name)
6
+ raise NotImplementedError, 'abstract'
7
+ end
8
+
9
+ # Return all active experiments.
10
+ def active
11
+ raise NotImplementedError, 'abstract'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,45 @@
1
+ module Experimental
2
+ module Source
3
+ class Cache < Base
4
+ # A cache source provides in memory caching around another source.
5
+ #
6
+ # If a +:ttl+ option is passed, experiments will only be cached for that
7
+ # many seconds, otherwise it is cached forever.
8
+ def initialize(source, options = {})
9
+ @source = source
10
+ @ttl = options[:ttl]
11
+ @last_update = nil
12
+ @cache = {}
13
+ end
14
+
15
+ attr_reader :source, :ttl
16
+
17
+ def [](name)
18
+ refresh if dirty?
19
+ cache[name.to_s]
20
+ end
21
+
22
+ def active
23
+ refresh if dirty?
24
+ cache.values
25
+ end
26
+
27
+ private
28
+
29
+ attr_accessor :cache, :last_update
30
+
31
+ def dirty?
32
+ return true if last_update.nil?
33
+ ttl ? (Time.now.to_f - last_update > ttl) : false
34
+ end
35
+
36
+ def refresh
37
+ cache.clear
38
+ source.active.each do |experiment|
39
+ cache[experiment.name] = experiment
40
+ end
41
+ self.last_update = Time.now.to_f
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ module Experimental
2
+ module Source
3
+ class Configuration < Base
4
+ def initialize
5
+ @experiments = {}
6
+ Experimental.experiment_data.each do |name, attributes|
7
+ experiment = Experiment.new(attributes) { |e| e.name = name }
8
+ @experiments[experiment.name] = experiment
9
+ end
10
+ end
11
+
12
+ def [](name)
13
+ @experiments[name.to_s]
14
+ end
15
+
16
+ def active
17
+ @experiments.values
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ module Experimental
2
+ module Source
3
+ autoload :ActiveRecord, 'experimental/source/active_record'
4
+ autoload :Base, 'experimental/source/base'
5
+ autoload :Cache, 'experimental/source/cache'
6
+ autoload :Configuration, 'experimental/source/configuration'
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ module Experimental
2
+ module Test
3
+ def self.included(base)
4
+ base.before do
5
+ Experimental.source = Experimental::Source::Configuration.new
6
+ Experimental.overrides.reset
7
+ end
8
+
9
+ base.after do
10
+ Experimental.overrides.reset
11
+ end
12
+ end
13
+
14
+ # Force the given subject into the given +bucket+ of the given +experiment+.
15
+ #
16
+ # If +bucket+ is nil, exclude the user from the experiment.
17
+ def set_experimental_bucket(subject, experiment_name, bucket)
18
+ Experimental.overrides[subject, experiment_name] = bucket
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  module Experimental
2
- VERSION = [0, 1, 0]
2
+ VERSION = [0, 2, 0]
3
3
 
4
4
  class << VERSION
5
5
  include Comparable
data/lib/experimental.rb CHANGED
@@ -4,8 +4,51 @@ require 'experimental/engine'
4
4
  module Experimental
5
5
  autoload :VERSION, 'experimental/version'
6
6
  autoload :ControllerActions, 'experimental/controller_actions'
7
+ autoload :Loader, 'experimental/loader'
8
+ autoload :Overrides, 'experimental/overrides'
9
+ autoload :Source, 'experimental/source'
10
+ autoload :Test, 'experimental/test'
7
11
 
8
- def self.register_population_filter(name, filter_class)
9
- Experiment.register_population_filter(name, filter_class)
12
+ class << self
13
+ def configure(configuration)
14
+ source = Source::ActiveRecord.new
15
+ if (ttl = configuration['cache_for'])
16
+ source = Source::Cache.new(source, ttl: ttl)
17
+ end
18
+ self.experiment_data = configuration['experiments']
19
+ self.source = source
20
+ end
21
+
22
+ def register_population_filter(name, filter_class)
23
+ Experiment.register_population_filter(name, filter_class)
24
+ end
25
+
26
+ def source=(source)
27
+ @experimental_source = source
28
+ end
29
+
30
+ def source
31
+ @experimental_source ||= Source::ActiveRecord.new
32
+ end
33
+
34
+ def experiment_data=(data)
35
+ @experimental_data = data
36
+ end
37
+
38
+ def experiment_data
39
+ @experimental_data ||= {}
40
+ end
41
+
42
+ def reset
43
+ self.source = nil
44
+ self.experiment_data = nil
45
+ Experiment.reset_population_filters
46
+ end
47
+ end
48
+
49
+ def self.overrides
50
+ Thread.current[:experimental_overrides] ||= Overrides.new
10
51
  end
11
52
  end
53
+
54
+ require 'experimental/railtie' if defined?(Rails)
@@ -1,6 +1,7 @@
1
- namespace :experiments do
1
+ namespace :experimental do
2
2
  desc "sync experiments from config/experiment.yml into the database"
3
3
  task :sync => :environment do
4
- Experimental::Experiment::Loader.sync(true)
4
+ logger = Logger.new(STDOUT)
5
+ Experimental::Loader.new(logger: logger).sync
5
6
  end
6
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: experimental
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - HowAboutWe.com
@@ -10,104 +10,118 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2013-05-17 00:00:00.000000000 Z
13
+ date: 2013-10-22 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: rails
17
17
  requirement: !ruby/object:Gem::Requirement
18
18
  requirements:
19
- - - ~>
19
+ - - <=
20
20
  - !ruby/object:Gem::Version
21
- version: 3.2.6
21
+ version: '5.0'
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
25
25
  requirements:
26
- - - ~>
26
+ - - <=
27
27
  - !ruby/object:Gem::Version
28
- version: 3.2.6
28
+ version: '5.0'
29
29
  - !ruby/object:Gem::Dependency
30
30
  name: jquery-rails
31
31
  requirement: !ruby/object:Gem::Requirement
32
32
  requirements:
33
- - - ! '>='
33
+ - - '>='
34
34
  - !ruby/object:Gem::Version
35
35
  version: '0'
36
36
  type: :runtime
37
37
  prerelease: false
38
38
  version_requirements: !ruby/object:Gem::Requirement
39
39
  requirements:
40
- - - ! '>='
40
+ - - '>='
41
41
  - !ruby/object:Gem::Version
42
42
  version: '0'
43
43
  - !ruby/object:Gem::Dependency
44
44
  name: sqlite3
45
45
  requirement: !ruby/object:Gem::Requirement
46
46
  requirements:
47
- - - ! '>='
47
+ - - '>='
48
48
  - !ruby/object:Gem::Version
49
49
  version: '0'
50
50
  type: :development
51
51
  prerelease: false
52
52
  version_requirements: !ruby/object:Gem::Requirement
53
53
  requirements:
54
- - - ! '>='
54
+ - - '>='
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ - !ruby/object:Gem::Dependency
58
+ name: timecop
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - '>='
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - '>='
55
69
  - !ruby/object:Gem::Version
56
70
  version: '0'
57
71
  - !ruby/object:Gem::Dependency
58
72
  name: rspec-rails
59
73
  requirement: !ruby/object:Gem::Requirement
60
74
  requirements:
61
- - - ! '>='
75
+ - - '>='
62
76
  - !ruby/object:Gem::Version
63
77
  version: '0'
64
78
  type: :development
65
79
  prerelease: false
66
80
  version_requirements: !ruby/object:Gem::Requirement
67
81
  requirements:
68
- - - ! '>='
82
+ - - '>='
69
83
  - !ruby/object:Gem::Version
70
84
  version: '0'
71
85
  - !ruby/object:Gem::Dependency
72
86
  name: activeadmin
73
87
  requirement: !ruby/object:Gem::Requirement
74
88
  requirements:
75
- - - ! '>='
89
+ - - '>='
76
90
  - !ruby/object:Gem::Version
77
91
  version: '0'
78
92
  type: :development
79
93
  prerelease: false
80
94
  version_requirements: !ruby/object:Gem::Requirement
81
95
  requirements:
82
- - - ! '>='
96
+ - - '>='
83
97
  - !ruby/object:Gem::Version
84
98
  version: '0'
85
99
  - !ruby/object:Gem::Dependency
86
100
  name: sass-rails
87
101
  requirement: !ruby/object:Gem::Requirement
88
102
  requirements:
89
- - - ! '>='
103
+ - - '>='
90
104
  - !ruby/object:Gem::Version
91
105
  version: '0'
92
106
  type: :development
93
107
  prerelease: false
94
108
  version_requirements: !ruby/object:Gem::Requirement
95
109
  requirements:
96
- - - ! '>='
110
+ - - '>='
97
111
  - !ruby/object:Gem::Version
98
112
  version: '0'
99
113
  - !ruby/object:Gem::Dependency
100
114
  name: coffee-rails
101
115
  requirement: !ruby/object:Gem::Requirement
102
116
  requirements:
103
- - - ! '>='
117
+ - - '>='
104
118
  - !ruby/object:Gem::Version
105
119
  version: '0'
106
120
  type: :development
107
121
  prerelease: false
108
122
  version_requirements: !ruby/object:Gem::Requirement
109
123
  requirements:
110
- - - ! '>='
124
+ - - '>='
111
125
  - !ruby/object:Gem::Version
112
126
  version: '0'
113
127
  description: AB Test framework for Rails
@@ -117,9 +131,7 @@ executables: []
117
131
  extensions: []
118
132
  extra_rdoc_files: []
119
133
  files:
120
- - app/models/experimental/cache.rb
121
134
  - app/models/experimental/experiment.rb
122
- - app/models/experimental/loader.rb
123
135
  - app/models/experimental/population/default.rb
124
136
  - app/models/experimental/population/filter.rb
125
137
  - app/models/experimental/population/new_users.rb
@@ -131,18 +143,27 @@ files:
131
143
  - config/routes.rb
132
144
  - lib/experimental/controller_actions.rb
133
145
  - lib/experimental/engine.rb
146
+ - lib/experimental/loader.rb
147
+ - lib/experimental/overrides.rb
148
+ - lib/experimental/railtie.rb
134
149
  - lib/experimental/rspec_helpers.rb
150
+ - lib/experimental/source/active_record.rb
151
+ - lib/experimental/source/base.rb
152
+ - lib/experimental/source/cache.rb
153
+ - lib/experimental/source/configuration.rb
154
+ - lib/experimental/source.rb
155
+ - lib/experimental/test.rb
135
156
  - lib/experimental/version.rb
136
157
  - lib/experimental.rb
137
158
  - lib/generators/experimental/experimental_generator.rb
138
159
  - lib/generators/experimental/templates/create_experiments_table.rb
139
160
  - lib/generators/experimental/templates/experimental.rb
140
161
  - lib/generators/experimental/templates/experiments.yml
141
- - lib/tasks/experiment.rake
162
+ - lib/tasks/experimental.rake
142
163
  - MIT-LICENSE
143
164
  - Rakefile
144
165
  - README.markdown
145
- homepage: http://wwww.github.com/howaboutwe/experimental
166
+ homepage: https://github.com/howaboutwe/experimental
146
167
  licenses:
147
168
  - MIT
148
169
  metadata: {}
@@ -152,17 +173,17 @@ require_paths:
152
173
  - lib
153
174
  required_ruby_version: !ruby/object:Gem::Requirement
154
175
  requirements:
155
- - - ! '>='
176
+ - - '>='
156
177
  - !ruby/object:Gem::Version
157
178
  version: '0'
158
179
  required_rubygems_version: !ruby/object:Gem::Requirement
159
180
  requirements:
160
- - - ! '>='
181
+ - - '>='
161
182
  - !ruby/object:Gem::Version
162
183
  version: '0'
163
184
  requirements: []
164
185
  rubyforge_project:
165
- rubygems_version: 2.0.3
186
+ rubygems_version: 2.1.9
166
187
  signing_key:
167
188
  specification_version: 4
168
189
  summary: Adds support for database-backed AB tests in Rails apps
@@ -1,69 +0,0 @@
1
- module Experimental
2
- class Cache
3
- cattr_accessor :interval
4
- cattr_reader :last_check
5
- cattr_reader :last_update
6
- cattr_reader :experiments
7
- cattr_accessor :cache_race_condition_ttl
8
- @@cache_race_condition_ttl = 10
9
- cattr_accessor :cache_key
10
-
11
- class << self
12
- def get(name)
13
- unless within_interval?
14
- if need_update?(last_cached_update)
15
- @@experiments = experiments_to_hash(Experiment.in_code)
16
- @@last_update = Time.now
17
- end
18
- end
19
- experiments[name.to_sym]
20
- end
21
-
22
- def [](name)
23
- get(name)
24
- end
25
-
26
- def interval
27
- @@interval ||= 5.minutes
28
- end
29
-
30
- def last_check
31
- @@last_check ||=
32
- Time.now - interval - 1.minute
33
- end
34
-
35
- def last_update
36
- @@last_update ||= last_cached_update
37
- end
38
-
39
- def cache_key
40
- @@cache_key ||= "experiments_last_update"
41
- end
42
-
43
- def within_interval?
44
- (Time.now - last_check) < interval
45
- end
46
-
47
- def need_update?(last_cached_update_time)
48
- experiments.nil? || last_update < last_cached_update_time
49
- end
50
-
51
- def experiments_to_hash(experiments)
52
- HashWithIndifferentAccess.new.tap do |h|
53
- experiments.each { |e| h[e.name.to_sym] = e }
54
- end
55
- end
56
-
57
- def last_cached_update
58
- # setting a default to 1 minute ago will force all servers to update
59
- Rails.cache.fetch(cache_key, :race_condition_ttl => cache_race_condition_ttl ) do
60
- Experiment.last_updated_at || 1.minute.ago
61
- end
62
- end
63
-
64
- def expire_last_updated
65
- Rails.cache.delete(cache_key)
66
- end
67
- end
68
- end
69
- end
@@ -1,64 +0,0 @@
1
- module Experimental
2
- class Loader
3
- cattr_accessor :file_path
4
- @@file_path = 'config/experiments.yml'
5
-
6
- @@whitelisted_attributes = [:name, :num_buckets, :notes, :population]
7
-
8
- class << self
9
- def sync(verbose = false)
10
- puts "Loading experiments ..." if verbose
11
- experiments = load_experiments
12
-
13
- #new/active
14
- experiments.in_code.each do |exp|
15
- name = exp.with_indifferent_access[:name]
16
- puts "\tUpdating #{name} ..." if verbose
17
-
18
- exp = whitelisted_attrs(exp)
19
- e = Experimental::Experiment.find_by_name(name) || Experimental::Experiment.new
20
-
21
- puts "\t\tcreating ..." if verbose && e.id.nil?
22
- puts "\t\tupdating ..." if verbose && !e.id.nil?
23
-
24
- exp.merge!({ start_date: Time.now }) if e.start_date.nil?
25
- e.update_attributes!(exp, without_protection: true)
26
- end unless experiments.in_code.nil?
27
-
28
- #removed
29
- experiments.removed.each do |exp|
30
- name = exp.with_indifferent_access[:name]
31
- puts "\tRemoving #{name} ..." if verbose
32
-
33
- exp = whitelisted_attrs(exp)
34
- e = Experimental::Experiment.find_by_name(name)
35
-
36
- if e && e.removed_at.nil?
37
- puts "\t\t#{name} exists, removing ..." if verbose
38
- result = e.remove
39
- puts "\t\t\t#{result}" if verbose
40
- else
41
- puts "\t\t#{name} doesn't exist!" if verbose
42
- end
43
- end unless experiments.removed.nil?
44
-
45
- puts "Expiring cache ..." if verbose
46
- Experimental::Experiment.expire_cache
47
-
48
- puts "Done syncing experiments!" if verbose
49
- end
50
-
51
- private
52
-
53
- def load_experiments
54
- OpenStruct.new YAML.load_file(File.join(Rails.root, file_path))
55
- end
56
-
57
- def whitelisted_attrs(exp)
58
- exp.
59
- select { |k, v| @@whitelisted_attributes.include?(k.to_sym) }.
60
- with_indifferent_access
61
- end
62
- end
63
- end
64
- end