fluidfeatures 0.4.4 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +162 -3
- data/lib/fluidfeatures.rb +14 -5
- data/lib/fluidfeatures/app/reporter.rb +35 -9
- data/lib/fluidfeatures/app/state.rb +6 -1
- data/lib/fluidfeatures/app/transaction.rb +3 -3
- data/lib/fluidfeatures/config.rb +24 -0
- data/lib/fluidfeatures/persistence/buckets.rb +71 -0
- data/lib/fluidfeatures/persistence/features.rb +62 -0
- data/lib/fluidfeatures/persistence/storage.rb +32 -0
- data/lib/fluidfeatures/version.rb +1 -1
- data/spec/app/reporter_spec.rb +170 -4
- data/spec/app/state_spec.rb +47 -5
- data/spec/config_spec.rb +53 -0
- data/spec/fixtures/fluidfeatures.yml +18 -0
- data/spec/{fluidfeatures_spec.rb → integration/fluidfeatures_spec.rb} +9 -7
- data/spec/persistence/buckets_spec.rb +95 -0
- data/spec/persistence/features_spec.rb +37 -0
- data/spec/spec_helper.rb +11 -6
- data/spec/support/api_helpers.rb +9 -4
- data/spec/support/hash_ext.rb +11 -0
- data/spec/support/polling_loop_shared_examples.rb +1 -3
- data/spec/support/string_ext.rb +12 -0
- metadata +13 -3
data/README.md
CHANGED
@@ -1,7 +1,166 @@
|
|
1
1
|
[![Build Status](https://secure.travis-ci.org/FluidFeatures/fluidfeatures-ruby.png)](http://travis-ci.org/FluidFeatures/fluidfeatures-ruby)
|
2
2
|
|
3
|
-
|
4
|
-
|
3
|
+
Ruby graceful feature rollout and simple A/B testing
|
4
|
+
====================================================
|
5
5
|
|
6
|
-
Ruby client for API of FluidFeatures.com
|
6
|
+
`gem fluidfeatures-ruby` is a pure Ruby client for the API of FluidFeatures.com, which provides an elegant way to wrap new code so you have real-time control over rolling out new features to your user-base.
|
7
|
+
|
8
|
+
Integration with Rails
|
9
|
+
----------------------
|
10
|
+
|
11
|
+
If you are looking to use FluidFeatures with Rails then see https://github.com/FluidFeatures/fluidfeatures-rails
|
12
|
+
|
13
|
+
`gem fluidfeatures-rails` uses this `gem fluidfeatures-ruby` but integrates into Rails, which makes it quick and easy to use FluidFeatures with your Rails application.
|
14
|
+
|
15
|
+
Ruby specific usage (non-Rails usage)
|
16
|
+
=====================================
|
17
|
+
|
18
|
+
This gem can be used in any Ruby application. This means a Sinatra application, offline processing or sending emails. Anything that touches your user-base can have a customized experience for each user and can be a vehicle for A/B testing. For instance, you can test different email formats and see which one results in more conversions on your website. You could even integrate into a [Map-Reduce](http://www.bigfastblog.com/map-reduce-with-ruby-using-hadoop) job.
|
19
|
+
|
20
|
+
Installation
|
21
|
+
------------
|
22
|
+
|
23
|
+
```
|
24
|
+
gem install fluidfeatures-ruby
|
25
|
+
```
|
26
|
+
|
27
|
+
"Application"
|
28
|
+
-------------
|
29
|
+
|
30
|
+
You create a FluidFeatures::App (application) object with the credentials for accessing the FluidFeatures API. These credentials can be found of the application page of your FluidFeatures dashboard.
|
31
|
+
|
32
|
+
Call `app` on the `FluidFeatures` module to instantiate this object.
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
require 'fluidfeatures'
|
36
|
+
|
37
|
+
config = {
|
38
|
+
"baseuri" => "https://www.fluidfeatures.com/service"
|
39
|
+
"appid" => "1vu33ki6emqe3"
|
40
|
+
"secret" = "sssseeecrrreeetttt"
|
41
|
+
}
|
42
|
+
|
43
|
+
fluid_app = FluidFeatures.app(config)
|
44
|
+
```
|
45
|
+
|
46
|
+
It's also possible to pass a logger object, which will direct all logging to your logger.
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
fluid_app = FluidFeatures.app(config.update("logger" => logger))
|
50
|
+
```
|
51
|
+
|
52
|
+
User Transactions
|
53
|
+
-----------------
|
54
|
+
|
55
|
+
Each interaction with a user is wrapped in a transaction. In fluidfeature-rails, a transaction wraps a single HTTP request.
|
56
|
+
|
57
|
+
Transactions guarantee that the feature set for a user will be consistent during the transaction. It is possible that you might reconfigure which users see which features during a transaction, so this ensures that each request is processed atomically regardless of any changes that are occurring outside of the transaction.
|
58
|
+
|
59
|
+
To create a new user transaction call `user_transaction` on the `FluidFeatures::App` object, which we assigned to `fluid_app` above.
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
fluid_user_transaction = fluid_app.user_transaction(user_id, url, display_name, is_anonymous, unique_attrs, cohort_attrs)
|
63
|
+
```
|
64
|
+
|
65
|
+
An transaction takes a few parameters.
|
66
|
+
|
67
|
+
`user_id` is the unique id that you generally refer to this user by. This can be a numeric or a string. If this is `nil` then the user is labeled anonymous and a unique id is generated to track this user. You can retrieve this unique id by using `anonymous_user_id = fluid_user_transaction.user.unique_id`. In HTTP context you should set a HTTP cookie with this `anonymous_user_id`, so that this user is consistently treated as the same user and experiences the same experience for each visit.
|
68
|
+
|
69
|
+
`url` is a reference to where the transaction is taking place. This has more meaning in HTTP context.
|
70
|
+
|
71
|
+
`display_name` is a human readable name of the user. eg. "Fred Flintstone". FluidFeatures will use this in the web dashboard for display purposes and also for searching for this user.
|
72
|
+
|
73
|
+
`is_anonymous` is a boolean that indicates whether this is an anonymous user or not. If this is true then `user_id` can be `nil` if you are not creating and tracking your own user ids for anonymous users.
|
74
|
+
|
75
|
+
`unique_attrs` is a `Hash` of other attributes that are unique to this user. For instance, their Twitter handle. Provide these are key-value pairs, which can be displayed and searched in the FluidFeatures dashboard. You can freely pass any key-value pairs you wish. For example, `:nickname => "The Hungry Bear"` or `:twitter => "@philwhln"`.
|
76
|
+
|
77
|
+
`cohort_attrs` is similar to `unique_attrs` but are not unique to this user. This is a way that you can group users together when managing feature visibility. These can be anything. A common one is `:admin => true` or `:admin => false`. Other ones include `:coffee_drinker => true`, `:age_group => "25-33"` or `:month_joined => "2012-july"`.
|
78
|
+
|
79
|
+
Within the FluidFeatures service, we plan to automatically add other cohorts, such as `:is_early_adopter`, `:active_user`, `:stale_user` to help you target your new features and engage the right users with the right features. This is outside the scope of this gem.
|
80
|
+
|
81
|
+
Is this feature enabled for this user?
|
82
|
+
--------------------------------------
|
83
|
+
|
84
|
+
This is at the core of graceful feature rollout. When the answer is always "no" for a feature, then you are able to push that feature into production without anyone knowing. After that you can start enabling it for specific users and groups of users.
|
85
|
+
|
86
|
+
To find out if the feature is enabled for the current user, call `feature_enabled?` on your `FluidFeatures::UserTransaction` object, which we assigned to `fluid_transaction` above.
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
if fluid_user_transaction.feature_enabled? feature_name
|
90
|
+
# implement feature here
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
In the above example it will use `"default"` for the version of the feature. If you wish to define your own version, you can pass `version_name` as shown below.
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
if fluid_user_transaction.feature_enabled? feature_name, version_name
|
98
|
+
# implement feature here
|
99
|
+
end
|
100
|
+
```
|
101
|
+
|
102
|
+
Real-world examples might be...
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
if fluid_user_transaction.feature_enabled? "theme", "old-one"
|
106
|
+
# show the user the old theme
|
107
|
+
end
|
108
|
+
if fluid_user_transaction.feature_enabled? "theme", "new-one"
|
109
|
+
# show the user the new theme
|
110
|
+
end
|
111
|
+
```
|
112
|
+
|
113
|
+
```ruby
|
114
|
+
if fluid_user_transaction.feature_enabled? "email-sender", "postfix"
|
115
|
+
# send email directly using postfix
|
116
|
+
end
|
117
|
+
if fluid_user_transaction.feature_enabled? "email-sender", "sendgrid-smtp"
|
118
|
+
# send email using sendgrid's smtp interface
|
119
|
+
end
|
120
|
+
if fluid_user_transaction.feature_enabled? "email-sender", "sendgrid-api"
|
121
|
+
# send email using sendgrid's http api
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
When FluidFeatures::UserTransaction gets a call to `feature_enabled?` it may not have seen this feature version before, so it will report this back to the FluidFeatures service as a new feature version. By default, new feature versions are disabled for all users until they are assigned to specific users, cohorts or percentage of users. This is not always ideal, since you may want to wrap an existing feature, such as the `["theme","old-one"]` above, in order to phase it out or test it against a newer version. It that case you can tell FluidFeatures explicitly what the default enabled state of this feature version should be, by passing `default_enabled` boolean as `true` or `false`. `true` will result is all users initially seeing this feature version.
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
if fluid_user_transaction.feature_enabled? feature_name, version_name, default_enabled
|
129
|
+
# implement feature here
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
It's a goal!
|
134
|
+
------------
|
135
|
+
|
136
|
+
The main motivation behind FluidFeatures is to make your site better, increase your conversion rate and ensure that the features you are developing are something that your customers want.
|
137
|
+
|
138
|
+
Rolling out new features is fun and doing it gracefully is important, but rolling out the right features is even more important. With "goals" you can keep track of how well each feature is doing and even run short A/B or multi-variant tests to validate your hypothesis about which version of a feature you should rollout.
|
139
|
+
|
140
|
+
As you start to rollout a newer version of a feature, you can watch how well your goals are performing for that version and for other versions. If early indications show that your newer version sucks and conversions are lagging, then you can rollback or pause rollout until you can understand the issue better.
|
141
|
+
|
142
|
+
Common high-level goals are "signed-up-to-mailing-list", "clicked-buy-button" or "visited-at-least-5-pages-during-session". Lower-level ones might include, "page-loaded-in-under-one-second", "no-errors-written-to-log" or "cpu-stayed-below-80-percent". Facebook tracks spikes in user comments, which they have found usually results in a feature gone wrong and users complaining. Your goals do not have to positive.
|
143
|
+
|
144
|
+
Goals are boolean and when one fires for a user then any feature enabled for that user at that time is held accountant, whether the goal is positive or negative.
|
145
|
+
|
146
|
+
Flagging a goal is easy. You simply call `goal_hit` on the `FluidFeatures::UserTransaction` object, which in the example below is `fluid_user_transaction`.
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
fluid_user_transaction.goal_hit goal_name
|
150
|
+
```
|
151
|
+
|
152
|
+
Might look something like this...
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
def buy_button_clicked
|
156
|
+
@fluid_user_transaction.goal_hit "clicked-buy-button"
|
157
|
+
end
|
158
|
+
```
|
159
|
+
|
160
|
+
This goal will automatically appear in your FluidFeatures dashboard after the first time `goal_hit` is called with this value.
|
161
|
+
|
162
|
+
More info
|
163
|
+
=========
|
164
|
+
|
165
|
+
For more information visit http://www.fluidfeatures.com or email support@fluidfeatures.com
|
7
166
|
|
data/lib/fluidfeatures.rb
CHANGED
@@ -1,15 +1,24 @@
|
|
1
|
-
|
2
1
|
require "logger"
|
3
2
|
|
3
|
+
require "fluidfeatures/config"
|
4
|
+
|
5
|
+
require "fluidfeatures/persistence/storage"
|
6
|
+
require "fluidfeatures/persistence/buckets"
|
7
|
+
require "fluidfeatures/persistence/features"
|
8
|
+
|
4
9
|
require "fluidfeatures/client"
|
5
10
|
require "fluidfeatures/app"
|
6
11
|
|
7
12
|
module FluidFeatures
|
8
13
|
|
9
|
-
|
10
|
-
|
11
|
-
client = ::FluidFeatures::Client.new(base_uri, logger)
|
12
|
-
::FluidFeatures::App.new(client, app_id, secret, logger)
|
14
|
+
class << self
|
15
|
+
attr_accessor :config
|
13
16
|
end
|
14
17
|
|
18
|
+
def self.app(config)
|
19
|
+
config["logger"] ||= ::Logger.new(STDERR)
|
20
|
+
self.config = config
|
21
|
+
client = ::FluidFeatures::Client.new(config["baseuri"], config["logger"])
|
22
|
+
::FluidFeatures::App.new(client, config["appid"], config["secret"], config["logger"])
|
23
|
+
end
|
15
24
|
end
|
@@ -6,7 +6,7 @@ module FluidFeatures
|
|
6
6
|
|
7
7
|
attr_accessor :app
|
8
8
|
|
9
|
-
# Throw
|
9
|
+
# Throw oldest buckets away or offload to persistent storage when this limit reached.
|
10
10
|
MAX_BUCKETS = 10
|
11
11
|
|
12
12
|
# Max number of transactions we queue in a bucket.
|
@@ -29,22 +29,39 @@ module FluidFeatures
|
|
29
29
|
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
30
30
|
configure(app)
|
31
31
|
run_loop
|
32
|
+
at_exit do
|
33
|
+
buckets_storage.append(@buckets)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def buckets_storage
|
38
|
+
@buckets_storage ||= FluidFeatures::Persistence::Buckets.create(FluidFeatures.config["cache"])
|
39
|
+
end
|
40
|
+
|
41
|
+
def features_storage
|
42
|
+
@features_storage ||= FluidFeatures::Persistence::Features.create(FluidFeatures.config["cache"])
|
32
43
|
end
|
33
44
|
|
34
45
|
def configure(app)
|
35
46
|
@app = app
|
36
47
|
|
37
|
-
@buckets =
|
48
|
+
@buckets = buckets_storage.fetch(MAX_BUCKETS)
|
49
|
+
|
38
50
|
@buckets_lock = ::Mutex.new
|
39
51
|
|
52
|
+
#maybe could get rid of @current_bucket concept
|
40
53
|
@current_bucket = nil
|
41
54
|
@current_bucket_lock = ::Mutex.new
|
42
|
-
@current_bucket =
|
55
|
+
@current_bucket = last_or_new_bucket
|
43
56
|
|
44
|
-
@unknown_features =
|
57
|
+
@unknown_features = features_storage.list_unknown
|
45
58
|
@unknown_features_lock = ::Mutex.new
|
46
59
|
end
|
47
60
|
|
61
|
+
def last_or_new_bucket
|
62
|
+
@buckets.empty? || @buckets.last.size >= MAX_BUCKET_SIZE ? new_bucket : @buckets.last
|
63
|
+
end
|
64
|
+
|
48
65
|
# Pass FluidFeatures::AppUserTransaction for reporting
|
49
66
|
# back to the FluidFeatures service.
|
50
67
|
def report_transaction(transaction)
|
@@ -76,6 +93,7 @@ module FluidFeatures
|
|
76
93
|
|
77
94
|
if transaction.unknown_features.size > 0
|
78
95
|
queue_unknown_features(transaction.unknown_features)
|
96
|
+
features_storage.replace_unknown(@unknown_features)
|
79
97
|
end
|
80
98
|
|
81
99
|
end
|
@@ -176,6 +194,8 @@ module FluidFeatures
|
|
176
194
|
end
|
177
195
|
# return unknown features to queue until the next attempt at sending
|
178
196
|
queue_unknown_features(unknown_features)
|
197
|
+
else
|
198
|
+
features_storage.replace_unknown({})
|
179
199
|
end
|
180
200
|
|
181
201
|
# return whether we were able to send or not
|
@@ -194,16 +214,15 @@ module FluidFeatures
|
|
194
214
|
@private
|
195
215
|
def new_bucket
|
196
216
|
bucket = []
|
197
|
-
discarded_bucket = nil
|
198
217
|
@buckets_lock.synchronize do
|
199
218
|
@buckets << bucket
|
200
219
|
if @buckets.size > MAX_BUCKETS
|
201
|
-
|
220
|
+
#offload to storage
|
221
|
+
unless buckets_storage.append_one(@buckets.shift)
|
222
|
+
app.logger.warn "[FF] Discarded transactions due to reporter backlog. These will not be reported to FluidFeatures."
|
223
|
+
end
|
202
224
|
end
|
203
225
|
end
|
204
|
-
if discarded_bucket
|
205
|
-
app.logger.warn "[FF] Discarded #{discarded_bucket.size} transactions due to reporter backlog. These will not be reported to FluidFeatures."
|
206
|
-
end
|
207
226
|
bucket
|
208
227
|
end
|
209
228
|
|
@@ -211,6 +230,11 @@ module FluidFeatures
|
|
211
230
|
def remove_bucket
|
212
231
|
removed_bucket = nil
|
213
232
|
@buckets_lock.synchronize do
|
233
|
+
#try to get buckets from storage first
|
234
|
+
if @buckets.empty? && !buckets_storage.empty?
|
235
|
+
@buckets = buckets_storage.fetch(MAX_BUCKETS)
|
236
|
+
end
|
237
|
+
|
214
238
|
if @buckets.size > 0
|
215
239
|
removed_bucket = @buckets.shift
|
216
240
|
end
|
@@ -231,6 +255,8 @@ module FluidFeatures
|
|
231
255
|
if @buckets.size <= MAX_BUCKETS
|
232
256
|
@buckets.unshift bucket
|
233
257
|
success = true
|
258
|
+
else
|
259
|
+
success = buckets_storage.append_one(bucket)
|
234
260
|
end
|
235
261
|
end
|
236
262
|
success
|
@@ -35,10 +35,14 @@ module FluidFeatures
|
|
35
35
|
|
36
36
|
def configure(app)
|
37
37
|
@app = app
|
38
|
-
@features =
|
38
|
+
@features = features_storage.list
|
39
39
|
@features_lock = ::Mutex.new
|
40
40
|
end
|
41
41
|
|
42
|
+
def features_storage
|
43
|
+
@features_storage ||= FluidFeatures::Persistence::Features.create(FluidFeatures.config["cache"])
|
44
|
+
end
|
45
|
+
|
42
46
|
def features
|
43
47
|
f = nil
|
44
48
|
@features_lock.synchronize do
|
@@ -50,6 +54,7 @@ module FluidFeatures
|
|
50
54
|
def features= f
|
51
55
|
return unless f.is_a? Hash
|
52
56
|
@features_lock.synchronize do
|
57
|
+
features_storage.replace(f) unless @features == f
|
53
58
|
@features = f
|
54
59
|
end
|
55
60
|
end
|
@@ -12,7 +12,7 @@ module FluidFeatures
|
|
12
12
|
@url = url
|
13
13
|
|
14
14
|
# take a snap-shot of the features end at
|
15
|
-
# the beginning of the
|
15
|
+
# the beginning of the transaction
|
16
16
|
@features = user.features
|
17
17
|
|
18
18
|
@features_hit = {}
|
@@ -23,7 +23,7 @@ module FluidFeatures
|
|
23
23
|
|
24
24
|
end
|
25
25
|
|
26
|
-
def feature_enabled?(feature_name, version_name, default_enabled)
|
26
|
+
def feature_enabled?(feature_name, version_name=nil, default_enabled=nil)
|
27
27
|
raise "transaction ended" if ended
|
28
28
|
raise "feature_name invalid : #{feature_name}" unless feature_name.is_a? String
|
29
29
|
version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
@@ -73,7 +73,7 @@ module FluidFeatures
|
|
73
73
|
end
|
74
74
|
end
|
75
75
|
|
76
|
-
def goal_hit(goal_name, goal_version_name)
|
76
|
+
def goal_hit(goal_name, goal_version_name=nil)
|
77
77
|
raise "transaction ended" if ended
|
78
78
|
raise "goal_name invalid : #{goal_name}" unless goal_name.is_a? String
|
79
79
|
goal_version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
@@ -0,0 +1,24 @@
|
|
1
|
+
|
2
|
+
require 'yaml'
|
3
|
+
require 'erb'
|
4
|
+
|
5
|
+
module FluidFeatures
|
6
|
+
class Config
|
7
|
+
attr_accessor :vars
|
8
|
+
def initialize(path, environment, replacements = {})
|
9
|
+
self.vars = YAML.load(ERB.new(File.read(path)).result)
|
10
|
+
self.vars = vars["common"].update(vars[environment])
|
11
|
+
self.vars = vars.update(replacements)
|
12
|
+
self.vars["cache"]["limit"] = self.class.parse_file_size(vars["cache"]["limit"]) if vars["cache"]
|
13
|
+
end
|
14
|
+
|
15
|
+
def [](name)
|
16
|
+
@vars[name.to_s]
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.parse_file_size(size)
|
20
|
+
return size if !size || size.is_a?(Numeric) || !/(\d+)\s*(kb|mb|gb)$/.match(size.downcase)
|
21
|
+
$1.to_i * 1024 ** (%w{kb mb gb}.index($2) + 1)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module FluidFeatures
|
2
|
+
module Persistence
|
3
|
+
class Buckets < Storage
|
4
|
+
attr_accessor :limit
|
5
|
+
|
6
|
+
def self.create(config)
|
7
|
+
return NullBuckets.new unless config && config["dir"] && config["enable"] && config["limit"] > 0
|
8
|
+
new(config)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(config)
|
12
|
+
self.limit = config["limit"]
|
13
|
+
super config
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch(n = 1)
|
17
|
+
ret = []
|
18
|
+
store.transaction do
|
19
|
+
return [] unless store && store["buckets"] && !store["buckets"].empty?
|
20
|
+
n = store["buckets"].size if n > store["buckets"].size
|
21
|
+
ret = store["buckets"].slice!(0, n)
|
22
|
+
store.commit
|
23
|
+
end
|
24
|
+
return ret
|
25
|
+
end
|
26
|
+
|
27
|
+
def append(buckets)
|
28
|
+
transaction do
|
29
|
+
store["buckets"] += buckets
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def append_one(bucket)
|
34
|
+
transaction do
|
35
|
+
store["buckets"].push(bucket)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def empty?
|
40
|
+
store.transaction(true) do
|
41
|
+
return true unless store["buckets"]
|
42
|
+
return store["buckets"].empty?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def transaction
|
49
|
+
begin
|
50
|
+
return false if file_size > limit
|
51
|
+
store.transaction do
|
52
|
+
store["buckets"] ||= []
|
53
|
+
yield
|
54
|
+
end
|
55
|
+
rescue Exception => _
|
56
|
+
return false
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class NullBuckets
|
62
|
+
def fetch(*args); [] end
|
63
|
+
|
64
|
+
def append(*args); false end
|
65
|
+
|
66
|
+
def append_one(*args); false end
|
67
|
+
|
68
|
+
def empty?; true end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module FluidFeatures
|
2
|
+
module Persistence
|
3
|
+
class Features < Storage
|
4
|
+
|
5
|
+
def self.create(config)
|
6
|
+
return NullFeatures.new unless config && config["dir"] && config["enable"]
|
7
|
+
new(config)
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
super config
|
12
|
+
end
|
13
|
+
|
14
|
+
def list
|
15
|
+
store.transaction(true) do
|
16
|
+
return {} unless store && store["features"]
|
17
|
+
store["features"]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def replace(features)
|
22
|
+
transaction do
|
23
|
+
store["features"] = features
|
24
|
+
!!store["features"]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def list_unknown
|
29
|
+
store.transaction(true) do
|
30
|
+
return {} unless store && store["unknown_features"]
|
31
|
+
store["unknown_features"]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def replace_unknown(features)
|
36
|
+
transaction do
|
37
|
+
store["unknown_features"] = features
|
38
|
+
!!store["unknown_features"]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def transaction
|
45
|
+
begin
|
46
|
+
store.transaction do
|
47
|
+
yield
|
48
|
+
end
|
49
|
+
rescue Exception => _
|
50
|
+
return false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class NullFeatures
|
56
|
+
def list; {} end
|
57
|
+
def list_unknown; {} end
|
58
|
+
def replace(*args); false end
|
59
|
+
def replace_unknown(*args); false end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require "pstore"
|
2
|
+
|
3
|
+
module FluidFeatures
|
4
|
+
module Persistence
|
5
|
+
class Storage
|
6
|
+
attr_accessor :dir, :file_name, :store, :logger
|
7
|
+
|
8
|
+
def initialize(config)
|
9
|
+
self.dir = config["dir"]
|
10
|
+
self.logger = config["logger"] || Logger.new(STDERR)
|
11
|
+
self.file_name = "#{self.class.to_s.split('::').last.downcase}.pstore"
|
12
|
+
FileUtils.mkpath(dir) unless Dir.exists?(dir)
|
13
|
+
end
|
14
|
+
|
15
|
+
def store
|
16
|
+
@store ||= PStore.new(path, true)
|
17
|
+
end
|
18
|
+
|
19
|
+
def path
|
20
|
+
File.join(dir, file_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
def file_size
|
24
|
+
begin
|
25
|
+
File.size(path)
|
26
|
+
rescue Exception => _
|
27
|
+
return 0
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/spec/app/reporter_spec.rb
CHANGED
@@ -16,10 +16,102 @@ describe FluidFeatures::AppReporter do
|
|
16
16
|
|
17
17
|
before(:each) do
|
18
18
|
described_class.any_instance.stub(:run_loop)
|
19
|
+
FluidFeatures.stub(:config).and_return(config)
|
19
20
|
end
|
20
21
|
|
21
|
-
describe "#
|
22
|
+
describe "#features_storage" do
|
23
|
+
before(:each) do
|
24
|
+
described_class.any_instance.stub(:configure)
|
25
|
+
FluidFeatures::Persistence::Features.should_receive(:create).with(config["cache"]).twice
|
26
|
+
.and_return(FluidFeatures::Persistence::NullFeatures.new)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should create features storage" do
|
30
|
+
reporter.features_storage
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should not call create twice" do
|
34
|
+
reporter.features_storage
|
35
|
+
reporter.features_storage
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "#buckets_storage" do
|
40
|
+
before(:each) do
|
41
|
+
described_class.any_instance.stub(:configure)
|
42
|
+
FluidFeatures::Persistence::Buckets.should_receive(:create).with(config["cache"])
|
43
|
+
.and_return(FluidFeatures::Persistence::NullBuckets.new)
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should create buckets storage" do
|
47
|
+
reporter.buckets_storage
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should not call create twice" do
|
51
|
+
reporter.buckets_storage
|
52
|
+
reporter.buckets_storage
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "#configure" do
|
57
|
+
let(:features_storage) { mock("features_storage") }
|
58
|
+
let(:buckets_storage) { mock("buckets_storage") }
|
59
|
+
let(:bucket) { mock("bucket") }
|
60
|
+
|
61
|
+
before(:each) do
|
62
|
+
reporter.stub!(:features_storage).and_return(features_storage)
|
63
|
+
reporter.stub!(:buckets_storage).and_return(buckets_storage)
|
64
|
+
reporter.stub!(:last_or_new_bucket)
|
65
|
+
features_storage.stub!(:list_unknown)
|
66
|
+
buckets_storage.stub!(:fetch)
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should assign result of buckets_storage.fetch to @buckets" do
|
70
|
+
buckets = mock("buckets")
|
71
|
+
buckets_storage.should_receive(:fetch).with(described_class::MAX_BUCKETS).and_return(buckets)
|
72
|
+
reporter.configure(app)
|
73
|
+
reporter.instance_variable_get(:@buckets).should == buckets
|
74
|
+
end
|
75
|
+
|
76
|
+
it "should assign result of last_or_new_bucket to @current_bucket" do
|
77
|
+
reporter.should_receive(:last_or_new_bucket).and_return(bucket)
|
78
|
+
reporter.configure(app)
|
79
|
+
reporter.instance_variable_get(:@current_bucket).should == bucket
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should assign result of features_storage.list_unknown to @unknown_features" do
|
83
|
+
features = mock("features")
|
84
|
+
features_storage.should_receive(:list_unknown).and_return(features)
|
85
|
+
reporter.configure(app)
|
86
|
+
reporter.instance_variable_get(:@unknown_features).should == features
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe "last_or_new_bucket" do
|
91
|
+
it "should return new bucket if buckets empty" do
|
92
|
+
reporter.instance_variable_set(:@buckets, [])
|
93
|
+
reporter.should_receive(:new_bucket).and_return([])
|
94
|
+
reporter.last_or_new_bucket.should == []
|
95
|
+
end
|
96
|
+
|
97
|
+
it "should return new bucket if last bucket is full" do
|
98
|
+
bucket = mock("bucket")
|
99
|
+
bucket.should_receive(:size).and_return(described_class::MAX_BUCKET_SIZE + 1)
|
100
|
+
reporter.instance_variable_set(:@buckets, [bucket])
|
101
|
+
reporter.should_receive(:new_bucket).and_return([])
|
102
|
+
reporter.last_or_new_bucket.should == []
|
103
|
+
end
|
104
|
+
|
105
|
+
it "should return last bucket of buckets" do
|
106
|
+
bucket = mock("bucket")
|
107
|
+
bucket.should_receive(:size).and_return(described_class::MAX_BUCKET_SIZE - 1)
|
108
|
+
reporter.instance_variable_set(:@buckets, [bucket])
|
109
|
+
reporter.should_not_receive(:new_bucket)
|
110
|
+
reporter.last_or_new_bucket.should == bucket
|
111
|
+
end
|
112
|
+
end
|
22
113
|
|
114
|
+
describe "#report_transaction" do
|
23
115
|
before(:each) do
|
24
116
|
reporter.stub!(:queue_transaction_payload)
|
25
117
|
reporter.stub!(:queue_unknown_features)
|
@@ -41,7 +133,6 @@ describe FluidFeatures::AppReporter do
|
|
41
133
|
reporter.should_receive(:queue_unknown_features).with(transaction.unknown_features)
|
42
134
|
reporter.report_transaction(transaction)
|
43
135
|
end
|
44
|
-
|
45
136
|
end
|
46
137
|
|
47
138
|
describe "#transactions_queued?" do
|
@@ -78,6 +169,7 @@ describe FluidFeatures::AppReporter do
|
|
78
169
|
|
79
170
|
describe "#queue_unknown_features" do
|
80
171
|
let(:unknown_features) { { "feature" => { "a" => true } } }
|
172
|
+
|
81
173
|
it "should add unknown_features to instance variable" do
|
82
174
|
reporter.queue_unknown_features(unknown_features)
|
83
175
|
reporter.instance_variable_get(:@unknown_features).should == unknown_features
|
@@ -90,11 +182,11 @@ describe FluidFeatures::AppReporter do
|
|
90
182
|
it "should raise error if versions is not Hash" do
|
91
183
|
expect { reporter.queue_unknown_features("feature" => "not cool") }.to raise_error /should be a Hash/
|
92
184
|
end
|
93
|
-
|
94
185
|
end
|
95
186
|
|
96
187
|
describe "#send_transactions" do
|
97
188
|
let(:unknown_features) { { "feature" => { "a" => true } } }
|
189
|
+
|
98
190
|
let(:app) { mock "FluidFeatures::App", client: mock("client", uuid: 'client uuid'), logger: mock('logger') }
|
99
191
|
|
100
192
|
before(:each) do
|
@@ -120,8 +212,82 @@ describe FluidFeatures::AppReporter do
|
|
120
212
|
reporter.send_transactions
|
121
213
|
end
|
122
214
|
|
215
|
+
it "should reset unknown features in storage on success" do
|
216
|
+
app.stub!(:post).and_return(true)
|
217
|
+
reporter.features_storage.should_receive(:replace_unknown).with({})
|
218
|
+
reporter.send_transactions
|
219
|
+
end
|
123
220
|
end
|
124
221
|
|
125
|
-
|
222
|
+
describe "#new_bucket" do
|
223
|
+
let(:buckets) { [] }
|
224
|
+
|
225
|
+
before(:each) do
|
226
|
+
reporter.instance_variable_set(:@buckets, buckets)
|
227
|
+
end
|
228
|
+
|
229
|
+
it "should append first bucket to storage if over the limit" do
|
230
|
+
buckets.stub!(:size).and_return(described_class::MAX_BUCKETS + 1)
|
231
|
+
reporter.buckets_storage.should_receive(:append_one).with([])
|
232
|
+
reporter.new_bucket
|
233
|
+
end
|
234
|
+
|
235
|
+
it "should append first bucket to storage if" do
|
236
|
+
buckets.stub!(:size).and_return(described_class::MAX_BUCKETS - 1)
|
237
|
+
reporter.buckets_storage.should_not_receive(:append)
|
238
|
+
reporter.new_bucket
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
describe "#remove_bucket" do
|
243
|
+
let(:buckets) { [] }
|
244
|
+
|
245
|
+
before(:each) do
|
246
|
+
reporter.instance_variable_set(:@buckets, buckets)
|
247
|
+
end
|
248
|
+
|
249
|
+
it "should load buckets from storage if buckets empty and something in storage" do
|
250
|
+
buckets.stub!(:empty?).and_return(true)
|
251
|
+
reporter.buckets_storage.stub!(:empty?).and_return(false)
|
252
|
+
reporter.buckets_storage.should_receive(:fetch).and_return(buckets)
|
253
|
+
reporter.remove_bucket
|
254
|
+
end
|
255
|
+
|
256
|
+
it "should not load buckets if buckets not empty" do
|
257
|
+
buckets.stub!(:empty?).and_return(false)
|
258
|
+
reporter.buckets_storage.stub!(:empty?).and_return(false)
|
259
|
+
reporter.buckets_storage.should_not_receive(:fetch)
|
260
|
+
reporter.remove_bucket
|
261
|
+
end
|
262
|
+
|
263
|
+
it "should not load buckets if storage empty" do
|
264
|
+
buckets.stub!(:empty?).and_return(true)
|
265
|
+
reporter.buckets_storage.stub!(:empty?).and_return(true)
|
266
|
+
reporter.buckets_storage.should_not_receive(:fetch)
|
267
|
+
reporter.remove_bucket
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
describe "#unremove_bucket" do
|
272
|
+
let(:buckets) { [] }
|
126
273
|
|
274
|
+
let(:bucket) { mock("bucket") }
|
275
|
+
|
276
|
+
before(:each) do
|
277
|
+
reporter.instance_variable_set(:@buckets, buckets)
|
278
|
+
end
|
279
|
+
|
280
|
+
it "should offload bucket to storage if buckets over limit" do
|
281
|
+
buckets.stub!(:size).and_return(described_class::MAX_BUCKETS + 1)
|
282
|
+
reporter.buckets_storage.should_receive(:append_one).with(bucket)
|
283
|
+
reporter.unremove_bucket(bucket)
|
284
|
+
end
|
285
|
+
|
286
|
+
it "should not offload bucket to storage if buckets under limit" do
|
287
|
+
buckets.stub!(:size).and_return(described_class::MAX_BUCKETS - 1)
|
288
|
+
reporter.buckets_storage.should_not_receive(:append)
|
289
|
+
reporter.unremove_bucket(bucket)
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
127
293
|
end
|
data/spec/app/state_spec.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
+
require "fluidfeatures/persistence/buckets"
|
3
|
+
require "fluidfeatures/persistence/features"
|
2
4
|
|
3
5
|
describe FluidFeatures::AppState do
|
4
6
|
|
@@ -15,10 +17,54 @@ describe FluidFeatures::AppState do
|
|
15
17
|
app.stub!(:is_a?).and_return(false)
|
16
18
|
app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
|
17
19
|
described_class.any_instance.stub(:run_loop)
|
20
|
+
FluidFeatures.stub(:config).and_return(config)
|
18
21
|
end
|
19
22
|
|
20
|
-
describe "#
|
23
|
+
describe "#features_storage" do
|
24
|
+
before(:each) do
|
25
|
+
described_class.any_instance.stub(:configure)
|
26
|
+
FluidFeatures::Persistence::Features.should_receive(:create).with(config["cache"])
|
27
|
+
.and_return(FluidFeatures::Persistence::NullFeatures.new)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should create features storage" do
|
31
|
+
state.features_storage
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should not call create twice" do
|
35
|
+
state.features_storage
|
36
|
+
state.features_storage
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "#configure" do
|
41
|
+
let(:features_storage) { mock("features_storage") }
|
21
42
|
|
43
|
+
before(:each) do
|
44
|
+
state.stub!(:features_storage).and_return(features_storage)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should assign result of features_storage.list to @features" do
|
48
|
+
features = mock("features")
|
49
|
+
features_storage.should_receive(:list).and_return(features)
|
50
|
+
state.configure(app)
|
51
|
+
state.instance_variable_get(:@features).should == features
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "#features=" do
|
56
|
+
it "should replace features if there are changes" do
|
57
|
+
state.features_storage.should_receive(:replace).with({foo: "bar"})
|
58
|
+
state.features = {foo: "bar"}
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should not replace features if not amended" do
|
62
|
+
state.features_storage.should_not_receive(:replace)
|
63
|
+
state.features = {}
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "#load_state" do
|
22
68
|
before(:each) do
|
23
69
|
app.stub!(:get)
|
24
70
|
end
|
@@ -34,7 +80,6 @@ describe FluidFeatures::AppState do
|
|
34
80
|
}])
|
35
81
|
state.load_state.should == [true, "feature" => { "versions" => { "a" => { "parts" => Set.new([1, 2, 3]) } } }]
|
36
82
|
end
|
37
|
-
|
38
83
|
end
|
39
84
|
|
40
85
|
describe "#feature_version_enabled_for_user" do
|
@@ -67,9 +112,6 @@ describe FluidFeatures::AppState do
|
|
67
112
|
it "should return true if stated implicitly" do
|
68
113
|
state.feature_version_enabled_for_user("Feature", "a", 5, { "key" => "id" }).should be_true
|
69
114
|
end
|
70
|
-
|
71
115
|
end
|
72
|
-
|
73
116
|
end
|
74
|
-
|
75
117
|
end
|
data/spec/config_spec.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe FluidFeatures::Config do
|
4
|
+
let(:path) { "#{File.dirname(__FILE__)}/fixtures/fluidfeatures.yml" }
|
5
|
+
|
6
|
+
let(:env) { "development" }
|
7
|
+
|
8
|
+
let(:replacements) { {} }
|
9
|
+
|
10
|
+
let(:config) { FluidFeatures::Config.new(path, env, replacements) }
|
11
|
+
|
12
|
+
context "parse file size" do
|
13
|
+
[nil, 1024, "foo"].each do |input|
|
14
|
+
it "should pass #{input.class.to_s} through" do
|
15
|
+
described_class.parse_file_size(input).should == input
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
{ "1Kb" => 1024, "2mb" => 2097152, "2GB" => 2147483648}.each do |i, o|
|
20
|
+
it "should read '#{i}' as #{o}" do
|
21
|
+
described_class.parse_file_size(i).should == o
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
%w{test development production}.each do |env_name|
|
27
|
+
context "with #{env_name} environment" do
|
28
|
+
let(:env) { env_name }
|
29
|
+
|
30
|
+
it "should load environment configuration from yml file and merge common section" do
|
31
|
+
config.vars.should == {
|
32
|
+
"baseuri" => "baseuri",
|
33
|
+
"cache" => { "enable" => false, "dir" => "cache_dir", "limit" => 2097152 },
|
34
|
+
"appid" => "#{env_name}_appid",
|
35
|
+
"secret" => "#{env_name}_secret"
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
context "and replacements" do
|
40
|
+
let(:replacements) { { "baseuri" => "env_baseuri", "appid" => "env_appid", "secret" => "env_secret" } }
|
41
|
+
|
42
|
+
it "should update variables with passed hash" do
|
43
|
+
config.vars.should == {
|
44
|
+
"baseuri" => "env_baseuri",
|
45
|
+
"cache" => { "enable" => false, "dir" => "cache_dir", "limit" => 2097152 },
|
46
|
+
"appid" => "env_appid",
|
47
|
+
"secret" => "env_secret"
|
48
|
+
}
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
common:
|
2
|
+
baseuri: baseuri
|
3
|
+
cache:
|
4
|
+
enable: false
|
5
|
+
dir: cache_dir
|
6
|
+
limit: 2mb
|
7
|
+
|
8
|
+
development:
|
9
|
+
appid: development_appid
|
10
|
+
secret: development_secret
|
11
|
+
|
12
|
+
test:
|
13
|
+
appid: test_appid
|
14
|
+
secret: test_secret
|
15
|
+
|
16
|
+
production:
|
17
|
+
appid: production_appid
|
18
|
+
secret: production_secret
|
@@ -3,26 +3,30 @@ require "spec_helper"
|
|
3
3
|
describe "FluidFeatures" do
|
4
4
|
|
5
5
|
describe "App initialization" do
|
6
|
-
|
7
6
|
it "should raise 'host not set' without valid base uri" do
|
8
|
-
expect { FluidFeatures.app(
|
7
|
+
expect { FluidFeatures.app(config.remove("baseuri")) }.
|
9
8
|
to raise_error(StandardError, /host not set/)
|
10
9
|
end
|
11
10
|
|
12
11
|
it "should raise 'app_id invalid' without valid app id" do
|
13
|
-
expect { FluidFeatures.app(
|
12
|
+
expect { FluidFeatures.app(config.remove("appid")) }.
|
14
13
|
to raise_error(StandardError, /app_id invalid/)
|
15
14
|
end
|
16
15
|
|
17
16
|
it "should raise 'secret invalid' without valid secret" do
|
18
|
-
expect { FluidFeatures.app(
|
17
|
+
expect { FluidFeatures.app(config.remove("secret")) }.
|
19
18
|
to raise_error(StandardError, /secret invalid/)
|
20
19
|
end
|
21
20
|
|
21
|
+
it "should set @config class variable to passed config" do
|
22
|
+
FluidFeatures::Client.stub!(:new); FluidFeatures::App.stub!(:new)
|
23
|
+
config = mock("config", "[]" => nil, "[]=" => nil)
|
24
|
+
FluidFeatures.app(config)
|
25
|
+
FluidFeatures.config.should == config
|
26
|
+
end
|
22
27
|
end
|
23
28
|
|
24
29
|
describe "API methods" do
|
25
|
-
|
26
30
|
#let(:feature_name) { "Feature-#{UUID.new.generate}" }
|
27
31
|
let(:feature_name) { "Feature1" }
|
28
32
|
|
@@ -47,7 +51,5 @@ describe "FluidFeatures" do
|
|
47
51
|
sleep abit
|
48
52
|
end
|
49
53
|
end
|
50
|
-
|
51
54
|
end
|
52
|
-
|
53
55
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe FluidFeatures::Persistence::Buckets do
|
4
|
+
context "instance" do
|
5
|
+
let(:storage) { described_class.create(config["cache"]) }
|
6
|
+
|
7
|
+
describe "#fetch" do
|
8
|
+
it "should return [] for empty storage" do
|
9
|
+
storage.fetch.should == []
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should return [] for empty storage when called with limit" do
|
13
|
+
storage.fetch(10).should == []
|
14
|
+
end
|
15
|
+
|
16
|
+
it "should return limited set when called with limit" do
|
17
|
+
storage.append([["bucket0"], ["bucket1"], ["bucket2"]])
|
18
|
+
storage.fetch(2).should == [["bucket0"], ["bucket1"]]
|
19
|
+
storage.should_not be_empty
|
20
|
+
storage.fetch.should == [["bucket2"]]
|
21
|
+
storage.should be_empty
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should return limited set when called with limit over of bounds" do
|
26
|
+
storage.append([["bucket0"], ["bucket1"], ["bucket2"]])
|
27
|
+
storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
|
28
|
+
storage.should be_empty
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#append" do
|
33
|
+
it "should append buckets to empty storage" do
|
34
|
+
storage.append([["bucket"]]).should be_true
|
35
|
+
storage.fetch(10).should == [["bucket"]]
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should append buckets to existing buckets" do
|
39
|
+
storage.append_one(["bucket0"])
|
40
|
+
storage.append([["bucket1"], ["bucket2"]]).should be_true
|
41
|
+
storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should not append and return false if storage size over the limit" do
|
45
|
+
storage.stub!(:file_size).and_return(2)
|
46
|
+
storage.stub!(:limit).and_return(1)
|
47
|
+
storage.append([["bucket0"], ["bucket1"]]).should be_false
|
48
|
+
storage.fetch.should == []
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#append_one" do
|
53
|
+
it "should append bucket to empty storage" do
|
54
|
+
storage.append_one(["bucket"]).should be_true
|
55
|
+
storage.fetch.should == [["bucket"]]
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should append bucket to existing buckets" do
|
59
|
+
storage.append([["bucket0"], ["bucket1"]])
|
60
|
+
storage.append_one(["bucket2"]).should be_true
|
61
|
+
storage.fetch(10).should == [["bucket0"], ["bucket1"], ["bucket2"]]
|
62
|
+
end
|
63
|
+
|
64
|
+
it "should not append and return false if storage size over the limit" do
|
65
|
+
storage.stub!(:file_size).and_return(2)
|
66
|
+
storage.stub!(:limit).and_return(1)
|
67
|
+
storage.append_one(["bucket"]).should be_false
|
68
|
+
storage.fetch.should == []
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe "#empty?" do
|
73
|
+
it "should return true for fresh storage" do
|
74
|
+
storage.should be_empty
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should return false when storage has buckets" do
|
78
|
+
storage.append_one(["bucket"])
|
79
|
+
storage.should_not be_empty
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context "null instance" do
|
85
|
+
let(:storage) { described_class.create(nil) }
|
86
|
+
|
87
|
+
specify { storage.fetch.should == [] }
|
88
|
+
|
89
|
+
specify { storage.append([["bucket0"], ["bucket1"]]).should == false }
|
90
|
+
|
91
|
+
specify { storage.append_one(["bucket"]).should == false }
|
92
|
+
|
93
|
+
specify { storage.empty?.should == true }
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe FluidFeatures::Persistence::Features do
|
4
|
+
context "instance" do
|
5
|
+
let(:storage) { described_class.create(config["cache"]) }
|
6
|
+
|
7
|
+
it "#list should return all features" do
|
8
|
+
storage.replace({ feature: "foo" })
|
9
|
+
storage.list.should == ({ feature: "foo" })
|
10
|
+
end
|
11
|
+
|
12
|
+
it "#replace should replace features with passed hash" do
|
13
|
+
storage.replace({ feature: "foo" }).should == true
|
14
|
+
end
|
15
|
+
|
16
|
+
it "#list_unknown should return unknown_features features" do
|
17
|
+
storage.replace_unknown({ feature: "foo" })
|
18
|
+
storage.list_unknown.should == ({ feature: "foo" })
|
19
|
+
end
|
20
|
+
|
21
|
+
it "#replace_unknown should replace unknown_features with passed hash" do
|
22
|
+
storage.replace_unknown({ feature: "foo" }).should == true
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context "null instance" do
|
27
|
+
let(:storage) { described_class.create(nil) }
|
28
|
+
|
29
|
+
specify { storage.list.should == {} }
|
30
|
+
|
31
|
+
specify { storage.list_unknown.should == {} }
|
32
|
+
|
33
|
+
specify { storage.replace([["bucket0"], ["bucket1"]]).should == false }
|
34
|
+
|
35
|
+
specify { storage.replace_unknown([["bucket0"], ["bucket1"]]).should == false }
|
36
|
+
end
|
37
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,9 +1,8 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
1
|
+
|
2
|
+
unless ENV["FLUIDFEATURES_APPID"]
|
3
|
+
$stderr.puts "Expect ENV variabled FLUIDFEATURES_APPID FLUIDFEATURES_SECRET FLUIDFEATURES_BASEURI to run the tests"
|
4
|
+
exit!
|
5
|
+
end
|
7
6
|
|
8
7
|
require 'vcr'
|
9
8
|
require 'fluidfeatures'
|
@@ -17,6 +16,7 @@ VCR.configure do |c|
|
|
17
16
|
end
|
18
17
|
|
19
18
|
RSpec.configure do |config|
|
19
|
+
config.fail_fast = true
|
20
20
|
config.treat_symbols_as_metadata_keys_with_true_values = true
|
21
21
|
config.run_all_when_everything_filtered = true
|
22
22
|
config.filter_run :focus
|
@@ -25,4 +25,9 @@ RSpec.configure do |config|
|
|
25
25
|
|
26
26
|
config.extend VCR::RSpec::Macros
|
27
27
|
config.include FluidFeatures::ApiHelpers
|
28
|
+
|
29
|
+
config.before(:each) do
|
30
|
+
dir = File.join(File.dirname(__FILE__), "tmp")
|
31
|
+
FileUtils.rm_rf(dir)
|
32
|
+
end
|
28
33
|
end
|
data/spec/support/api_helpers.rb
CHANGED
@@ -1,11 +1,17 @@
|
|
1
1
|
module FluidFeatures
|
2
2
|
module ApiHelpers
|
3
|
-
def
|
4
|
-
|
3
|
+
def config
|
4
|
+
{
|
5
|
+
"cache" => { "enable" => true, "dir" => "spec/tmp", "limit" => 1024 ** 2 },
|
6
|
+
"baseuri" => ENV["FLUIDFEATURES_BASEURI"],
|
7
|
+
"appid" => ENV["FLUIDFEATURES_APPID"],
|
8
|
+
"secret" => ENV["FLUIDFEATURES_SECRET"],
|
9
|
+
"logger" => Logger.new("/dev/null")
|
10
|
+
}
|
5
11
|
end
|
6
12
|
|
7
13
|
def app
|
8
|
-
@app ||= FluidFeatures.app(
|
14
|
+
@app ||= FluidFeatures.app(config)
|
9
15
|
end
|
10
16
|
|
11
17
|
def transaction
|
@@ -16,7 +22,6 @@ module FluidFeatures
|
|
16
22
|
def commit(transaction)
|
17
23
|
transaction.end_transaction
|
18
24
|
@transaction = transaction = nil
|
19
|
-
#Thread.list.map &:join
|
20
25
|
end
|
21
26
|
|
22
27
|
#time to sleep waiting for thread
|
@@ -1,14 +1,13 @@
|
|
1
1
|
shared_examples "polling loop" do |payload_method|
|
2
|
-
|
3
2
|
let(:app) { mock "FluidFeatures::App" }
|
4
3
|
|
5
4
|
before(:each) do
|
6
5
|
app.stub!(:is_a?).and_return(false)
|
7
6
|
app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
|
7
|
+
FluidFeatures.stub(:config).and_return(config)
|
8
8
|
end
|
9
9
|
|
10
10
|
context "initialization" do
|
11
|
-
|
12
11
|
before(:each) do
|
13
12
|
described_class.any_instance.stub(:configure)
|
14
13
|
described_class.any_instance.stub(:run_loop)
|
@@ -38,5 +37,4 @@ shared_examples "polling loop" do |payload_method|
|
|
38
37
|
it "should initialize @app" do
|
39
38
|
described_class.new(app).app.should == app
|
40
39
|
end
|
41
|
-
|
42
40
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class String
|
2
|
+
def const
|
3
|
+
names = self.split('::')
|
4
|
+
names.shift if names.empty? || names.first.empty?
|
5
|
+
|
6
|
+
constant = Object
|
7
|
+
names.each do |name|
|
8
|
+
constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
|
9
|
+
end
|
10
|
+
constant
|
11
|
+
end
|
12
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fluidfeatures
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2013-01-08 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: persistent_http
|
@@ -163,7 +163,11 @@ files:
|
|
163
163
|
- lib/fluidfeatures/app/transaction.rb
|
164
164
|
- lib/fluidfeatures/app/user.rb
|
165
165
|
- lib/fluidfeatures/client.rb
|
166
|
+
- lib/fluidfeatures/config.rb
|
166
167
|
- lib/fluidfeatures/const.rb
|
168
|
+
- lib/fluidfeatures/persistence/buckets.rb
|
169
|
+
- lib/fluidfeatures/persistence/features.rb
|
170
|
+
- lib/fluidfeatures/persistence/storage.rb
|
167
171
|
- lib/fluidfeatures/version.rb
|
168
172
|
- lib/pre_ruby192/uri.rb
|
169
173
|
- spec/app/feature_spec.rb
|
@@ -174,10 +178,16 @@ files:
|
|
174
178
|
- spec/app_spec.rb
|
175
179
|
- spec/cassettes/feature.yml
|
176
180
|
- spec/cassettes/goal.yml
|
177
|
-
- spec/
|
181
|
+
- spec/config_spec.rb
|
182
|
+
- spec/fixtures/fluidfeatures.yml
|
183
|
+
- spec/integration/fluidfeatures_spec.rb
|
184
|
+
- spec/persistence/buckets_spec.rb
|
185
|
+
- spec/persistence/features_spec.rb
|
178
186
|
- spec/spec_helper.rb
|
179
187
|
- spec/support/api_helpers.rb
|
188
|
+
- spec/support/hash_ext.rb
|
180
189
|
- spec/support/polling_loop_shared_examples.rb
|
190
|
+
- spec/support/string_ext.rb
|
181
191
|
homepage: https://github.com/FluidFeatures/fluidfeatures-ruby
|
182
192
|
licenses: []
|
183
193
|
post_install_message:
|