ldclient-rb 3.0.3 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,76 @@
1
+
2
+ module LaunchDarkly
3
+ # A thread-safe cache with maximum number of entries and TTL.
4
+ # Adapted from https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/ttl/cache.rb
5
+ # under MIT license with the following changes:
6
+ # * made thread-safe
7
+ # * removed many unused methods
8
+ # * reading a key does not reset its expiration time, only writing
9
+ class ExpiringCache
10
+ def initialize(max_size, ttl)
11
+ @max_size = max_size
12
+ @ttl = ttl
13
+ @data_lru = {}
14
+ @data_ttl = {}
15
+ @lock = Mutex.new
16
+ end
17
+
18
+ def [](key)
19
+ @lock.synchronize do
20
+ ttl_evict
21
+ @data_lru[key]
22
+ end
23
+ end
24
+
25
+ def []=(key, val)
26
+ @lock.synchronize do
27
+ ttl_evict
28
+
29
+ @data_lru.delete(key)
30
+ @data_ttl.delete(key)
31
+
32
+ @data_lru[key] = val
33
+ @data_ttl[key] = Time.now.to_f
34
+
35
+ if @data_lru.size > @max_size
36
+ key, _ = @data_lru.first # hashes have a FIFO ordering in Ruby
37
+
38
+ @data_ttl.delete(key)
39
+ @data_lru.delete(key)
40
+ end
41
+
42
+ val
43
+ end
44
+ end
45
+
46
+ def delete(key)
47
+ @lock.synchronize do
48
+ ttl_evict
49
+
50
+ @data_lru.delete(key)
51
+ @data_ttl.delete(key)
52
+ end
53
+ end
54
+
55
+ def clear
56
+ @lock.synchronize do
57
+ @data_lru.clear
58
+ @data_ttl.clear
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def ttl_evict
65
+ ttl_horizon = Time.now.to_f - @ttl
66
+ key, time = @data_ttl.first
67
+
68
+ until time.nil? || time > ttl_horizon
69
+ @data_ttl.delete(key)
70
+ @data_lru.delete(key)
71
+
72
+ key, time = @data_ttl.first
73
+ end
74
+ end
75
+ end
76
+ end
@@ -28,7 +28,11 @@ module LaunchDarkly
28
28
  @config = config
29
29
  @store = config.feature_store
30
30
 
31
- @event_processor = EventProcessor.new(sdk_key, config)
31
+ if @config.offline? || !@config.send_events
32
+ @event_processor = NullEventProcessor.new
33
+ else
34
+ @event_processor = EventProcessor.new(sdk_key, config)
35
+ end
32
36
 
33
37
  if @config.use_ldd?
34
38
  @config.logger.info { "[LDClient] Started LaunchDarkly Client in LDD mode" }
@@ -38,12 +42,16 @@ module LaunchDarkly
38
42
  requestor = Requestor.new(sdk_key, config)
39
43
 
40
44
  if !@config.offline?
41
- if @config.stream?
42
- @update_processor = StreamProcessor.new(sdk_key, config, requestor)
45
+ if @config.update_processor.nil?
46
+ if @config.stream?
47
+ @update_processor = StreamProcessor.new(sdk_key, config, requestor)
48
+ else
49
+ @config.logger.info { "Disabling streaming API" }
50
+ @config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" }
51
+ @update_processor = PollingProcessor.new(config, requestor)
52
+ end
43
53
  else
44
- @config.logger.info { "Disabling streaming API" }
45
- @config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" }
46
- @update_processor = PollingProcessor.new(config, requestor)
54
+ @update_processor = @config.update_processor
47
55
  end
48
56
  @update_processor.start
49
57
  end
@@ -113,12 +121,6 @@ module LaunchDarkly
113
121
  def variation(key, user, default)
114
122
  return default if @config.offline?
115
123
 
116
- unless user
117
- @config.logger.error { "[LDClient] Must specify user" }
118
- @event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
119
- return default
120
- end
121
-
122
124
  if !initialized?
123
125
  if @store.initialized?
124
126
  @config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" }
@@ -129,7 +131,7 @@ module LaunchDarkly
129
131
  end
130
132
  end
131
133
 
132
- sanitize_user(user)
134
+ sanitize_user(user) if !user.nil?
133
135
  feature = @store.get(FEATURES, key)
134
136
 
135
137
  if feature.nil?
@@ -138,24 +140,29 @@ module LaunchDarkly
138
140
  return default
139
141
  end
140
142
 
143
+ unless user
144
+ @config.logger.error { "[LDClient] Must specify user" }
145
+ @event_processor.add_event(make_feature_event(feature, user, nil, default, default))
146
+ return default
147
+ end
148
+
141
149
  begin
142
- res = evaluate(feature, user, @store)
150
+ res = evaluate(feature, user, @store, @config.logger)
143
151
  if !res[:events].nil?
144
152
  res[:events].each do |event|
145
153
  @event_processor.add_event(event)
146
154
  end
147
155
  end
148
- if !res[:value].nil?
149
- @event_processor.add_event(kind: "feature", key: key, user: user, value: res[:value], default: default, version: feature[:version])
150
- return res[:value]
151
- else
156
+ value = res[:value]
157
+ if value.nil?
152
158
  @config.logger.debug { "[LDClient] Result value is null in toggle" }
153
- @event_processor.add_event(kind: "feature", key: key, user: user, value: default, default: default, version: feature[:version])
154
- return default
159
+ value = default
155
160
  end
161
+ @event_processor.add_event(make_feature_event(feature, user, res[:variation], value, default))
162
+ return value
156
163
  rescue => exn
157
164
  @config.logger.warn { "[LDClient] Error evaluating feature flag: #{exn.inspect}. \nTrace: #{exn.backtrace}" }
158
- @event_processor.add_event(kind: "feature", key: key, user: user, value: default, default: default, version: feature[:version])
165
+ @event_processor.add_event(make_feature_event(feature, user, nil, default, default))
159
166
  return default
160
167
  end
161
168
  end
@@ -200,7 +207,7 @@ module LaunchDarkly
200
207
  features = @store.all(FEATURES)
201
208
 
202
209
  # TODO rescue if necessary
203
- Hash[features.map{ |k, f| [k, evaluate(f, user, @store)[:value]] }]
210
+ Hash[features.map{ |k, f| [k, evaluate(f, user, @store, @config.logger)[:value]] }]
204
211
  rescue => exn
205
212
  @config.logger.warn { "[LDClient] Error evaluating all flags: #{exn.inspect}. \nTrace: #{exn.backtrace}" }
206
213
  return Hash.new
@@ -232,6 +239,20 @@ module LaunchDarkly
232
239
  end
233
240
  end
234
241
 
235
- private :evaluate, :log_exception, :sanitize_user
242
+ def make_feature_event(flag, user, variation, value, default)
243
+ {
244
+ kind: "feature",
245
+ key: flag[:key],
246
+ user: user,
247
+ variation: variation,
248
+ value: value,
249
+ default: default,
250
+ version: flag[:version],
251
+ trackEvents: flag[:trackEvents],
252
+ debugEventsUntilDate: flag[:debugEventsUntilDate]
253
+ }
254
+ end
255
+
256
+ private :evaluate, :log_exception, :sanitize_user, :make_feature_event
236
257
  end
237
258
  end
@@ -0,0 +1,46 @@
1
+ require "concurrent"
2
+ require "concurrent/atomics"
3
+ require "concurrent/executors"
4
+ require "thread"
5
+
6
+ # Simple wrapper for a FixedThreadPool that rejects new jobs if all the threads are busy, rather
7
+ # than blocking. Also provides a way to wait for all jobs to finish without shutting down.
8
+
9
+ module LaunchDarkly
10
+ class NonBlockingThreadPool
11
+ def initialize(capacity)
12
+ @capacity = capacity
13
+ @pool = Concurrent::FixedThreadPool.new(capacity)
14
+ @semaphore = Concurrent::Semaphore.new(capacity)
15
+ end
16
+
17
+ # Attempts to submit a job, but only if a worker is available. Unlike the regular post method,
18
+ # this returns a value: true if the job was submitted, false if all workers are busy.
19
+ def post
20
+ if !@semaphore.try_acquire(1)
21
+ return
22
+ end
23
+ @pool.post do
24
+ begin
25
+ yield
26
+ ensure
27
+ @semaphore.release(1)
28
+ end
29
+ end
30
+ end
31
+
32
+ # Waits until no jobs are executing, without shutting down the pool.
33
+ def wait_all
34
+ @semaphore.acquire(@capacity)
35
+ @semaphore.release(@capacity)
36
+ end
37
+
38
+ def shutdown
39
+ @pool.shutdown
40
+ end
41
+
42
+ def wait_for_termination
43
+ @pool.wait_for_termination
44
+ end
45
+ end
46
+ end
@@ -9,15 +9,14 @@ module LaunchDarkly
9
9
  # streaming API. Feature data can also be further cached in memory to reduce overhead
10
10
  # of calls to Redis.
11
11
  #
12
- # To use this class, you must first have the `redis`, `connection-pool`, and `moneta`
13
- # gems installed. Then, create an instance and store it in the `feature_store`
14
- # property of your client configuration.
12
+ # To use this class, you must first have the `redis` and `connection-pool` gems
13
+ # installed. Then, create an instance and store it in the `feature_store` property
14
+ # of your client configuration.
15
15
  #
16
16
  class RedisFeatureStore
17
17
  begin
18
18
  require "redis"
19
19
  require "connection_pool"
20
- require "moneta"
21
20
  REDIS_ENABLED = true
22
21
  rescue ScriptError, StandardError
23
22
  REDIS_ENABLED = false
@@ -38,7 +37,7 @@ module LaunchDarkly
38
37
  #
39
38
  def initialize(opts = {})
40
39
  if !REDIS_ENABLED
41
- raise RuntimeError.new("can't use RedisFeatureStore because one of these gems is missing: redis, connection_pool, moneta")
40
+ raise RuntimeError.new("can't use RedisFeatureStore because one of these gems is missing: redis, connection_pool")
42
41
  end
43
42
  @redis_opts = opts[:redis_opts] || Hash.new
44
43
  if opts[:redis_url]
@@ -54,15 +53,12 @@ module LaunchDarkly
54
53
  @prefix = opts[:prefix] || RedisFeatureStore.default_prefix
55
54
  @logger = opts[:logger] || Config.default_logger
56
55
 
57
- @expiration_seconds = opts[:expiration] || 15
58
- @capacity = opts[:capacity] || 1000
59
- # We're using Moneta only to provide expiration behavior for the in-memory cache.
60
- # Moneta can also be used as a wrapper for Redis, but it doesn't support the Redis
61
- # hash operations that we use.
62
- if @expiration_seconds > 0
63
- @cache = Moneta.new(:LRUHash, expires: true, threadsafe: true, max_count: @capacity)
56
+ expiration_seconds = opts[:expiration] || 15
57
+ capacity = opts[:capacity] || 1000
58
+ if expiration_seconds > 0
59
+ @cache = ExpiringCache.new(capacity, expiration_seconds)
64
60
  else
65
- @cache = Moneta.new(:Null) # a stub that caches nothing
61
+ @cache = nil
66
62
  end
67
63
 
68
64
  @stopped = Concurrent::AtomicBoolean.new(false)
@@ -92,7 +88,7 @@ and prefix: #{@prefix}")
92
88
  end
93
89
 
94
90
  def get(kind, key)
95
- f = @cache[cache_key(kind, key)]
91
+ f = @cache.nil? ? nil : @cache[cache_key(kind, key)]
96
92
  if f.nil?
97
93
  @logger.debug { "RedisFeatureStore: no cache hit for #{key} in '#{kind[:namespace]}', requesting from Redis" }
98
94
  f = with_connection do |redis|
@@ -139,7 +135,7 @@ and prefix: #{@prefix}")
139
135
  end
140
136
 
141
137
  def init(all_data)
142
- @cache.clear
138
+ @cache.clear if !@cache.nil?
143
139
  count = 0
144
140
  with_connection do |redis|
145
141
  all_data.each do |kind, items|
@@ -174,7 +170,7 @@ and prefix: #{@prefix}")
174
170
  def stop
175
171
  if @stopped.make_true
176
172
  @pool.shutdown { |redis| redis.close }
177
- @cache.clear
173
+ @cache.clear if !@cache.nil?
178
174
  end
179
175
  end
180
176
 
@@ -213,7 +209,7 @@ and prefix: #{@prefix}")
213
209
  end
214
210
 
215
211
  def put_cache(kind, key, value)
216
- @cache.store(cache_key(kind, key), value, expires: @expiration_seconds)
212
+ @cache[cache_key(kind, key)] = value if !@cache.nil?
217
213
  end
218
214
 
219
215
  def update_with_versioning(kind, new_item)
@@ -0,0 +1,24 @@
1
+
2
+ module LaunchDarkly
3
+ # A non-thread-safe implementation of a LRU cache set with only add and reset methods.
4
+ # Based on https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/cache.rb
5
+ class SimpleLRUCacheSet
6
+ def initialize(capacity)
7
+ @values = {}
8
+ @capacity = capacity
9
+ end
10
+
11
+ # Adds a value to the cache or marks it recent if it was already there. Returns true if already there.
12
+ def add(value)
13
+ found = true
14
+ @values.delete(value) { found = false }
15
+ @values[value] = true
16
+ @values.shift if @values.length > @capacity
17
+ found
18
+ end
19
+
20
+ def clear
21
+ @values = {}
22
+ end
23
+ end
24
+ end
@@ -1,18 +1,28 @@
1
1
  require "json"
2
2
 
3
3
  module LaunchDarkly
4
- class EventSerializer
4
+ class UserFilter
5
5
  def initialize(config)
6
6
  @all_attributes_private = config.all_attributes_private
7
7
  @private_attribute_names = Set.new(config.private_attribute_names.map(&:to_sym))
8
8
  end
9
9
 
10
- def serialize_events(events)
11
- events.map { |event|
12
- Hash[event.map { |key, value|
13
- [key, (key.to_sym == :user) ? transform_user_props(value) : value]
14
- }]
15
- }.to_json
10
+ def transform_user_props(user_props)
11
+ return nil if user_props.nil?
12
+
13
+ user_private_attrs = Set.new((user_props[:privateAttributeNames] || []).map(&:to_sym))
14
+
15
+ filtered_user_props, removed = filter_values(user_props, user_private_attrs, ALLOWED_TOP_LEVEL_KEYS, IGNORED_TOP_LEVEL_KEYS)
16
+ if user_props.has_key?(:custom)
17
+ filtered_user_props[:custom], removed_custom = filter_values(user_props[:custom], user_private_attrs)
18
+ removed.merge(removed_custom)
19
+ end
20
+
21
+ unless removed.empty?
22
+ # note, :privateAttributeNames is what the developer sets; :privateAttrs is what we send to the server
23
+ filtered_user_props[:privateAttrs] = removed.to_a.sort.map { |s| s.to_s }
24
+ end
25
+ return filtered_user_props
16
26
  end
17
27
 
18
28
  private
@@ -35,21 +45,5 @@ module LaunchDarkly
35
45
  def private_attr?(name, user_private_attrs)
36
46
  @all_attributes_private || @private_attribute_names.include?(name) || user_private_attrs.include?(name)
37
47
  end
38
-
39
- def transform_user_props(user_props)
40
- user_private_attrs = Set.new((user_props[:privateAttributeNames] || []).map(&:to_sym))
41
-
42
- filtered_user_props, removed = filter_values(user_props, user_private_attrs, ALLOWED_TOP_LEVEL_KEYS, IGNORED_TOP_LEVEL_KEYS)
43
- if user_props.has_key?(:custom)
44
- filtered_user_props[:custom], removed_custom = filter_values(user_props[:custom], user_private_attrs)
45
- removed.merge(removed_custom)
46
- end
47
-
48
- unless removed.empty?
49
- # note, :privateAttributeNames is what the developer sets; :privateAttrs is what we send to the server
50
- filtered_user_props[:privateAttrs] = removed.to_a.sort
51
- end
52
- return filtered_user_props
53
- end
54
48
  end
55
49
  end
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "3.0.3"
2
+ VERSION = "4.0.0"
3
3
  end
@@ -12,6 +12,8 @@ describe LaunchDarkly::Evaluation do
12
12
  }
13
13
  }
14
14
 
15
+ let(:logger) { LaunchDarkly::Config.default_logger }
16
+
15
17
  include LaunchDarkly::Evaluation
16
18
 
17
19
  describe "evaluate" do
@@ -24,7 +26,7 @@ describe LaunchDarkly::Evaluation do
24
26
  variations: ['a', 'b', 'c']
25
27
  }
26
28
  user = { key: 'x' }
27
- expect(evaluate(flag, user, features)).to eq({value: 'b', events: []})
29
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 1, value: 'b', events: []})
28
30
  end
29
31
 
30
32
  it "returns nil if flag is off and off variation is unspecified" do
@@ -35,7 +37,7 @@ describe LaunchDarkly::Evaluation do
35
37
  variations: ['a', 'b', 'c']
36
38
  }
37
39
  user = { key: 'x' }
38
- expect(evaluate(flag, user, features)).to eq({value: nil, events: []})
40
+ expect(evaluate(flag, user, features, logger)).to eq({variation: nil, value: nil, events: []})
39
41
  end
40
42
 
41
43
  it "returns off variation if prerequisite is not found" do
@@ -48,7 +50,34 @@ describe LaunchDarkly::Evaluation do
48
50
  variations: ['a', 'b', 'c']
49
51
  }
50
52
  user = { key: 'x' }
51
- expect(evaluate(flag, user, features)).to eq({value: 'b', events: []})
53
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 1, value: 'b', events: []})
54
+ end
55
+
56
+ it "returns off variation and event if prerequisite of a prerequisite is not found" do
57
+ flag = {
58
+ key: 'feature0',
59
+ on: true,
60
+ prerequisites: [{key: 'feature1', variation: 1}],
61
+ fallthrough: { variation: 0 },
62
+ offVariation: 1,
63
+ variations: ['a', 'b', 'c'],
64
+ version: 1
65
+ }
66
+ flag1 = {
67
+ key: 'feature1',
68
+ on: true,
69
+ prerequisites: [{key: 'feature2', variation: 1}], # feature2 doesn't exist
70
+ fallthrough: { variation: 0 },
71
+ variations: ['d', 'e'],
72
+ version: 2
73
+ }
74
+ features.upsert(LaunchDarkly::FEATURES, flag1)
75
+ user = { key: 'x' }
76
+ events_should_be = [{
77
+ kind: 'feature', key: 'feature1', variation: nil, value: nil, version: 2, prereqOf: 'feature0',
78
+ trackEvents: nil, debugEventsUntilDate: nil
79
+ }]
80
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 1, value: 'b', events: events_should_be})
52
81
  end
53
82
 
54
83
  it "returns off variation and event if prerequisite is not met" do
@@ -70,8 +99,11 @@ describe LaunchDarkly::Evaluation do
70
99
  }
71
100
  features.upsert(LaunchDarkly::FEATURES, flag1)
72
101
  user = { key: 'x' }
73
- events_should_be = [{kind: 'feature', key: 'feature1', value: 'd', version: 2, prereqOf: 'feature0'}]
74
- expect(evaluate(flag, user, features)).to eq({value: 'b', events: events_should_be})
102
+ events_should_be = [{
103
+ kind: 'feature', key: 'feature1', variation: 0, value: 'd', version: 2, prereqOf: 'feature0',
104
+ trackEvents: nil, debugEventsUntilDate: nil
105
+ }]
106
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 1, value: 'b', events: events_should_be})
75
107
  end
76
108
 
77
109
  it "returns fallthrough variation and event if prerequisite is met and there are no rules" do
@@ -93,8 +125,11 @@ describe LaunchDarkly::Evaluation do
93
125
  }
94
126
  features.upsert(LaunchDarkly::FEATURES, flag1)
95
127
  user = { key: 'x' }
96
- events_should_be = [{kind: 'feature', key: 'feature1', value: 'e', version: 2, prereqOf: 'feature0'}]
97
- expect(evaluate(flag, user, features)).to eq({value: 'a', events: events_should_be})
128
+ events_should_be = [{
129
+ kind: 'feature', key: 'feature1', variation: 1, value: 'e', version: 2, prereqOf: 'feature0',
130
+ trackEvents: nil, debugEventsUntilDate: nil
131
+ }]
132
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 0, value: 'a', events: events_should_be})
98
133
  end
99
134
 
100
135
  it "matches user from targets" do
@@ -109,7 +144,7 @@ describe LaunchDarkly::Evaluation do
109
144
  variations: ['a', 'b', 'c']
110
145
  }
111
146
  user = { key: 'userkey' }
112
- expect(evaluate(flag, user, features)).to eq({value: 'c', events: []})
147
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 2, value: 'c', events: []})
113
148
  end
114
149
 
115
150
  it "matches user from rules" do
@@ -133,7 +168,7 @@ describe LaunchDarkly::Evaluation do
133
168
  variations: ['a', 'b', 'c']
134
169
  }
135
170
  user = { key: 'userkey' }
136
- expect(evaluate(flag, user, features)).to eq({value: 'c', events: []})
171
+ expect(evaluate(flag, user, features, logger)).to eq({variation: 2, value: 'c', events: []})
137
172
  end
138
173
  end
139
174