fluidfeatures 0.5.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +0 -1
- data/README.md +2 -2
- data/lib/fluidfeatures/app/reporter.rb +72 -34
- data/lib/fluidfeatures/app/state.rb +95 -35
- data/lib/fluidfeatures/app/transaction.rb +1 -1
- data/lib/fluidfeatures/client.rb +4 -0
- data/lib/fluidfeatures/config.rb +41 -8
- data/lib/fluidfeatures/exceptions.rb +16 -0
- data/lib/fluidfeatures/persistence/buckets.rb +4 -4
- data/lib/fluidfeatures/persistence/features.rb +9 -9
- data/lib/fluidfeatures/persistence/storage.rb +6 -9
- data/lib/fluidfeatures/version.rb +1 -1
- data/lib/fluidfeatures.rb +5 -5
- data/spec/app/reporter_spec.rb +15 -4
- data/spec/app/state_spec.rb +18 -7
- data/spec/config_spec.rb +27 -21
- data/spec/fixtures/fluidfeatures.yml +4 -4
- data/spec/integration/fluidfeatures_spec.rb +23 -5
- data/spec/persistence/features_spec.rb +2 -2
- data/spec/spec_helper.rb +0 -5
- data/spec/support/api_helpers.rb +3 -3
- data/spec/support/polling_loop_shared_examples.rb +3 -9
- metadata +3 -2
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -35,8 +35,8 @@ Call `app` on the `FluidFeatures` module to instantiate this object.
|
|
35
35
|
require 'fluidfeatures'
|
36
36
|
|
37
37
|
config = {
|
38
|
-
"
|
39
|
-
"
|
38
|
+
"base_uri" => "https://www.fluidfeatures.com/service"
|
39
|
+
"app_id" => "1vu33ki6emqe3"
|
40
40
|
"secret" = "sssseeecrrreeetttt"
|
41
41
|
}
|
42
42
|
|
@@ -27,13 +27,26 @@ module FluidFeatures
|
|
27
27
|
|
28
28
|
def initialize(app)
|
29
29
|
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
30
|
+
@sending = false
|
30
31
|
configure(app)
|
31
|
-
run_loop
|
32
32
|
at_exit do
|
33
33
|
buckets_storage.append(@buckets)
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
+
def start_sending
|
38
|
+
return if @sending
|
39
|
+
@sending = true
|
40
|
+
run_loop
|
41
|
+
end
|
42
|
+
|
43
|
+
def stop_sending(wait=false)
|
44
|
+
@sending = false
|
45
|
+
if wait
|
46
|
+
@loop_thread.join if @loop_thread and @loop_thread.alive?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
37
50
|
def buckets_storage
|
38
51
|
@buckets_storage ||= FluidFeatures::Persistence::Buckets.create(FluidFeatures.config["cache"])
|
39
52
|
end
|
@@ -96,48 +109,66 @@ module FluidFeatures
|
|
96
109
|
features_storage.replace_unknown(@unknown_features)
|
97
110
|
end
|
98
111
|
|
112
|
+
start_sending unless @sending
|
99
113
|
end
|
100
114
|
|
101
115
|
def run_loop
|
102
|
-
Thread.new do
|
103
|
-
while true
|
104
|
-
begin
|
105
116
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
117
|
+
return unless @sending
|
118
|
+
return if @loop_thread and @loop_thread.alive?
|
119
|
+
|
120
|
+
@loop_thread = Thread.new do
|
121
|
+
while @sending
|
122
|
+
run_loop_iteration(
|
123
|
+
WAIT_BETWEEN_QUEUE_EMTPY_CHECKS,
|
124
|
+
WAIT_BETWEEN_SEND_SUCCESS_NONE_WAITING,
|
125
|
+
WAIT_BETWEEN_SEND_SUCCESS_NEXT_WAITING,
|
126
|
+
WAIT_BETWEEN_SEND_FAILURES
|
127
|
+
)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
110
131
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
# If service is down, then slow our requests
|
123
|
-
# within this thread
|
124
|
-
sleep WAIT_BETWEEN_SEND_FAILURES
|
125
|
-
end
|
132
|
+
def run_loop_iteration(
|
133
|
+
wait_between_queue_emtpy_checks,
|
134
|
+
wait_between_send_success_none_waiting,
|
135
|
+
wait_between_send_success_next_waiting,
|
136
|
+
wait_between_send_failures)
|
137
|
+
begin
|
138
|
+
|
139
|
+
unless transactions_queued?
|
140
|
+
sleep wait_between_queue_emtpy_checks
|
141
|
+
return
|
142
|
+
end
|
126
143
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
144
|
+
success = send_transactions
|
145
|
+
|
146
|
+
if success
|
147
|
+
# Unless we have a full bucket waiting do not make
|
148
|
+
# more than N requests per second.
|
149
|
+
if bucket_count <= 1
|
150
|
+
sleep wait_between_send_success_none_waiting
|
151
|
+
else
|
152
|
+
sleep wait_between_send_success_next_waiting
|
132
153
|
end
|
154
|
+
else
|
155
|
+
# If service is down, then slow our requests
|
156
|
+
# within this thread
|
157
|
+
sleep wait_between_send_failures
|
133
158
|
end
|
159
|
+
|
160
|
+
rescue Exception => err
|
161
|
+
# catch errors, so that we do not affect the rest of the application
|
162
|
+
app.logger.error "[FF] send_transactions failed : #{err.message}\n#{err.backtrace.join("\n")}"
|
163
|
+
# hold off for a little while and try again
|
164
|
+
sleep wait_between_send_failures
|
134
165
|
end
|
135
166
|
end
|
136
167
|
|
137
168
|
@private
|
138
169
|
def transactions_queued?
|
139
170
|
have_transactions = false
|
140
|
-
|
171
|
+
buckets_lock_synchronize do
|
141
172
|
if @buckets.size == 1
|
142
173
|
@current_bucket_lock.synchronize do
|
143
174
|
if @current_bucket.size > 0
|
@@ -163,7 +194,7 @@ module FluidFeatures
|
|
163
194
|
end
|
164
195
|
|
165
196
|
remaining_buckets_stats = nil
|
166
|
-
|
197
|
+
buckets_lock_synchronize do
|
167
198
|
remaining_buckets_stats = @buckets.map { |b| b.size }
|
168
199
|
end
|
169
200
|
|
@@ -205,7 +236,7 @@ module FluidFeatures
|
|
205
236
|
@private
|
206
237
|
def bucket_count
|
207
238
|
num_buckets = 0
|
208
|
-
|
239
|
+
buckets_lock_synchronize do
|
209
240
|
num_buckets = @buckets.size
|
210
241
|
end
|
211
242
|
num_buckets
|
@@ -214,7 +245,7 @@ module FluidFeatures
|
|
214
245
|
@private
|
215
246
|
def new_bucket
|
216
247
|
bucket = []
|
217
|
-
|
248
|
+
buckets_lock_synchronize do
|
218
249
|
@buckets << bucket
|
219
250
|
if @buckets.size > MAX_BUCKETS
|
220
251
|
#offload to storage
|
@@ -229,7 +260,7 @@ module FluidFeatures
|
|
229
260
|
@private
|
230
261
|
def remove_bucket
|
231
262
|
removed_bucket = nil
|
232
|
-
|
263
|
+
buckets_lock_synchronize do
|
233
264
|
#try to get buckets from storage first
|
234
265
|
if @buckets.empty? && !buckets_storage.empty?
|
235
266
|
@buckets = buckets_storage.fetch(MAX_BUCKETS)
|
@@ -251,7 +282,7 @@ module FluidFeatures
|
|
251
282
|
@private
|
252
283
|
def unremove_bucket(bucket)
|
253
284
|
success = false
|
254
|
-
|
285
|
+
buckets_lock_synchronize do
|
255
286
|
if @buckets.size <= MAX_BUCKETS
|
256
287
|
@buckets.unshift bucket
|
257
288
|
success = true
|
@@ -292,5 +323,12 @@ module FluidFeatures
|
|
292
323
|
end
|
293
324
|
end
|
294
325
|
|
326
|
+
@private
|
327
|
+
def buckets_lock_synchronize
|
328
|
+
@buckets_lock.synchronize do
|
329
|
+
yield
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
295
333
|
end
|
296
334
|
end
|
@@ -4,6 +4,7 @@ require "thread"
|
|
4
4
|
|
5
5
|
require "fluidfeatures/const"
|
6
6
|
require "fluidfeatures/app/transaction"
|
7
|
+
require "fluidfeatures/exceptions"
|
7
8
|
|
8
9
|
module FluidFeatures
|
9
10
|
class AppState
|
@@ -29,70 +30,121 @@ module FluidFeatures
|
|
29
30
|
|
30
31
|
def initialize(app)
|
31
32
|
raise "app invalid : #{app}" unless app.is_a? ::FluidFeatures::App
|
33
|
+
@receiving = false
|
32
34
|
configure(app)
|
33
|
-
run_loop
|
34
35
|
end
|
35
36
|
|
36
37
|
def configure(app)
|
37
38
|
@app = app
|
38
|
-
@features =
|
39
|
+
@features = nil
|
39
40
|
@features_lock = ::Mutex.new
|
40
41
|
end
|
41
42
|
|
43
|
+
def start_receiving
|
44
|
+
return if @receiving
|
45
|
+
@receiving = true
|
46
|
+
run_loop
|
47
|
+
end
|
48
|
+
|
49
|
+
def stop_receiving(wait=false)
|
50
|
+
@receiving = false
|
51
|
+
if wait
|
52
|
+
@loop_thread.join if @loop_thread and @loop_thread.alive?
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
42
56
|
def features_storage
|
43
57
|
@features_storage ||= FluidFeatures::Persistence::Features.create(FluidFeatures.config["cache"])
|
44
58
|
end
|
45
59
|
|
46
60
|
def features
|
47
61
|
f = nil
|
48
|
-
@
|
49
|
-
|
62
|
+
if @receiving
|
63
|
+
# use features loaded in background
|
64
|
+
features_lock_synchronize do
|
65
|
+
f = @features
|
66
|
+
end
|
67
|
+
end
|
68
|
+
unless f
|
69
|
+
# we have not loaded features yet.
|
70
|
+
# load in foreground but do not use caching (etags)
|
71
|
+
success, state = load_state(use_cache=false)
|
72
|
+
if success
|
73
|
+
unless state
|
74
|
+
# Since we did not use etag caching, state should never
|
75
|
+
# be nil if success was true.
|
76
|
+
raise FFeaturesAppStateLoadFailure.new("Unexpected nil state returned from successful load_state(use_cache=false).")
|
77
|
+
end
|
78
|
+
self.features = f = state
|
79
|
+
else
|
80
|
+
# fluidfeatures API must be down.
|
81
|
+
# load persisted features from disk.
|
82
|
+
self.features = f = features_storage.list
|
83
|
+
end
|
84
|
+
end
|
85
|
+
# we should never return nil
|
86
|
+
unless f
|
87
|
+
# If we still could not load state then croak
|
88
|
+
raise FFeaturesAppStateLoadFailure.new("Could not load features state from API: #{state}")
|
89
|
+
end
|
90
|
+
unless @receiving
|
91
|
+
# start background receiver loop
|
92
|
+
start_receiving
|
50
93
|
end
|
51
94
|
f
|
52
95
|
end
|
53
96
|
|
54
97
|
def features= f
|
55
98
|
return unless f.is_a? Hash
|
56
|
-
|
57
|
-
features_storage.replace(f)
|
99
|
+
features_lock_synchronize do
|
100
|
+
features_storage.replace(f)
|
58
101
|
@features = f
|
59
102
|
end
|
103
|
+
f
|
60
104
|
end
|
61
105
|
|
62
106
|
def run_loop
|
63
|
-
Thread.new do
|
64
|
-
while true
|
65
|
-
begin
|
66
|
-
|
67
|
-
success, state = load_state
|
68
|
-
|
69
|
-
# Note, success could be true, but state might be nil.
|
70
|
-
# This occurs with 304 (no change)
|
71
|
-
if success and state
|
72
|
-
# switch out current state with new one
|
73
|
-
self.features = state
|
74
|
-
elsif not success
|
75
|
-
# If service is down, then slow our requests
|
76
|
-
# within this thread
|
77
|
-
sleep WAIT_BETWEEN_FETCH_FAILURES
|
78
|
-
end
|
79
107
|
|
80
|
-
|
81
|
-
|
82
|
-
sleep WAIT_BETWEEN_FETCH_SUCCESS
|
108
|
+
return unless @receiving
|
109
|
+
return if @loop_thread and @loop_thread.alive?
|
83
110
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
111
|
+
@loop_thread = Thread.new do
|
112
|
+
while @receiving
|
113
|
+
run_loop_iteration(WAIT_BETWEEN_FETCH_SUCCESS, WAIT_BETWEEN_FETCH_FAILURES)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def run_loop_iteration(wait_between_fetch_success, wait_between_fetch_failures)
|
119
|
+
begin
|
120
|
+
|
121
|
+
success, state = load_state
|
122
|
+
|
123
|
+
# Note, success could be true, but state might be nil.
|
124
|
+
# This occurs with 304 (no change)
|
125
|
+
if success and state
|
126
|
+
# switch out current state with new one
|
127
|
+
self.features = state
|
128
|
+
elsif not success
|
129
|
+
# If service is down, then slow our requests
|
130
|
+
# within this thread
|
131
|
+
sleep wait_between_fetch_failures
|
90
132
|
end
|
133
|
+
|
134
|
+
# What ever happens never make more than N requests
|
135
|
+
# per second
|
136
|
+
sleep wait_between_fetch_success
|
137
|
+
|
138
|
+
rescue Exception => err
|
139
|
+
# catch errors, so that we do not affect the rest of the application
|
140
|
+
app.logger.error "load_state failed : #{err.message}\n#{err.backtrace.join("\n")}"
|
141
|
+
# hold off for a little while and try again
|
142
|
+
sleep wait_between_fetch_failures
|
91
143
|
end
|
92
144
|
end
|
93
145
|
|
94
|
-
def load_state
|
95
|
-
success, state = app.get("/features", { :verbose => true, :etag_wait => ETAG_WAIT },
|
146
|
+
def load_state(use_cache=true)
|
147
|
+
success, state = app.get("/features", { :verbose => true, :etag_wait => ETAG_WAIT }, use_cache)
|
96
148
|
if success and state
|
97
149
|
state.each_pair do |feature_name, feature|
|
98
150
|
feature["versions"].each_pair do |version_name, version|
|
@@ -109,8 +161,6 @@ module FluidFeatures
|
|
109
161
|
version_name ||= ::FluidFeatures::DEFAULT_VERSION_NAME
|
110
162
|
raise "version_name invalid : #{version_name}" unless version_name.is_a? String
|
111
163
|
|
112
|
-
#assert(isinstance(user_id, basestring))
|
113
|
-
|
114
164
|
user_attributes ||= {}
|
115
165
|
user_attributes["user"] = user_id.to_s
|
116
166
|
if user_id.is_a? Integer
|
@@ -123,7 +173,10 @@ module FluidFeatures
|
|
123
173
|
enabled = false
|
124
174
|
|
125
175
|
feature = features[feature_name]
|
176
|
+
return false unless feature
|
126
177
|
version = feature["versions"][version_name]
|
178
|
+
return false unless version
|
179
|
+
|
127
180
|
modulus = user_id_hash % feature["num_parts"]
|
128
181
|
enabled = version["parts"].include? modulus
|
129
182
|
|
@@ -150,5 +203,12 @@ module FluidFeatures
|
|
150
203
|
enabled
|
151
204
|
end
|
152
205
|
|
206
|
+
@private
|
207
|
+
def features_lock_synchronize
|
208
|
+
@features_lock.synchronize do
|
209
|
+
yield
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
153
213
|
end
|
154
214
|
end
|
data/lib/fluidfeatures/client.rb
CHANGED
@@ -7,6 +7,7 @@ require "uuid"
|
|
7
7
|
require "json"
|
8
8
|
|
9
9
|
require "fluidfeatures/app"
|
10
|
+
require 'fluidfeatures/exceptions'
|
10
11
|
|
11
12
|
module FluidFeatures
|
12
13
|
class Client
|
@@ -20,6 +21,9 @@ module FluidFeatures
|
|
20
21
|
|
21
22
|
def initialize(base_uri, logger)
|
22
23
|
|
24
|
+
raise FFeaturesBadParam.new("base_uri is not String, is #{base_uri.class}") \
|
25
|
+
unless base_uri.is_a? String
|
26
|
+
|
23
27
|
@uuid = UUID.new.generate
|
24
28
|
@logger = logger
|
25
29
|
@base_uri = base_uri
|
data/lib/fluidfeatures/config.rb
CHANGED
@@ -1,24 +1,57 @@
|
|
1
1
|
|
2
2
|
require 'yaml'
|
3
|
-
require '
|
3
|
+
require 'fluidfeatures/exceptions'
|
4
4
|
|
5
5
|
module FluidFeatures
|
6
6
|
class Config
|
7
|
+
|
7
8
|
attr_accessor :vars
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
9
|
+
|
10
|
+
def initialize(source, environment=nil)
|
11
|
+
if source.is_a? String
|
12
|
+
init_from_file(source, environment)
|
13
|
+
elsif source.is_a? Hash
|
14
|
+
init_from_hash(source)
|
15
|
+
else
|
16
|
+
raise FFeaturesConfigInvalid.new(
|
17
|
+
"Invalid 'source' given. Expected file path String or Hash. Got #{source.class}"
|
18
|
+
)
|
19
|
+
end
|
20
|
+
if @vars["cache"] and @vars["cache"]["limit"]
|
21
|
+
@vars["cache"]["limit"] = self.class.parse_file_size(vars["cache"]["limit"])
|
22
|
+
end
|
13
23
|
end
|
14
24
|
|
15
25
|
def [](name)
|
16
26
|
@vars[name.to_s]
|
17
27
|
end
|
18
28
|
|
29
|
+
private
|
30
|
+
|
31
|
+
def init_from_file path, environment
|
32
|
+
unless File.exists? path
|
33
|
+
raise FFeaturesConfigFileNotExists.new("File not found : #{path}")
|
34
|
+
end
|
35
|
+
environments = YAML.load_file path
|
36
|
+
unless environments.is_a? Hash
|
37
|
+
raise FFeaturesConfigInvalid.new("Config is invalid : #{path}")
|
38
|
+
end
|
39
|
+
@vars = (environments["common"] || {}).clone
|
40
|
+
@vars.update environments[environment] if environments[environment]
|
41
|
+
end
|
42
|
+
|
43
|
+
def init_from_hash hash
|
44
|
+
@vars = hash.clone
|
45
|
+
end
|
46
|
+
|
19
47
|
def self.parse_file_size(size)
|
20
|
-
return size if
|
21
|
-
|
48
|
+
return size if size.is_a? Numeric
|
49
|
+
return size.to_i if size.is_a? String and size.match /^\d+$/
|
50
|
+
unless size.is_a? String and (/^(\d+)\s*(k|m|g)b$/i).match(size)
|
51
|
+
raise FFeaturesConfigInvalid.new("Invalid file size string in config : '#{size}'")
|
52
|
+
end
|
53
|
+
$1.to_i * 1024 ** ("kmg".index($2.downcase) + 1)
|
22
54
|
end
|
55
|
+
|
23
56
|
end
|
24
57
|
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
|
2
|
+
class FFeaturesException < Exception
|
3
|
+
end
|
4
|
+
|
5
|
+
class FFeaturesConfigInvalid < FFeaturesException
|
6
|
+
end
|
7
|
+
|
8
|
+
class FFeaturesConfigFileNotExists < FFeaturesConfigInvalid
|
9
|
+
end
|
10
|
+
|
11
|
+
class FFeaturesBadParam < FFeaturesException
|
12
|
+
end
|
13
|
+
|
14
|
+
class FFeaturesAppStateLoadFailure < FFeaturesException
|
15
|
+
end
|
16
|
+
|
@@ -3,14 +3,14 @@ module FluidFeatures
|
|
3
3
|
class Buckets < Storage
|
4
4
|
attr_accessor :limit
|
5
5
|
|
6
|
-
def self.create(config)
|
6
|
+
def self.create(config, logger=nil)
|
7
7
|
return NullBuckets.new unless config && config["dir"] && config["enable"] && config["limit"] > 0
|
8
|
-
new(config)
|
8
|
+
new(config, logger)
|
9
9
|
end
|
10
10
|
|
11
|
-
def initialize(config)
|
11
|
+
def initialize(config, logger=nil)
|
12
12
|
self.limit = config["limit"]
|
13
|
-
super
|
13
|
+
super
|
14
14
|
end
|
15
15
|
|
16
16
|
def fetch(n = 1)
|
@@ -2,18 +2,17 @@ module FluidFeatures
|
|
2
2
|
module Persistence
|
3
3
|
class Features < Storage
|
4
4
|
|
5
|
-
def self.create(config)
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
super config
|
5
|
+
def self.create(config, logger=nil)
|
6
|
+
if config && config["dir"] && config["enable"]
|
7
|
+
new(config, logger)
|
8
|
+
else
|
9
|
+
NullFeatures.new
|
10
|
+
end
|
12
11
|
end
|
13
12
|
|
14
13
|
def list
|
15
14
|
store.transaction(true) do
|
16
|
-
return
|
15
|
+
return nil unless store && store["features"]
|
17
16
|
store["features"]
|
18
17
|
end
|
19
18
|
end
|
@@ -25,6 +24,7 @@ module FluidFeatures
|
|
25
24
|
end
|
26
25
|
end
|
27
26
|
|
27
|
+
# TODO: remove. we do not care about persisting these
|
28
28
|
def list_unknown
|
29
29
|
store.transaction(true) do
|
30
30
|
return {} unless store && store["unknown_features"]
|
@@ -53,7 +53,7 @@ module FluidFeatures
|
|
53
53
|
end
|
54
54
|
|
55
55
|
class NullFeatures
|
56
|
-
def list;
|
56
|
+
def list; nil end
|
57
57
|
def list_unknown; {} end
|
58
58
|
def replace(*args); false end
|
59
59
|
def replace_unknown(*args); false end
|
@@ -5,10 +5,10 @@ module FluidFeatures
|
|
5
5
|
class Storage
|
6
6
|
attr_accessor :dir, :file_name, :store, :logger
|
7
7
|
|
8
|
-
def initialize(config)
|
9
|
-
|
10
|
-
|
11
|
-
|
8
|
+
def initialize(config, logger=nil)
|
9
|
+
@dir = config["dir"]
|
10
|
+
@logger = logger || Logger.new(STDERR)
|
11
|
+
@file_name = "#{self.class.to_s.split('::').last.downcase}.pstore"
|
12
12
|
FileUtils.mkpath(dir) unless Dir.exists?(dir)
|
13
13
|
end
|
14
14
|
|
@@ -21,11 +21,8 @@ module FluidFeatures
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def file_size
|
24
|
-
|
25
|
-
|
26
|
-
rescue Exception => _
|
27
|
-
return 0
|
28
|
-
end
|
24
|
+
# TODO: rescue should return nil here
|
25
|
+
File.size(path) rescue 0
|
29
26
|
end
|
30
27
|
end
|
31
28
|
end
|
data/lib/fluidfeatures.rb
CHANGED
@@ -15,10 +15,10 @@ module FluidFeatures
|
|
15
15
|
attr_accessor :config
|
16
16
|
end
|
17
17
|
|
18
|
-
def self.app(config)
|
19
|
-
|
18
|
+
def self.app(config, logger=nil)
|
19
|
+
logger ||= ::Logger.new(STDERR)
|
20
20
|
self.config = config
|
21
|
-
client = ::FluidFeatures::Client.new(config["
|
22
|
-
::FluidFeatures::App.new(client, config["
|
21
|
+
client = ::FluidFeatures::Client.new(config["base_uri"], logger)
|
22
|
+
::FluidFeatures::App.new(client, config["app_id"], config["secret"], logger)
|
23
23
|
end
|
24
|
-
end
|
24
|
+
end
|
data/spec/app/reporter_spec.rb
CHANGED
@@ -2,7 +2,7 @@ require 'spec_helper'
|
|
2
2
|
|
3
3
|
describe FluidFeatures::AppReporter do
|
4
4
|
|
5
|
-
it_should_behave_like "polling loop"
|
5
|
+
it_should_behave_like "polling loop" do
|
6
6
|
before(:each) { described_class.any_instance.stub(:transactions_queued?).and_return(true) }
|
7
7
|
end
|
8
8
|
|
@@ -15,15 +15,15 @@ describe FluidFeatures::AppReporter do
|
|
15
15
|
let(:transaction) { mock('transaction', url: 'http://example.com/source.html', duration: 999, user: user, unknown_features: [], features_hit: %w[feature], goals_hit: %w[goal]) }
|
16
16
|
|
17
17
|
before(:each) do
|
18
|
-
described_class.any_instance.stub(:run_loop)
|
19
18
|
FluidFeatures.stub(:config).and_return(config)
|
20
19
|
end
|
21
20
|
|
22
21
|
describe "#features_storage" do
|
23
22
|
before(:each) do
|
24
23
|
described_class.any_instance.stub(:configure)
|
25
|
-
FluidFeatures::Persistence::Features
|
26
|
-
|
24
|
+
FluidFeatures::Persistence::Features
|
25
|
+
.should_receive(:create).with(config["cache"])
|
26
|
+
.and_return(FluidFeatures::Persistence::NullFeatures.new)
|
27
27
|
end
|
28
28
|
|
29
29
|
it "should create features storage" do
|
@@ -184,6 +184,17 @@ describe FluidFeatures::AppReporter do
|
|
184
184
|
end
|
185
185
|
end
|
186
186
|
|
187
|
+
describe "#start_sending" do
|
188
|
+
it "#start_sending should call send_transactions" do
|
189
|
+
reporter.should_receive(:transactions_queued?).and_return(true)
|
190
|
+
# send_transactions needs to return success=true to prevent throttling
|
191
|
+
reporter.should_receive(:send_transactions).and_return(true)
|
192
|
+
reporter.start_sending
|
193
|
+
sleep(0.001) # wait for thread to start
|
194
|
+
reporter.stop_sending(wait=true)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
187
198
|
describe "#send_transactions" do
|
188
199
|
let(:unknown_features) { { "feature" => { "a" => true } } }
|
189
200
|
|
data/spec/app/state_spec.rb
CHANGED
@@ -4,7 +4,7 @@ require "fluidfeatures/persistence/features"
|
|
4
4
|
|
5
5
|
describe FluidFeatures::AppState do
|
6
6
|
|
7
|
-
it_should_behave_like "polling loop"
|
7
|
+
it_should_behave_like "polling loop"
|
8
8
|
|
9
9
|
context do
|
10
10
|
let(:state) { described_class.new(app) }
|
@@ -16,10 +16,14 @@ describe FluidFeatures::AppState do
|
|
16
16
|
before(:each) do
|
17
17
|
app.stub!(:is_a?).and_return(false)
|
18
18
|
app.stub!(:is_a?).with(FluidFeatures::App).and_return(true)
|
19
|
-
described_class.any_instance.stub(:run_loop)
|
20
19
|
FluidFeatures.stub(:config).and_return(config)
|
21
20
|
end
|
22
21
|
|
22
|
+
after(:each) do
|
23
|
+
# ensure the loop is shutdown
|
24
|
+
state.stop_receiving(wait=true)
|
25
|
+
end
|
26
|
+
|
23
27
|
describe "#features_storage" do
|
24
28
|
before(:each) do
|
25
29
|
described_class.any_instance.stub(:configure)
|
@@ -48,19 +52,26 @@ describe FluidFeatures::AppState do
|
|
48
52
|
features = mock("features")
|
49
53
|
features_storage.should_receive(:list).and_return(features)
|
50
54
|
state.configure(app)
|
51
|
-
state.
|
55
|
+
state.should_receive(:run_loop)
|
56
|
+
state.should_receive(:load_state).with(false).and_return([false,nil])
|
57
|
+
state.features.should == features
|
52
58
|
end
|
53
59
|
end
|
54
60
|
|
55
61
|
describe "#features=" do
|
56
|
-
it "should replace features
|
62
|
+
it "should replace features" do
|
57
63
|
state.features_storage.should_receive(:replace).with({foo: "bar"})
|
58
64
|
state.features = {foo: "bar"}
|
59
65
|
end
|
66
|
+
end
|
60
67
|
|
61
|
-
|
62
|
-
|
63
|
-
|
68
|
+
describe "#start_receiving" do
|
69
|
+
it "#start_receiving should call load_state" do
|
70
|
+
# load_state needs to return success=true to prevent throttling
|
71
|
+
state.should_receive(:load_state).and_return([true,{}])
|
72
|
+
state.start_receiving
|
73
|
+
sleep(0.001) # wait for thread to start
|
74
|
+
state.stop_receiving(wait=true)
|
64
75
|
end
|
65
76
|
end
|
66
77
|
|
data/spec/config_spec.rb
CHANGED
@@ -1,18 +1,18 @@
|
|
1
1
|
require "spec_helper"
|
2
|
+
require 'fluidfeatures/exceptions'
|
2
3
|
|
3
4
|
describe FluidFeatures::Config do
|
4
|
-
let(:
|
5
|
+
let(:source) { "#{File.dirname(__FILE__)}/fixtures/fluidfeatures.yml" }
|
5
6
|
|
6
7
|
let(:env) { "development" }
|
7
8
|
|
8
|
-
let(:
|
9
|
-
|
10
|
-
let(:config) { FluidFeatures::Config.new(path, env, replacements) }
|
9
|
+
let(:config) { FluidFeatures::Config.new(source, env) }
|
11
10
|
|
12
11
|
context "parse file size" do
|
13
|
-
[nil,
|
14
|
-
it "should
|
15
|
-
described_class.parse_file_size(input)
|
12
|
+
[nil, "foo", Object.new, Object, [], {} ].each do |input|
|
13
|
+
it "should raise exception for non-matching #{input.class.to_s} #{input||'nil'}" do
|
14
|
+
expect { described_class.parse_file_size(input) }
|
15
|
+
.to raise_error(FFeaturesConfigInvalid)
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
@@ -21,6 +21,12 @@ describe FluidFeatures::Config do
|
|
21
21
|
described_class.parse_file_size(i).should == o
|
22
22
|
end
|
23
23
|
end
|
24
|
+
|
25
|
+
{ "123" => 123, "456" => 456 }.each do |i, o|
|
26
|
+
it "should read '#{i}' as #{o}" do
|
27
|
+
described_class.parse_file_size(i).should == o
|
28
|
+
end
|
29
|
+
end
|
24
30
|
end
|
25
31
|
|
26
32
|
%w{test development production}.each do |env_name|
|
@@ -29,25 +35,25 @@ describe FluidFeatures::Config do
|
|
29
35
|
|
30
36
|
it "should load environment configuration from yml file and merge common section" do
|
31
37
|
config.vars.should == {
|
32
|
-
"
|
38
|
+
"base_uri" => "base_uri",
|
33
39
|
"cache" => { "enable" => false, "dir" => "cache_dir", "limit" => 2097152 },
|
34
|
-
"
|
40
|
+
"app_id" => "#{env_name}_app_id",
|
35
41
|
"secret" => "#{env_name}_secret"
|
36
42
|
}
|
37
43
|
end
|
44
|
+
end
|
45
|
+
end
|
38
46
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
}
|
49
|
-
end
|
50
|
-
end
|
47
|
+
context "with hash config" do
|
48
|
+
let(:source) { { "base_uri" => "env_base_uri", "app_id" => "env_app_id", "secret" => "env_secret" } }
|
49
|
+
|
50
|
+
it "should update variables with passed hash" do
|
51
|
+
config.vars.should == {
|
52
|
+
"base_uri" => "env_base_uri",
|
53
|
+
"app_id" => "env_app_id",
|
54
|
+
"secret" => "env_secret"
|
55
|
+
}
|
51
56
|
end
|
52
57
|
end
|
58
|
+
|
53
59
|
end
|
@@ -1,18 +1,18 @@
|
|
1
1
|
common:
|
2
|
-
|
2
|
+
base_uri: base_uri
|
3
3
|
cache:
|
4
4
|
enable: false
|
5
5
|
dir: cache_dir
|
6
6
|
limit: 2mb
|
7
7
|
|
8
8
|
development:
|
9
|
-
|
9
|
+
app_id: development_app_id
|
10
10
|
secret: development_secret
|
11
11
|
|
12
12
|
test:
|
13
|
-
|
13
|
+
app_id: test_app_id
|
14
14
|
secret: test_secret
|
15
15
|
|
16
16
|
production:
|
17
|
-
|
17
|
+
app_id: production_app_id
|
18
18
|
secret: production_secret
|
@@ -4,12 +4,12 @@ describe "FluidFeatures" do
|
|
4
4
|
|
5
5
|
describe "App initialization" do
|
6
6
|
it "should raise 'host not set' without valid base uri" do
|
7
|
-
expect { FluidFeatures.app(config.remove("
|
8
|
-
to raise_error(
|
7
|
+
expect { FluidFeatures.app(config.remove("base_uri")) }.
|
8
|
+
to raise_error(FFeaturesBadParam, /base_uri/)
|
9
9
|
end
|
10
10
|
|
11
11
|
it "should raise 'app_id invalid' without valid app id" do
|
12
|
-
expect { FluidFeatures.app(config.remove("
|
12
|
+
expect { FluidFeatures.app(config.remove("app_id")) }.
|
13
13
|
to raise_error(StandardError, /app_id invalid/)
|
14
14
|
end
|
15
15
|
|
@@ -19,7 +19,8 @@ describe "FluidFeatures" do
|
|
19
19
|
end
|
20
20
|
|
21
21
|
it "should set @config class variable to passed config" do
|
22
|
-
FluidFeatures::Client.stub!(:new)
|
22
|
+
FluidFeatures::Client.stub!(:new)
|
23
|
+
FluidFeatures::App.stub!(:new)
|
23
24
|
config = mock("config", "[]" => nil, "[]=" => nil)
|
24
25
|
FluidFeatures.app(config)
|
25
26
|
FluidFeatures.config.should == config
|
@@ -32,12 +33,28 @@ describe "FluidFeatures" do
|
|
32
33
|
|
33
34
|
let(:feature) { app.features.pop[feature_name] }
|
34
35
|
|
36
|
+
before(:each) do
|
37
|
+
app.state.should_receive(:run_loop).once do
|
38
|
+
# only iterate once and not in a thread
|
39
|
+
app.state.run_loop_iteration(0,0)
|
40
|
+
end
|
41
|
+
app.reporter.should_receive(:run_loop).once do
|
42
|
+
# only iterate once and not in a thread
|
43
|
+
app.reporter.run_loop_iteration(0,0,0,0)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
after(:each) do
|
48
|
+
# ensure the loop is shutdown
|
49
|
+
app.state.stop_receiving(wait=true)
|
50
|
+
app.reporter.stop_sending(wait=true)
|
51
|
+
end
|
52
|
+
|
35
53
|
specify "#feature_enabled? should create feature" do
|
36
54
|
VCR.use_cassette('feature') do
|
37
55
|
transaction.feature_enabled?(feature_name, "a", true)
|
38
56
|
transaction.feature_enabled?(feature_name, "b", true)
|
39
57
|
commit transaction
|
40
|
-
sleep abit
|
41
58
|
feature["name"].should == feature_name
|
42
59
|
feature["versions"].size.should == 2
|
43
60
|
end
|
@@ -51,5 +68,6 @@ describe "FluidFeatures" do
|
|
51
68
|
sleep abit
|
52
69
|
end
|
53
70
|
end
|
71
|
+
|
54
72
|
end
|
55
73
|
end
|
@@ -26,7 +26,7 @@ describe FluidFeatures::Persistence::Features do
|
|
26
26
|
context "null instance" do
|
27
27
|
let(:storage) { described_class.create(nil) }
|
28
28
|
|
29
|
-
specify { storage.list.should ==
|
29
|
+
specify { storage.list.should == nil }
|
30
30
|
|
31
31
|
specify { storage.list_unknown.should == {} }
|
32
32
|
|
@@ -34,4 +34,4 @@ describe FluidFeatures::Persistence::Features do
|
|
34
34
|
|
35
35
|
specify { storage.replace_unknown([["bucket0"], ["bucket1"]]).should == false }
|
36
36
|
end
|
37
|
-
end
|
37
|
+
end
|
data/spec/spec_helper.rb
CHANGED
data/spec/support/api_helpers.rb
CHANGED
@@ -3,9 +3,9 @@ module FluidFeatures
|
|
3
3
|
def config
|
4
4
|
{
|
5
5
|
"cache" => { "enable" => true, "dir" => "spec/tmp", "limit" => 1024 ** 2 },
|
6
|
-
"
|
7
|
-
"
|
8
|
-
"secret" =>
|
6
|
+
"base_uri" => "https://www.fluidfeatures.com/service",
|
7
|
+
"app_id" => "1vu33ki6emqe3",
|
8
|
+
"secret" => "secret",
|
9
9
|
"logger" => Logger.new("/dev/null")
|
10
10
|
}
|
11
11
|
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
shared_examples "polling loop" do
|
1
|
+
shared_examples "polling loop" do
|
2
2
|
let(:app) { mock "FluidFeatures::App" }
|
3
3
|
|
4
4
|
before(:each) do
|
@@ -22,18 +22,12 @@ shared_examples "polling loop" do |payload_method|
|
|
22
22
|
described_class.new(app)
|
23
23
|
end
|
24
24
|
|
25
|
-
it "should call #run_loop" do
|
26
|
-
described_class.any_instance.
|
25
|
+
it "should not call #run_loop" do
|
26
|
+
described_class.any_instance.should_not_receive(:run_loop)
|
27
27
|
described_class.new(app)
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
31
|
-
it "#run_loop should call payload method" do
|
32
|
-
described_class.any_instance.should_receive(payload_method)
|
33
|
-
described_class.new(app)
|
34
|
-
sleep 0.15
|
35
|
-
end
|
36
|
-
|
37
31
|
it "should initialize @app" do
|
38
32
|
described_class.new(app).app.should == app
|
39
33
|
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.6.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: 2013-01-
|
12
|
+
date: 2013-01-12 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: persistent_http
|
@@ -165,6 +165,7 @@ files:
|
|
165
165
|
- lib/fluidfeatures/client.rb
|
166
166
|
- lib/fluidfeatures/config.rb
|
167
167
|
- lib/fluidfeatures/const.rb
|
168
|
+
- lib/fluidfeatures/exceptions.rb
|
168
169
|
- lib/fluidfeatures/persistence/buckets.rb
|
169
170
|
- lib/fluidfeatures/persistence/features.rb
|
170
171
|
- lib/fluidfeatures/persistence/storage.rb
|