ldclient-rb 3.0.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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