ldclient-rb 5.3.0 → 5.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +4 -0
- data/ldclient-rb.gemspec +1 -0
- data/lib/ldclient-rb.rb +1 -0
- data/lib/ldclient-rb/config.rb +8 -2
- data/lib/ldclient-rb/file_data_source.rb +307 -0
- data/lib/ldclient-rb/ldclient.rb +18 -15
- data/lib/ldclient-rb/polling.rb +2 -1
- data/lib/ldclient-rb/version.rb +1 -1
- data/spec/file_data_source_spec.rb +255 -0
- data/spec/polling_spec.rb +56 -25
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1638ba1d1609d7d8a027f23a679da4fda6112ff2
|
4
|
+
data.tar.gz: cd6bd5a3a4a24d2e69174a76c4cff1bfe75d54ec
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3d26b16c922b72855188f6d6f8e1205538dd46ffce2af1699989a65b32dcfb15cb7fe696c3217d96b43e77bbeb66a29f3bab5c1d055ff5f6b20599827261f8d7
|
7
|
+
data.tar.gz: 866d5aad4cba770fb7aaf5c82dfecd934c115cb3801bf8941b06646adde1ce07e0d2c3be98296bfc42fe13f6363f26ad91dfdb67c3038a486618326d82cc68b5
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,18 @@
|
|
2
2
|
|
3
3
|
All notable changes to the LaunchDarkly Ruby SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
|
4
4
|
|
5
|
+
## [5.4.1] - 2018-11-05
|
6
|
+
### Fixed:
|
7
|
+
* Fixed a `LoadError` in `file_data_source.rb`, which was added in 5.4.0. (Thanks, [kbarrette](https://github.com/launchdarkly/ruby-client/pull/110)!)
|
8
|
+
|
9
|
+
|
10
|
+
## [5.4.0] - 2018-11-02
|
11
|
+
### Added:
|
12
|
+
- It is now possible to inject feature flags into the client from local JSON or YAML files, replacing the normal LaunchDarkly connection. This would typically be for testing purposes. See `file_data_source.rb`.
|
13
|
+
|
14
|
+
### Fixed:
|
15
|
+
- When shutting down an `LDClient`, if in polling mode, the client was using `Thread.raise` to make the polling thread stop sleeping. `Thread.raise` can cause unpredictable behavior in a worker thread, so it is no longer used.
|
16
|
+
|
5
17
|
## [5.3.0] - 2018-10-24
|
6
18
|
### Added:
|
7
19
|
- The `all_flags_state` method now accepts a new option, `details_only_for_tracked_flags`, which reduces the size of the JSON representation of the flag state by omitting some metadata. Specifically, it omits any data that is normally used for generating detailed evaluation events if a flag does not have event tracking or debugging turned on.
|
data/README.md
CHANGED
@@ -121,6 +121,10 @@ else
|
|
121
121
|
end
|
122
122
|
```
|
123
123
|
|
124
|
+
Using flag data from a file
|
125
|
+
---------------------------
|
126
|
+
For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See [`file_data_source.rb`](https://github.com/launchdarkly/ruby-client/blob/master/lib/ldclient-rb/file_data_source.rb) for more details.
|
127
|
+
|
124
128
|
Learn more
|
125
129
|
-----------
|
126
130
|
|
data/ldclient-rb.gemspec
CHANGED
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.add_development_dependency "rake", "~> 10.0"
|
30
30
|
spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
|
31
31
|
spec.add_development_dependency "timecop", "~> 0.9.1"
|
32
|
+
spec.add_development_dependency "listen", "~> 3.0" # see file_data_source.rb
|
32
33
|
|
33
34
|
spec.add_runtime_dependency "json", [">= 1.8", "< 3"]
|
34
35
|
spec.add_runtime_dependency "faraday", [">= 0.9", "< 2"]
|
data/lib/ldclient-rb.rb
CHANGED
data/lib/ldclient-rb/config.rb
CHANGED
@@ -61,8 +61,11 @@ module LaunchDarkly
|
|
61
61
|
# @option opts [Boolean] :inline_users_in_events (false) Whether to include full user details in every
|
62
62
|
# analytics event. By default, events will only include the user key, except for one "index" event
|
63
63
|
# that provides the full details for the user.
|
64
|
-
# @option opts [Object] :update_processor An object that will receive feature flag data from
|
65
|
-
# Defaults to either the streaming or the polling processor, can be customized for tests.
|
64
|
+
# @option opts [Object] :update_processor (DEPRECATED) An object that will receive feature flag data from
|
65
|
+
# LaunchDarkly. Defaults to either the streaming or the polling processor, can be customized for tests.
|
66
|
+
# @option opts [Object] :update_processor_factory A function that takes the SDK and configuration object
|
67
|
+
# as parameters, and returns an object that can obtain feature flag data and put it into the feature
|
68
|
+
# store. Defaults to creating either the streaming or the polling processor, can be customized for tests.
|
66
69
|
# @return [type] [description]
|
67
70
|
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
|
68
71
|
def initialize(opts = {})
|
@@ -88,6 +91,7 @@ module LaunchDarkly
|
|
88
91
|
@user_keys_flush_interval = opts[:user_keys_flush_interval] || Config.default_user_keys_flush_interval
|
89
92
|
@inline_users_in_events = opts[:inline_users_in_events] || false
|
90
93
|
@update_processor = opts[:update_processor]
|
94
|
+
@update_processor_factory = opts[:update_processor_factory]
|
91
95
|
end
|
92
96
|
|
93
97
|
#
|
@@ -218,6 +222,8 @@ module LaunchDarkly
|
|
218
222
|
|
219
223
|
attr_reader :update_processor
|
220
224
|
|
225
|
+
attr_reader :update_processor_factory
|
226
|
+
|
221
227
|
#
|
222
228
|
# The default LaunchDarkly client configuration. This configuration sets
|
223
229
|
# reasonable defaults for most users.
|
@@ -0,0 +1,307 @@
|
|
1
|
+
require 'concurrent/atomics'
|
2
|
+
require 'json'
|
3
|
+
require 'yaml'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module LaunchDarkly
|
7
|
+
# To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
|
8
|
+
# file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
|
9
|
+
# gem has been provided by the host app.
|
10
|
+
@@have_listen = false
|
11
|
+
begin
|
12
|
+
require 'listen'
|
13
|
+
@@have_listen = true
|
14
|
+
rescue LoadError
|
15
|
+
end
|
16
|
+
def self.have_listen?
|
17
|
+
@@have_listen
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Provides a way to use local files as a source of feature flag state. This would typically be
|
22
|
+
# used in a test environment, to operate using a predetermined feature flag state without an
|
23
|
+
# actual LaunchDarkly connection.
|
24
|
+
#
|
25
|
+
# To use this component, call `FileDataSource.factory`, and store its return value in the
|
26
|
+
# `update_processor_factory` property of your LaunchDarkly client configuration. In the options
|
27
|
+
# to `factory`, set `paths` to the file path(s) of your data file(s):
|
28
|
+
#
|
29
|
+
# factory = FileDataSource.factory(paths: [ myFilePath ])
|
30
|
+
# config = LaunchDarkly::Config.new(update_processor_factory: factory)
|
31
|
+
#
|
32
|
+
# This will cause the client not to connect to LaunchDarkly to get feature flags. The
|
33
|
+
# client may still make network connections to send analytics events, unless you have disabled
|
34
|
+
# this with Config.send_events or Config.offline.
|
35
|
+
#
|
36
|
+
# Flag data files can be either JSON or YAML. They contain an object with three possible
|
37
|
+
# properties:
|
38
|
+
#
|
39
|
+
# - "flags": Feature flag definitions.
|
40
|
+
# - "flagValues": Simplified feature flags that contain only a value.
|
41
|
+
# - "segments": User segment definitions.
|
42
|
+
#
|
43
|
+
# The format of the data in "flags" and "segments" is defined by the LaunchDarkly application
|
44
|
+
# and is subject to change. Rather than trying to construct these objects yourself, it is simpler
|
45
|
+
# to request existing flags directly from the LaunchDarkly server in JSON format, and use this
|
46
|
+
# output as the starting point for your file. In Linux you would do this:
|
47
|
+
#
|
48
|
+
# curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
|
49
|
+
#
|
50
|
+
# The output will look something like this (but with many more properties):
|
51
|
+
#
|
52
|
+
# {
|
53
|
+
# "flags": {
|
54
|
+
# "flag-key-1": {
|
55
|
+
# "key": "flag-key-1",
|
56
|
+
# "on": true,
|
57
|
+
# "variations": [ "a", "b" ]
|
58
|
+
# }
|
59
|
+
# },
|
60
|
+
# "segments": {
|
61
|
+
# "segment-key-1": {
|
62
|
+
# "key": "segment-key-1",
|
63
|
+
# "includes": [ "user-key-1" ]
|
64
|
+
# }
|
65
|
+
# }
|
66
|
+
# }
|
67
|
+
#
|
68
|
+
# Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported
|
69
|
+
# by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to
|
70
|
+
# set specific flag keys to specific values. For that, you can use a much simpler format:
|
71
|
+
#
|
72
|
+
# {
|
73
|
+
# "flagValues": {
|
74
|
+
# "my-string-flag-key": "value-1",
|
75
|
+
# "my-boolean-flag-key": true,
|
76
|
+
# "my-integer-flag-key": 3
|
77
|
+
# }
|
78
|
+
# }
|
79
|
+
#
|
80
|
+
# Or, in YAML:
|
81
|
+
#
|
82
|
+
# flagValues:
|
83
|
+
# my-string-flag-key: "value-1"
|
84
|
+
# my-boolean-flag-key: true
|
85
|
+
# my-integer-flag-key: 1
|
86
|
+
#
|
87
|
+
# It is also possible to specify both "flags" and "flagValues", if you want some flags
|
88
|
+
# to have simple values and others to have complex behavior. However, it is an error to use the
|
89
|
+
# same flag key or segment key more than once, either in a single file or across multiple files.
|
90
|
+
#
|
91
|
+
# If the data source encounters any error in any file-- malformed content, a missing file, or a
|
92
|
+
# duplicate key-- it will not load flags from any of the files.
|
93
|
+
#
|
94
|
+
class FileDataSource
|
95
|
+
#
|
96
|
+
# Returns a factory for the file data source component.
|
97
|
+
#
|
98
|
+
# @param options [Hash] the configuration options
|
99
|
+
# @option options [Array] :paths The paths of the source files for loading flag data. These
|
100
|
+
# may be absolute paths or relative to the current working directory.
|
101
|
+
# @option options [Boolean] :auto_update True if the data source should watch for changes to
|
102
|
+
# the source file(s) and reload flags whenever there is a change. Auto-updating will only
|
103
|
+
# work if all of the files you specified have valid directory paths at startup time.
|
104
|
+
# Note that the default implementation of this feature is based on polling the filesystem,
|
105
|
+
# which may not perform well. If you install the 'listen' gem (not included by default, to
|
106
|
+
# avoid adding unwanted dependencies to the SDK), its native file watching mechanism will be
|
107
|
+
# used instead. However, 'listen' will not be used in JRuby 9.1 due to a known instability.
|
108
|
+
# @option options [Float] :poll_interval The minimum interval, in seconds, between checks for
|
109
|
+
# file modifications - used only if auto_update is true, and if the native file-watching
|
110
|
+
# mechanism from 'listen' is not being used. The default value is 1 second.
|
111
|
+
#
|
112
|
+
def self.factory(options={})
|
113
|
+
return Proc.new do |sdk_key, config|
|
114
|
+
FileDataSourceImpl.new(config.feature_store, config.logger, options)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
class FileDataSourceImpl
|
120
|
+
def initialize(feature_store, logger, options={})
|
121
|
+
@feature_store = feature_store
|
122
|
+
@logger = logger
|
123
|
+
@paths = options[:paths] || []
|
124
|
+
if @paths.is_a? String
|
125
|
+
@paths = [ @paths ]
|
126
|
+
end
|
127
|
+
@auto_update = options[:auto_update]
|
128
|
+
if @auto_update && LaunchDarkly.have_listen? && !options[:force_polling] # force_polling is used only for tests
|
129
|
+
# We have seen unreliable behavior in the 'listen' gem in JRuby 9.1 (https://github.com/guard/listen/issues/449).
|
130
|
+
# Therefore, on that platform we'll fall back to file polling instead.
|
131
|
+
if defined?(JRUBY_VERSION) && JRUBY_VERSION.start_with?("9.1.")
|
132
|
+
@use_listen = false
|
133
|
+
else
|
134
|
+
@use_listen = true
|
135
|
+
end
|
136
|
+
end
|
137
|
+
@poll_interval = options[:poll_interval] || 1
|
138
|
+
@initialized = Concurrent::AtomicBoolean.new(false)
|
139
|
+
@ready = Concurrent::Event.new
|
140
|
+
end
|
141
|
+
|
142
|
+
def initialized?
|
143
|
+
@initialized.value
|
144
|
+
end
|
145
|
+
|
146
|
+
def start
|
147
|
+
ready = Concurrent::Event.new
|
148
|
+
|
149
|
+
# We will return immediately regardless of whether the file load succeeded or failed -
|
150
|
+
# the difference can be detected by checking "initialized?"
|
151
|
+
ready.set
|
152
|
+
|
153
|
+
load_all
|
154
|
+
|
155
|
+
if @auto_update
|
156
|
+
# If we're going to watch files, then the start event will be set the first time we get
|
157
|
+
# a successful load.
|
158
|
+
@listener = start_listener
|
159
|
+
end
|
160
|
+
|
161
|
+
ready
|
162
|
+
end
|
163
|
+
|
164
|
+
def stop
|
165
|
+
@listener.stop if !@listener.nil?
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def load_all
|
171
|
+
all_data = {
|
172
|
+
FEATURES => {},
|
173
|
+
SEGMENTS => {}
|
174
|
+
}
|
175
|
+
@paths.each do |path|
|
176
|
+
begin
|
177
|
+
load_file(path, all_data)
|
178
|
+
rescue => exn
|
179
|
+
Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", exn)
|
180
|
+
return
|
181
|
+
end
|
182
|
+
end
|
183
|
+
@feature_store.init(all_data)
|
184
|
+
@initialized.make_true
|
185
|
+
end
|
186
|
+
|
187
|
+
def load_file(path, all_data)
|
188
|
+
parsed = parse_content(IO.read(path))
|
189
|
+
(parsed[:flags] || {}).each do |key, flag|
|
190
|
+
add_item(all_data, FEATURES, flag)
|
191
|
+
end
|
192
|
+
(parsed[:flagValues] || {}).each do |key, value|
|
193
|
+
add_item(all_data, FEATURES, make_flag_with_value(key.to_s, value))
|
194
|
+
end
|
195
|
+
(parsed[:segments] || {}).each do |key, segment|
|
196
|
+
add_item(all_data, SEGMENTS, segment)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def parse_content(content)
|
201
|
+
# We can use the Ruby YAML parser for both YAML and JSON (JSON is a subset of YAML and while
|
202
|
+
# not all YAML parsers handle it correctly, we have verified that the Ruby one does, at least
|
203
|
+
# for all the samples of actual flag data that we've tested).
|
204
|
+
symbolize_all_keys(YAML.load(content))
|
205
|
+
end
|
206
|
+
|
207
|
+
def symbolize_all_keys(value)
|
208
|
+
# This is necessary because YAML.load doesn't have an option for parsing keys as symbols, and
|
209
|
+
# the SDK expects all objects to be formatted that way.
|
210
|
+
if value.is_a?(Hash)
|
211
|
+
value.map{ |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h
|
212
|
+
elsif value.is_a?(Array)
|
213
|
+
value.map{ |v| symbolize_all_keys(v) }
|
214
|
+
else
|
215
|
+
value
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def add_item(all_data, kind, item)
|
220
|
+
items = all_data[kind]
|
221
|
+
raise ArgumentError, "Received unknown item kind #{kind} in add_data" if items.nil? # shouldn't be possible since we preinitialize the hash
|
222
|
+
key = item[:key].to_sym
|
223
|
+
if !items[key].nil?
|
224
|
+
raise ArgumentError, "#{kind[:namespace]} key \"#{item[:key]}\" was used more than once"
|
225
|
+
end
|
226
|
+
items[key] = item
|
227
|
+
end
|
228
|
+
|
229
|
+
def make_flag_with_value(key, value)
|
230
|
+
{
|
231
|
+
key: key,
|
232
|
+
on: true,
|
233
|
+
fallthrough: { variation: 0 },
|
234
|
+
variations: [ value ]
|
235
|
+
}
|
236
|
+
end
|
237
|
+
|
238
|
+
def start_listener
|
239
|
+
resolved_paths = @paths.map { |p| Pathname.new(File.absolute_path(p)).realpath.to_s }
|
240
|
+
if @use_listen
|
241
|
+
start_listener_with_listen_gem(resolved_paths)
|
242
|
+
else
|
243
|
+
FileDataSourcePoller.new(resolved_paths, @poll_interval, self.method(:load_all), @logger)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def start_listener_with_listen_gem(resolved_paths)
|
248
|
+
path_set = resolved_paths.to_set
|
249
|
+
dir_paths = resolved_paths.map{ |p| File.dirname(p) }.uniq
|
250
|
+
opts = { latency: @poll_interval }
|
251
|
+
l = Listen.to(*dir_paths, opts) do |modified, added, removed|
|
252
|
+
paths = modified + added + removed
|
253
|
+
if paths.any? { |p| path_set.include?(p) }
|
254
|
+
load_all
|
255
|
+
end
|
256
|
+
end
|
257
|
+
l.start
|
258
|
+
l
|
259
|
+
end
|
260
|
+
|
261
|
+
#
|
262
|
+
# Used internally by FileDataSource to track data file changes if the 'listen' gem is not available.
|
263
|
+
#
|
264
|
+
class FileDataSourcePoller
|
265
|
+
def initialize(resolved_paths, interval, reloader, logger)
|
266
|
+
@stopped = Concurrent::AtomicBoolean.new(false)
|
267
|
+
get_file_times = Proc.new do
|
268
|
+
ret = {}
|
269
|
+
resolved_paths.each do |path|
|
270
|
+
begin
|
271
|
+
ret[path] = File.mtime(path)
|
272
|
+
rescue Errno::ENOENT
|
273
|
+
ret[path] = nil
|
274
|
+
end
|
275
|
+
end
|
276
|
+
ret
|
277
|
+
end
|
278
|
+
last_times = get_file_times.call
|
279
|
+
@thread = Thread.new do
|
280
|
+
while true
|
281
|
+
sleep interval
|
282
|
+
break if @stopped.value
|
283
|
+
begin
|
284
|
+
new_times = get_file_times.call
|
285
|
+
changed = false
|
286
|
+
last_times.each do |path, old_time|
|
287
|
+
new_time = new_times[path]
|
288
|
+
if !new_time.nil? && new_time != old_time
|
289
|
+
changed = true
|
290
|
+
break
|
291
|
+
end
|
292
|
+
end
|
293
|
+
reloader.call if changed
|
294
|
+
rescue => exn
|
295
|
+
Util.log_exception(logger, "Unexpected exception in FileDataSourcePoller", exn)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def stop
|
302
|
+
@stopped.make_true
|
303
|
+
@thread.run # wakes it up if it's sleeping
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
data/lib/ldclient-rb/ldclient.rb
CHANGED
@@ -39,22 +39,11 @@ module LaunchDarkly
|
|
39
39
|
return # requestor and update processor are not used in this mode
|
40
40
|
end
|
41
41
|
|
42
|
-
|
43
|
-
|
44
|
-
if @config.offline?
|
45
|
-
@update_processor = NullUpdateProcessor.new
|
42
|
+
if @config.update_processor
|
43
|
+
@update_processor = @config.update_processor
|
46
44
|
else
|
47
|
-
|
48
|
-
|
49
|
-
@update_processor = StreamProcessor.new(sdk_key, config, requestor)
|
50
|
-
else
|
51
|
-
@config.logger.info { "Disabling streaming API" }
|
52
|
-
@config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" }
|
53
|
-
@update_processor = PollingProcessor.new(config, requestor)
|
54
|
-
end
|
55
|
-
else
|
56
|
-
@update_processor = @config.update_processor
|
57
|
-
end
|
45
|
+
factory = @config.update_processor_factory || self.method(:create_default_update_processor)
|
46
|
+
@update_processor = factory.call(sdk_key, config)
|
58
47
|
end
|
59
48
|
|
60
49
|
ready = @update_processor.start
|
@@ -269,6 +258,20 @@ module LaunchDarkly
|
|
269
258
|
|
270
259
|
private
|
271
260
|
|
261
|
+
def create_default_update_processor(sdk_key, config)
|
262
|
+
if config.offline?
|
263
|
+
return NullUpdateProcessor.new
|
264
|
+
end
|
265
|
+
requestor = Requestor.new(sdk_key, config)
|
266
|
+
if config.stream?
|
267
|
+
StreamProcessor.new(sdk_key, config, requestor)
|
268
|
+
else
|
269
|
+
config.logger.info { "Disabling streaming API" }
|
270
|
+
config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" }
|
271
|
+
PollingProcessor.new(config, requestor)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
272
275
|
# @return [EvaluationDetail]
|
273
276
|
def evaluate_internal(key, user, default, include_reasons_in_events)
|
274
277
|
if @config.offline?
|
data/lib/ldclient-rb/polling.rb
CHANGED
@@ -26,7 +26,8 @@ module LaunchDarkly
|
|
26
26
|
def stop
|
27
27
|
if @stopped.make_true
|
28
28
|
if @worker && @worker.alive?
|
29
|
-
@worker.
|
29
|
+
@worker.run # causes the thread to wake up if it's currently in a sleep
|
30
|
+
@worker.join
|
30
31
|
end
|
31
32
|
@config.logger.info { "[LDClient] Polling connection stopped" }
|
32
33
|
end
|
data/lib/ldclient-rb/version.rb
CHANGED
@@ -0,0 +1,255 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "tempfile"
|
3
|
+
|
4
|
+
describe LaunchDarkly::FileDataSource do
|
5
|
+
let(:full_flag_1_key) { "flag1" }
|
6
|
+
let(:full_flag_1_value) { "on" }
|
7
|
+
let(:flag_value_1_key) { "flag2" }
|
8
|
+
let(:flag_value_1) { "value2" }
|
9
|
+
let(:all_flag_keys) { [ full_flag_1_key.to_sym, flag_value_1_key.to_sym ] }
|
10
|
+
let(:full_segment_1_key) { "seg1" }
|
11
|
+
let(:all_segment_keys) { [ full_segment_1_key.to_sym ] }
|
12
|
+
|
13
|
+
let(:flag_only_json) { <<-EOF
|
14
|
+
{
|
15
|
+
"flags": {
|
16
|
+
"flag1": {
|
17
|
+
"key": "flag1",
|
18
|
+
"on": true,
|
19
|
+
"fallthrough": {
|
20
|
+
"variation": 2
|
21
|
+
},
|
22
|
+
"variations": [ "fall", "off", "on" ]
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
26
|
+
EOF
|
27
|
+
}
|
28
|
+
|
29
|
+
let(:segment_only_json) { <<-EOF
|
30
|
+
{
|
31
|
+
"segments": {
|
32
|
+
"seg1": {
|
33
|
+
"key": "seg1",
|
34
|
+
"include": ["user1"]
|
35
|
+
}
|
36
|
+
}
|
37
|
+
}
|
38
|
+
EOF
|
39
|
+
}
|
40
|
+
|
41
|
+
let(:all_properties_json) { <<-EOF
|
42
|
+
{
|
43
|
+
"flags": {
|
44
|
+
"flag1": {
|
45
|
+
"key": "flag1",
|
46
|
+
"on": true,
|
47
|
+
"fallthrough": {
|
48
|
+
"variation": 2
|
49
|
+
},
|
50
|
+
"variations": [ "fall", "off", "on" ]
|
51
|
+
}
|
52
|
+
},
|
53
|
+
"flagValues": {
|
54
|
+
"flag2": "value2"
|
55
|
+
},
|
56
|
+
"segments": {
|
57
|
+
"seg1": {
|
58
|
+
"key": "seg1",
|
59
|
+
"include": ["user1"]
|
60
|
+
}
|
61
|
+
}
|
62
|
+
}
|
63
|
+
EOF
|
64
|
+
}
|
65
|
+
|
66
|
+
let(:all_properties_yaml) { <<-EOF
|
67
|
+
---
|
68
|
+
flags:
|
69
|
+
flag1:
|
70
|
+
key: flag1
|
71
|
+
"on": true
|
72
|
+
flagValues:
|
73
|
+
flag2: value2
|
74
|
+
segments:
|
75
|
+
seg1:
|
76
|
+
key: seg1
|
77
|
+
include: ["user1"]
|
78
|
+
EOF
|
79
|
+
}
|
80
|
+
|
81
|
+
let(:bad_file_path) { "no-such-file" }
|
82
|
+
|
83
|
+
before do
|
84
|
+
@config = LaunchDarkly::Config.new
|
85
|
+
@store = @config.feature_store
|
86
|
+
@tmp_dir = Dir.mktmpdir
|
87
|
+
end
|
88
|
+
|
89
|
+
after do
|
90
|
+
FileUtils.remove_dir(@tmp_dir)
|
91
|
+
end
|
92
|
+
|
93
|
+
def make_temp_file(content)
|
94
|
+
# Note that we don't create our files in the default temp file directory, but rather in an empty directory
|
95
|
+
# that we made. That's because (depending on the platform) the temp file directory may contain huge numbers
|
96
|
+
# of files, which can make the file watcher perform poorly enough to break the tests.
|
97
|
+
file = Tempfile.new('flags', @tmp_dir)
|
98
|
+
IO.write(file, content)
|
99
|
+
file
|
100
|
+
end
|
101
|
+
|
102
|
+
def with_data_source(options)
|
103
|
+
factory = LaunchDarkly::FileDataSource.factory(options)
|
104
|
+
ds = factory.call('', @config)
|
105
|
+
begin
|
106
|
+
yield ds
|
107
|
+
ensure
|
108
|
+
ds.stop
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
it "doesn't load flags prior to start" do
|
113
|
+
file = make_temp_file('{"flagValues":{"key":"value"}}')
|
114
|
+
with_data_source({ paths: [ file.path ] }) do |ds|
|
115
|
+
expect(@store.initialized?).to eq(false)
|
116
|
+
expect(@store.all(LaunchDarkly::FEATURES)).to eq({})
|
117
|
+
expect(@store.all(LaunchDarkly::SEGMENTS)).to eq({})
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
it "loads flags on start - from JSON" do
|
122
|
+
file = make_temp_file(all_properties_json)
|
123
|
+
with_data_source({ paths: [ file.path ] }) do |ds|
|
124
|
+
ds.start
|
125
|
+
expect(@store.initialized?).to eq(true)
|
126
|
+
expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys)
|
127
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
it "loads flags on start - from YAML" do
|
132
|
+
file = make_temp_file(all_properties_yaml)
|
133
|
+
with_data_source({ paths: [ file.path ] }) do |ds|
|
134
|
+
ds.start
|
135
|
+
expect(@store.initialized?).to eq(true)
|
136
|
+
expect(@store.all(LaunchDarkly::FEATURES).keys).to eq(all_flag_keys)
|
137
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq(all_segment_keys)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
it "sets start event and initialized on successful load" do
|
142
|
+
file = make_temp_file(all_properties_json)
|
143
|
+
with_data_source({ paths: [ file.path ] }) do |ds|
|
144
|
+
event = ds.start
|
145
|
+
expect(event.set?).to eq(true)
|
146
|
+
expect(ds.initialized?).to eq(true)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
it "sets start event and does not set initialized on unsuccessful load" do
|
151
|
+
with_data_source({ paths: [ bad_file_path ] }) do |ds|
|
152
|
+
event = ds.start
|
153
|
+
expect(event.set?).to eq(true)
|
154
|
+
expect(ds.initialized?).to eq(false)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
it "can load multiple files" do
|
159
|
+
file1 = make_temp_file(flag_only_json)
|
160
|
+
file2 = make_temp_file(segment_only_json)
|
161
|
+
with_data_source({ paths: [ file1.path, file2.path ] }) do |ds|
|
162
|
+
ds.start
|
163
|
+
expect(@store.initialized?).to eq(true)
|
164
|
+
expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([ full_flag_1_key.to_sym ])
|
165
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([ full_segment_1_key.to_sym ])
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
it "does not allow duplicate keys" do
|
170
|
+
file1 = make_temp_file(flag_only_json)
|
171
|
+
file2 = make_temp_file(flag_only_json)
|
172
|
+
with_data_source({ paths: [ file1.path, file2.path ] }) do |ds|
|
173
|
+
ds.start
|
174
|
+
expect(@store.initialized?).to eq(false)
|
175
|
+
expect(@store.all(LaunchDarkly::FEATURES).keys).to eq([])
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
it "does not reload modified file if auto-update is off" do
|
180
|
+
file = make_temp_file(flag_only_json)
|
181
|
+
|
182
|
+
with_data_source({ paths: [ file.path ] }) do |ds|
|
183
|
+
event = ds.start
|
184
|
+
expect(event.set?).to eq(true)
|
185
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([])
|
186
|
+
|
187
|
+
IO.write(file, all_properties_json)
|
188
|
+
sleep(0.5)
|
189
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([])
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def test_auto_reload(options)
|
194
|
+
file = make_temp_file(flag_only_json)
|
195
|
+
options[:paths] = [ file.path ]
|
196
|
+
|
197
|
+
with_data_source(options) do |ds|
|
198
|
+
event = ds.start
|
199
|
+
expect(event.set?).to eq(true)
|
200
|
+
expect(@store.all(LaunchDarkly::SEGMENTS).keys).to eq([])
|
201
|
+
|
202
|
+
sleep(1)
|
203
|
+
IO.write(file, all_properties_json)
|
204
|
+
|
205
|
+
max_time = 10
|
206
|
+
ok = wait_for_condition(10) { @store.all(LaunchDarkly::SEGMENTS).keys == all_segment_keys }
|
207
|
+
expect(ok).to eq(true), "Waited #{max_time}s after modifying file and it did not reload"
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
it "reloads modified file if auto-update is on" do
|
212
|
+
test_auto_reload({ auto_update: true })
|
213
|
+
end
|
214
|
+
|
215
|
+
it "reloads modified file in polling mode" do
|
216
|
+
test_auto_reload({ auto_update: true, force_polling: true, poll_interval: 0.1 })
|
217
|
+
end
|
218
|
+
|
219
|
+
it "evaluates simplified flag with client as expected" do
|
220
|
+
file = make_temp_file(all_properties_json)
|
221
|
+
factory = LaunchDarkly::FileDataSource.factory({ paths: file.path })
|
222
|
+
config = LaunchDarkly::Config.new(send_events: false, update_processor_factory: factory)
|
223
|
+
client = LaunchDarkly::LDClient.new('sdkKey', config)
|
224
|
+
|
225
|
+
begin
|
226
|
+
value = client.variation(flag_value_1_key, { key: 'user' }, '')
|
227
|
+
expect(value).to eq(flag_value_1)
|
228
|
+
ensure
|
229
|
+
client.close
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
it "evaluates full flag with client as expected" do
|
234
|
+
file = make_temp_file(all_properties_json)
|
235
|
+
factory = LaunchDarkly::FileDataSource.factory({ paths: file.path })
|
236
|
+
config = LaunchDarkly::Config.new(send_events: false, update_processor_factory: factory)
|
237
|
+
client = LaunchDarkly::LDClient.new('sdkKey', config)
|
238
|
+
|
239
|
+
begin
|
240
|
+
value = client.variation(full_flag_1_key, { key: 'user' }, '')
|
241
|
+
expect(value).to eq(full_flag_1_value)
|
242
|
+
ensure
|
243
|
+
client.close
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def wait_for_condition(max_time)
|
248
|
+
deadline = Time.now + max_time
|
249
|
+
while Time.now < deadline
|
250
|
+
return true if yield
|
251
|
+
sleep(0.1)
|
252
|
+
end
|
253
|
+
false
|
254
|
+
end
|
255
|
+
end
|
data/spec/polling_spec.rb
CHANGED
@@ -3,10 +3,17 @@ require 'ostruct'
|
|
3
3
|
|
4
4
|
describe LaunchDarkly::PollingProcessor do
|
5
5
|
subject { LaunchDarkly::PollingProcessor }
|
6
|
-
let(:store) { LaunchDarkly::InMemoryFeatureStore.new }
|
7
|
-
let(:config) { LaunchDarkly::Config.new(feature_store: store) }
|
8
6
|
let(:requestor) { double() }
|
9
|
-
|
7
|
+
|
8
|
+
def with_processor(store)
|
9
|
+
config = LaunchDarkly::Config.new(feature_store: store)
|
10
|
+
processor = subject.new(config, requestor)
|
11
|
+
begin
|
12
|
+
yield processor
|
13
|
+
ensure
|
14
|
+
processor.stop
|
15
|
+
end
|
16
|
+
end
|
10
17
|
|
11
18
|
describe 'successful request' do
|
12
19
|
flag = { key: 'flagkey', version: 1 }
|
@@ -22,47 +29,60 @@ describe LaunchDarkly::PollingProcessor do
|
|
22
29
|
|
23
30
|
it 'puts feature data in store' do
|
24
31
|
allow(requestor).to receive(:request_all_data).and_return(all_data)
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
32
|
+
store = LaunchDarkly::InMemoryFeatureStore.new
|
33
|
+
with_processor(store) do |processor|
|
34
|
+
ready = processor.start
|
35
|
+
ready.wait
|
36
|
+
expect(store.get(LaunchDarkly::FEATURES, "flagkey")).to eq(flag)
|
37
|
+
expect(store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(segment)
|
38
|
+
end
|
29
39
|
end
|
30
40
|
|
31
41
|
it 'sets initialized to true' do
|
32
42
|
allow(requestor).to receive(:request_all_data).and_return(all_data)
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
43
|
+
store = LaunchDarkly::InMemoryFeatureStore.new
|
44
|
+
with_processor(store) do |processor|
|
45
|
+
ready = processor.start
|
46
|
+
ready.wait
|
47
|
+
expect(processor.initialized?).to be true
|
48
|
+
expect(store.initialized?).to be true
|
49
|
+
end
|
37
50
|
end
|
38
51
|
end
|
39
52
|
|
40
53
|
describe 'connection error' do
|
41
54
|
it 'does not cause immediate failure, does not set initialized' do
|
42
55
|
allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error"))
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
56
|
+
store = LaunchDarkly::InMemoryFeatureStore.new
|
57
|
+
with_processor(store) do |processor|
|
58
|
+
ready = processor.start
|
59
|
+
finished = ready.wait(0.2)
|
60
|
+
expect(finished).to be false
|
61
|
+
expect(processor.initialized?).to be false
|
62
|
+
expect(store.initialized?).to be false
|
63
|
+
end
|
48
64
|
end
|
49
65
|
end
|
50
66
|
|
51
67
|
describe 'HTTP errors' do
|
52
68
|
def verify_unrecoverable_http_error(status)
|
53
69
|
allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status))
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
70
|
+
with_processor(LaunchDarkly::InMemoryFeatureStore.new) do |processor|
|
71
|
+
ready = processor.start
|
72
|
+
finished = ready.wait(0.2)
|
73
|
+
expect(finished).to be true
|
74
|
+
expect(processor.initialized?).to be false
|
75
|
+
end
|
58
76
|
end
|
59
77
|
|
60
78
|
def verify_recoverable_http_error(status)
|
61
79
|
allow(requestor).to receive(:request_all_data).and_raise(LaunchDarkly::UnexpectedResponseError.new(status))
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
80
|
+
with_processor(LaunchDarkly::InMemoryFeatureStore.new) do |processor|
|
81
|
+
ready = processor.start
|
82
|
+
finished = ready.wait(0.2)
|
83
|
+
expect(finished).to be false
|
84
|
+
expect(processor.initialized?).to be false
|
85
|
+
end
|
66
86
|
end
|
67
87
|
|
68
88
|
it 'stops immediately for error 401' do
|
@@ -85,5 +105,16 @@ describe LaunchDarkly::PollingProcessor do
|
|
85
105
|
verify_recoverable_http_error(503)
|
86
106
|
end
|
87
107
|
end
|
88
|
-
end
|
89
108
|
|
109
|
+
describe 'stop' do
|
110
|
+
it 'stops promptly rather than continuing to wait for poll interval' do
|
111
|
+
with_processor(LaunchDarkly::InMemoryFeatureStore.new) do |processor|
|
112
|
+
sleep(1) # somewhat arbitrary, but should ensure that it has started polling
|
113
|
+
start_time = Time.now
|
114
|
+
processor.stop
|
115
|
+
end_time = Time.now
|
116
|
+
expect(end_time - start_time).to be <(LaunchDarkly::Config.default_poll_interval - 5)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ldclient-rb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 5.
|
4
|
+
version: 5.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- LaunchDarkly
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-11-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -122,6 +122,20 @@ dependencies:
|
|
122
122
|
- - "~>"
|
123
123
|
- !ruby/object:Gem::Version
|
124
124
|
version: 0.9.1
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: listen
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '3.0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '3.0'
|
125
139
|
- !ruby/object:Gem::Dependency
|
126
140
|
name: json
|
127
141
|
requirement: !ruby/object:Gem::Requirement
|
@@ -310,6 +324,7 @@ files:
|
|
310
324
|
- lib/ldclient-rb/event_summarizer.rb
|
311
325
|
- lib/ldclient-rb/events.rb
|
312
326
|
- lib/ldclient-rb/expiring_cache.rb
|
327
|
+
- lib/ldclient-rb/file_data_source.rb
|
313
328
|
- lib/ldclient-rb/flags_state.rb
|
314
329
|
- lib/ldclient-rb/in_memory_store.rb
|
315
330
|
- lib/ldclient-rb/ldclient.rb
|
@@ -336,6 +351,7 @@ files:
|
|
336
351
|
- spec/events_spec.rb
|
337
352
|
- spec/expiring_cache_spec.rb
|
338
353
|
- spec/feature_store_spec_base.rb
|
354
|
+
- spec/file_data_source_spec.rb
|
339
355
|
- spec/fixtures/feature.json
|
340
356
|
- spec/fixtures/feature1.json
|
341
357
|
- spec/fixtures/numeric_key_user.json
|
@@ -391,6 +407,7 @@ test_files:
|
|
391
407
|
- spec/events_spec.rb
|
392
408
|
- spec/expiring_cache_spec.rb
|
393
409
|
- spec/feature_store_spec_base.rb
|
410
|
+
- spec/file_data_source_spec.rb
|
394
411
|
- spec/fixtures/feature.json
|
395
412
|
- spec/fixtures/feature1.json
|
396
413
|
- spec/fixtures/numeric_key_user.json
|