ldclient-rb 5.4.3 → 5.5.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +33 -6
  3. data/CHANGELOG.md +19 -0
  4. data/CONTRIBUTING.md +0 -12
  5. data/Gemfile.lock +22 -3
  6. data/README.md +41 -35
  7. data/ldclient-rb.gemspec +4 -3
  8. data/lib/ldclient-rb.rb +9 -1
  9. data/lib/ldclient-rb/cache_store.rb +1 -0
  10. data/lib/ldclient-rb/config.rb +201 -90
  11. data/lib/ldclient-rb/evaluation.rb +56 -8
  12. data/lib/ldclient-rb/event_summarizer.rb +3 -0
  13. data/lib/ldclient-rb/events.rb +16 -0
  14. data/lib/ldclient-rb/expiring_cache.rb +1 -0
  15. data/lib/ldclient-rb/file_data_source.rb +18 -13
  16. data/lib/ldclient-rb/flags_state.rb +3 -2
  17. data/lib/ldclient-rb/impl.rb +13 -0
  18. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  19. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  20. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  21. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  22. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  23. data/lib/ldclient-rb/in_memory_store.rb +15 -4
  24. data/lib/ldclient-rb/integrations.rb +55 -0
  25. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  26. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  27. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  28. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  29. data/lib/ldclient-rb/interfaces.rb +153 -0
  30. data/lib/ldclient-rb/ldclient.rb +135 -77
  31. data/lib/ldclient-rb/memoized_value.rb +2 -0
  32. data/lib/ldclient-rb/newrelic.rb +1 -0
  33. data/lib/ldclient-rb/non_blocking_thread_pool.rb +3 -3
  34. data/lib/ldclient-rb/polling.rb +1 -0
  35. data/lib/ldclient-rb/redis_store.rb +24 -190
  36. data/lib/ldclient-rb/requestor.rb +3 -2
  37. data/lib/ldclient-rb/simple_lru_cache.rb +1 -0
  38. data/lib/ldclient-rb/stream.rb +22 -10
  39. data/lib/ldclient-rb/user_filter.rb +1 -0
  40. data/lib/ldclient-rb/util.rb +1 -0
  41. data/lib/ldclient-rb/version.rb +1 -1
  42. data/scripts/gendocs.sh +12 -0
  43. data/spec/feature_store_spec_base.rb +173 -72
  44. data/spec/file_data_source_spec.rb +2 -2
  45. data/spec/http_util.rb +103 -0
  46. data/spec/in_memory_feature_store_spec.rb +1 -1
  47. data/spec/integrations/consul_feature_store_spec.rb +41 -0
  48. data/spec/integrations/dynamodb_feature_store_spec.rb +104 -0
  49. data/spec/integrations/store_wrapper_spec.rb +276 -0
  50. data/spec/ldclient_spec.rb +83 -4
  51. data/spec/redis_feature_store_spec.rb +25 -16
  52. data/spec/requestor_spec.rb +44 -38
  53. data/spec/stream_spec.rb +18 -18
  54. metadata +55 -33
  55. data/lib/sse_client.rb +0 -4
  56. data/lib/sse_client/backoff.rb +0 -38
  57. data/lib/sse_client/sse_client.rb +0 -171
  58. data/lib/sse_client/sse_events.rb +0 -67
  59. data/lib/sse_client/streaming_http.rb +0 -199
  60. data/spec/sse_client/sse_client_spec.rb +0 -177
  61. data/spec/sse_client/sse_events_spec.rb +0 -100
  62. data/spec/sse_client/sse_shared.rb +0 -82
  63. data/spec/sse_client/streaming_http_spec.rb +0 -263
@@ -219,7 +219,7 @@ EOF
219
219
  it "evaluates simplified flag with client as expected" do
220
220
  file = make_temp_file(all_properties_json)
221
221
  factory = LaunchDarkly::FileDataSource.factory({ paths: file.path })
222
- config = LaunchDarkly::Config.new(send_events: false, update_processor_factory: factory)
222
+ config = LaunchDarkly::Config.new(send_events: false, data_source: factory)
223
223
  client = LaunchDarkly::LDClient.new('sdkKey', config)
224
224
 
225
225
  begin
@@ -233,7 +233,7 @@ EOF
233
233
  it "evaluates full flag with client as expected" do
234
234
  file = make_temp_file(all_properties_json)
235
235
  factory = LaunchDarkly::FileDataSource.factory({ paths: file.path })
236
- config = LaunchDarkly::Config.new(send_events: false, update_processor_factory: factory)
236
+ config = LaunchDarkly::Config.new(send_events: false, data_source: factory)
237
237
  client = LaunchDarkly::LDClient.new('sdkKey', config)
238
238
 
239
239
  begin
data/spec/http_util.rb ADDED
@@ -0,0 +1,103 @@
1
+ require "webrick"
2
+ require "webrick/httpproxy"
3
+ require "webrick/https"
4
+
5
+ class StubHTTPServer
6
+ attr_reader :requests
7
+
8
+ @@next_port = 50000
9
+
10
+ def initialize
11
+ @port = StubHTTPServer.next_port
12
+ begin
13
+ base_opts = {
14
+ BindAddress: '127.0.0.1',
15
+ Port: @port,
16
+ AccessLog: [],
17
+ Logger: NullLogger.new,
18
+ RequestCallback: method(:record_request)
19
+ }
20
+ @server = create_server(@port, base_opts)
21
+ rescue Errno::EADDRINUSE
22
+ @port = StubHTTPServer.next_port
23
+ retry
24
+ end
25
+ @requests = []
26
+ end
27
+
28
+ def self.next_port
29
+ p = @@next_port
30
+ @@next_port = (p + 1 < 60000) ? p + 1 : 50000
31
+ p
32
+ end
33
+
34
+ def create_server(port, base_opts)
35
+ WEBrick::HTTPServer.new(base_opts)
36
+ end
37
+
38
+ def start
39
+ Thread.new { @server.start }
40
+ end
41
+
42
+ def stop
43
+ @server.shutdown
44
+ end
45
+
46
+ def base_uri
47
+ URI("http://127.0.0.1:#{@port}")
48
+ end
49
+
50
+ def setup_response(uri_path, &action)
51
+ @server.mount_proc(uri_path, action)
52
+ end
53
+
54
+ def setup_ok_response(uri_path, body, content_type=nil, headers={})
55
+ setup_response(uri_path) do |req, res|
56
+ res.status = 200
57
+ res.content_type = content_type if !content_type.nil?
58
+ res.body = body
59
+ headers.each { |n, v| res[n] = v }
60
+ end
61
+ end
62
+
63
+ def record_request(req, res)
64
+ @requests.push(req)
65
+ end
66
+ end
67
+
68
+ class StubProxyServer < StubHTTPServer
69
+ attr_reader :request_count
70
+ attr_accessor :connect_status
71
+
72
+ def initialize
73
+ super
74
+ @request_count = 0
75
+ end
76
+
77
+ def create_server(port, base_opts)
78
+ WEBrick::HTTPProxyServer.new(base_opts.merge({
79
+ ProxyContentHandler: proc do |req,res|
80
+ if !@connect_status.nil?
81
+ res.status = @connect_status
82
+ end
83
+ @request_count += 1
84
+ end
85
+ }))
86
+ end
87
+ end
88
+
89
+ class NullLogger
90
+ def method_missing(*)
91
+ self
92
+ end
93
+ end
94
+
95
+ def with_server(server = nil)
96
+ server = StubHTTPServer.new if server.nil?
97
+ begin
98
+ server.start
99
+ yield server
100
+ ensure
101
+ server.stop
102
+ end
103
+ end
@@ -1,7 +1,7 @@
1
1
  require "feature_store_spec_base"
2
2
  require "spec_helper"
3
3
 
4
- def create_in_memory_store()
4
+ def create_in_memory_store(opts = {})
5
5
  LaunchDarkly::InMemoryFeatureStore.new
6
6
  end
7
7
 
@@ -0,0 +1,41 @@
1
+ require "feature_store_spec_base"
2
+ require "diplomat"
3
+ require "spec_helper"
4
+
5
+
6
+ $my_prefix = 'testprefix'
7
+ $null_log = ::Logger.new($stdout)
8
+ $null_log.level = ::Logger::FATAL
9
+
10
+ $consul_base_opts = {
11
+ prefix: $my_prefix,
12
+ logger: $null_log
13
+ }
14
+
15
+ def create_consul_store(opts = {})
16
+ LaunchDarkly::Integrations::Consul::new_feature_store(
17
+ $consul_base_opts.merge(opts).merge({ expiration: 60 }))
18
+ end
19
+
20
+ def create_consul_store_uncached(opts = {})
21
+ LaunchDarkly::Integrations::Consul::new_feature_store(
22
+ $consul_base_opts.merge(opts).merge({ expiration: 0 }))
23
+ end
24
+
25
+ def clear_all_data
26
+ Diplomat::Kv.delete($my_prefix + '/', recurse: true)
27
+ end
28
+
29
+
30
+ describe "Consul feature store" do
31
+
32
+ # These tests will all fail if there isn't a local Consul instance running.
33
+
34
+ context "with local cache" do
35
+ include_examples "feature_store", method(:create_consul_store), method(:clear_all_data)
36
+ end
37
+
38
+ context "without local cache" do
39
+ include_examples "feature_store", method(:create_consul_store_uncached), method(:clear_all_data)
40
+ end
41
+ end
@@ -0,0 +1,104 @@
1
+ require "feature_store_spec_base"
2
+ require "aws-sdk-dynamodb"
3
+ require "spec_helper"
4
+
5
+
6
+ $table_name = 'LD_DYNAMODB_TEST_TABLE'
7
+ $endpoint = 'http://localhost:8000'
8
+ $my_prefix = 'testprefix'
9
+ $null_log = ::Logger.new($stdout)
10
+ $null_log.level = ::Logger::FATAL
11
+
12
+ $dynamodb_opts = {
13
+ credentials: Aws::Credentials.new("key", "secret"),
14
+ region: "us-east-1",
15
+ endpoint: $endpoint
16
+ }
17
+
18
+ $ddb_base_opts = {
19
+ dynamodb_opts: $dynamodb_opts,
20
+ prefix: $my_prefix,
21
+ logger: $null_log
22
+ }
23
+
24
+ def create_dynamodb_store(opts = {})
25
+ LaunchDarkly::Integrations::DynamoDB::new_feature_store($table_name,
26
+ $ddb_base_opts.merge(opts).merge({ expiration: 60 }))
27
+ end
28
+
29
+ def create_dynamodb_store_uncached(opts = {})
30
+ LaunchDarkly::Integrations::DynamoDB::new_feature_store($table_name,
31
+ $ddb_base_opts.merge(opts).merge({ expiration: 0 }))
32
+ end
33
+
34
+ def clear_all_data
35
+ client = create_test_client
36
+ items_to_delete = []
37
+ req = {
38
+ table_name: $table_name,
39
+ projection_expression: '#namespace, #key',
40
+ expression_attribute_names: {
41
+ '#namespace' => 'namespace',
42
+ '#key' => 'key'
43
+ }
44
+ }
45
+ while true
46
+ resp = client.scan(req)
47
+ items_to_delete = items_to_delete + resp.items
48
+ break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0
49
+ req.exclusive_start_key = resp.last_evaluated_key
50
+ end
51
+ requests = items_to_delete.map do |item|
52
+ { delete_request: { key: item } }
53
+ end
54
+ LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBUtil.batch_write_requests(client, $table_name, requests)
55
+ end
56
+
57
+ def create_table_if_necessary
58
+ client = create_test_client
59
+ begin
60
+ client.describe_table({ table_name: $table_name })
61
+ return # no error, table exists
62
+ rescue Aws::DynamoDB::Errors::ResourceNotFoundException
63
+ # fall through to code below - we'll create the table
64
+ end
65
+
66
+ req = {
67
+ table_name: $table_name,
68
+ key_schema: [
69
+ { attribute_name: "namespace", key_type: "HASH" },
70
+ { attribute_name: "key", key_type: "RANGE" }
71
+ ],
72
+ attribute_definitions: [
73
+ { attribute_name: "namespace", attribute_type: "S" },
74
+ { attribute_name: "key", attribute_type: "S" }
75
+ ],
76
+ provisioned_throughput: {
77
+ read_capacity_units: 1,
78
+ write_capacity_units: 1
79
+ }
80
+ }
81
+ client.create_table(req)
82
+
83
+ # When DynamoDB creates a table, it may not be ready to use immediately
84
+ end
85
+
86
+ def create_test_client
87
+ Aws::DynamoDB::Client.new($dynamodb_opts)
88
+ end
89
+
90
+
91
+ describe "DynamoDB feature store" do
92
+
93
+ # These tests will all fail if there isn't a local DynamoDB instance running.
94
+
95
+ create_table_if_necessary
96
+
97
+ context "with local cache" do
98
+ include_examples "feature_store", method(:create_dynamodb_store), method(:clear_all_data)
99
+ end
100
+
101
+ context "without local cache" do
102
+ include_examples "feature_store", method(:create_dynamodb_store_uncached), method(:clear_all_data)
103
+ end
104
+ end
@@ -0,0 +1,276 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Integrations::Util::CachingStoreWrapper do
4
+ subject { LaunchDarkly::Integrations::Util::CachingStoreWrapper }
5
+
6
+ THINGS = { namespace: "things" }
7
+
8
+ shared_examples "tests" do |cached|
9
+ opts = cached ? { expiration: 30 } : { expiration: 0 }
10
+
11
+ it "gets item" do
12
+ core = MockCore.new
13
+ wrapper = subject.new(core, opts)
14
+ key = "flag"
15
+ itemv1 = { key: key, version: 1 }
16
+ itemv2 = { key: key, version: 2 }
17
+
18
+ core.force_set(THINGS, itemv1)
19
+ expect(wrapper.get(THINGS, key)).to eq itemv1
20
+
21
+ core.force_set(THINGS, itemv2)
22
+ expect(wrapper.get(THINGS, key)).to eq (cached ? itemv1 : itemv2) # if cached, we will not see the new underlying value yet
23
+ end
24
+
25
+ it "gets deleted item" do
26
+ core = MockCore.new
27
+ wrapper = subject.new(core, opts)
28
+ key = "flag"
29
+ itemv1 = { key: key, version: 1, deleted: true }
30
+ itemv2 = { key: key, version: 2, deleted: false }
31
+
32
+ core.force_set(THINGS, itemv1)
33
+ expect(wrapper.get(THINGS, key)).to eq nil # item is filtered out because deleted is true
34
+
35
+ core.force_set(THINGS, itemv2)
36
+ expect(wrapper.get(THINGS, key)).to eq (cached ? nil : itemv2) # if cached, we will not see the new underlying value yet
37
+ end
38
+
39
+ it "gets missing item" do
40
+ core = MockCore.new
41
+ wrapper = subject.new(core, opts)
42
+ key = "flag"
43
+ item = { key: key, version: 1 }
44
+
45
+ expect(wrapper.get(THINGS, key)).to eq nil
46
+
47
+ core.force_set(THINGS, item)
48
+ expect(wrapper.get(THINGS, key)).to eq (cached ? nil : item) # the cache can retain a nil result
49
+ end
50
+
51
+ it "gets all items" do
52
+ core = MockCore.new
53
+ wrapper = subject.new(core, opts)
54
+ item1 = { key: "flag1", version: 1 }
55
+ item2 = { key: "flag2", version: 1 }
56
+
57
+ core.force_set(THINGS, item1)
58
+ core.force_set(THINGS, item2)
59
+ expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1, item2[:key] => item2 })
60
+
61
+ core.force_remove(THINGS, item2[:key])
62
+ expect(wrapper.all(THINGS)).to eq (cached ?
63
+ { item1[:key] => item1, item2[:key] => item2 } :
64
+ { item1[:key] => item1 })
65
+ end
66
+
67
+ it "gets all items filtering out deleted items" do
68
+ core = MockCore.new
69
+ wrapper = subject.new(core, opts)
70
+ item1 = { key: "flag1", version: 1 }
71
+ item2 = { key: "flag2", version: 1, deleted: true }
72
+
73
+ core.force_set(THINGS, item1)
74
+ core.force_set(THINGS, item2)
75
+ expect(wrapper.all(THINGS)).to eq({ item1[:key] => item1 })
76
+ end
77
+
78
+ it "upserts item successfully" do
79
+ core = MockCore.new
80
+ wrapper = subject.new(core, opts)
81
+ key = "flag"
82
+ itemv1 = { key: key, version: 1 }
83
+ itemv2 = { key: key, version: 2 }
84
+
85
+ wrapper.upsert(THINGS, itemv1)
86
+ expect(core.data[THINGS][key]).to eq itemv1
87
+
88
+ wrapper.upsert(THINGS, itemv2)
89
+ expect(core.data[THINGS][key]).to eq itemv2
90
+
91
+ # if we have a cache, verify that the new item is now cached by writing a different value
92
+ # to the underlying data - Get should still return the cached item
93
+ if cached
94
+ itemv3 = { key: key, version: 3 }
95
+ core.force_set(THINGS, itemv3)
96
+ end
97
+
98
+ expect(wrapper.get(THINGS, key)).to eq itemv2
99
+ end
100
+
101
+ it "deletes item" do
102
+ core = MockCore.new
103
+ wrapper = subject.new(core, opts)
104
+ key = "flag"
105
+ itemv1 = { key: key, version: 1 }
106
+ itemv2 = { key: key, version: 2, deleted: true }
107
+ itemv3 = { key: key, version: 3 }
108
+
109
+ core.force_set(THINGS, itemv1)
110
+ expect(wrapper.get(THINGS, key)).to eq itemv1
111
+
112
+ wrapper.delete(THINGS, key, 2)
113
+ expect(core.data[THINGS][key]).to eq itemv2
114
+
115
+ core.force_set(THINGS, itemv3) # make a change that bypasses the cache
116
+
117
+ expect(wrapper.get(THINGS, key)).to eq (cached ? nil : itemv3)
118
+ end
119
+ end
120
+
121
+ context "cached" do
122
+ include_examples "tests", true
123
+
124
+ cached_opts = { expiration: 30 }
125
+
126
+ it "get uses values from init" do
127
+ core = MockCore.new
128
+ wrapper = subject.new(core, cached_opts)
129
+ item1 = { key: "flag1", version: 1 }
130
+ item2 = { key: "flag2", version: 1 }
131
+
132
+ wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } })
133
+ core.force_remove(THINGS, item1[:key])
134
+
135
+ expect(wrapper.get(THINGS, item1[:key])).to eq item1
136
+ end
137
+
138
+ it "get all uses values from init" do
139
+ core = MockCore.new
140
+ wrapper = subject.new(core, cached_opts)
141
+ item1 = { key: "flag1", version: 1 }
142
+ item2 = { key: "flag2", version: 1 }
143
+
144
+ wrapper.init({ THINGS => { item1[:key] => item1, item2[:key] => item2 } })
145
+ core.force_remove(THINGS, item1[:key])
146
+
147
+ expect(wrapper.all(THINGS)).to eq ({ item1[:key] => item1, item2[:key] => item2 })
148
+ end
149
+
150
+ it "upsert doesn't update cache if unsuccessful" do
151
+ # This is for an upsert where the data in the store has a higher version. In an uncached
152
+ # store, this is just a no-op as far as the wrapper is concerned so there's nothing to
153
+ # test here. In a cached store, we need to verify that the cache has been refreshed
154
+ # using the data that was found in the store.
155
+ core = MockCore.new
156
+ wrapper = subject.new(core, cached_opts)
157
+ key = "flag"
158
+ itemv1 = { key: key, version: 1 }
159
+ itemv2 = { key: key, version: 2 }
160
+
161
+ wrapper.upsert(THINGS, itemv2)
162
+ expect(core.data[THINGS][key]).to eq itemv2
163
+
164
+ wrapper.upsert(THINGS, itemv1)
165
+ expect(core.data[THINGS][key]).to eq itemv2 # value in store remains the same
166
+
167
+ itemv3 = { key: key, version: 3 }
168
+ core.force_set(THINGS, itemv3) # bypasses cache so we can verify that itemv2 is in the cache
169
+ expect(wrapper.get(THINGS, key)).to eq itemv2
170
+ end
171
+
172
+ it "initialized? can cache false result" do
173
+ core = MockCore.new
174
+ wrapper = subject.new(core, { expiration: 0.2 }) # use a shorter cache TTL for this test
175
+
176
+ expect(wrapper.initialized?).to eq false
177
+ expect(core.inited_query_count).to eq 1
178
+
179
+ core.inited = true
180
+ expect(wrapper.initialized?).to eq false
181
+ expect(core.inited_query_count).to eq 1
182
+
183
+ sleep(0.5)
184
+
185
+ expect(wrapper.initialized?).to eq true
186
+ expect(core.inited_query_count).to eq 2
187
+
188
+ # From this point on it should remain true and the method should not be called
189
+ expect(wrapper.initialized?).to eq true
190
+ expect(core.inited_query_count).to eq 2
191
+ end
192
+ end
193
+
194
+ context "uncached" do
195
+ include_examples "tests", false
196
+
197
+ uncached_opts = { expiration: 0 }
198
+
199
+ it "queries internal initialized state only if not already inited" do
200
+ core = MockCore.new
201
+ wrapper = subject.new(core, uncached_opts)
202
+
203
+ expect(wrapper.initialized?).to eq false
204
+ expect(core.inited_query_count).to eq 1
205
+
206
+ core.inited = true
207
+ expect(wrapper.initialized?).to eq true
208
+ expect(core.inited_query_count).to eq 2
209
+
210
+ core.inited = false
211
+ expect(wrapper.initialized?).to eq true
212
+ expect(core.inited_query_count).to eq 2
213
+ end
214
+
215
+ it "does not query internal initialized state if init has been called" do
216
+ core = MockCore.new
217
+ wrapper = subject.new(core, uncached_opts)
218
+
219
+ expect(wrapper.initialized?).to eq false
220
+ expect(core.inited_query_count).to eq 1
221
+
222
+ wrapper.init({})
223
+
224
+ expect(wrapper.initialized?).to eq true
225
+ expect(core.inited_query_count).to eq 1
226
+ end
227
+ end
228
+
229
+ class MockCore
230
+ def initialize
231
+ @data = {}
232
+ @inited = false
233
+ @inited_query_count = 0
234
+ end
235
+
236
+ attr_reader :data
237
+ attr_reader :inited_query_count
238
+ attr_accessor :inited
239
+
240
+ def force_set(kind, item)
241
+ @data[kind] = {} if !@data.has_key?(kind)
242
+ @data[kind][item[:key]] = item
243
+ end
244
+
245
+ def force_remove(kind, key)
246
+ @data[kind].delete(key) if @data.has_key?(kind)
247
+ end
248
+
249
+ def init_internal(all_data)
250
+ @data = all_data
251
+ @inited = true
252
+ end
253
+
254
+ def get_internal(kind, key)
255
+ items = @data[kind]
256
+ items.nil? ? nil : items[key]
257
+ end
258
+
259
+ def get_all_internal(kind)
260
+ @data[kind]
261
+ end
262
+
263
+ def upsert_internal(kind, item)
264
+ @data[kind] = {} if !@data.has_key?(kind)
265
+ old_item = @data[kind][item[:key]]
266
+ return old_item if !old_item.nil? && old_item[:version] >= item[:version]
267
+ @data[kind][item[:key]] = item
268
+ item
269
+ end
270
+
271
+ def initialized_internal?
272
+ @inited_query_count = @inited_query_count + 1
273
+ @inited
274
+ end
275
+ end
276
+ end