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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c11f5029a26f9320594fad4b3e580add838f46dd
4
- data.tar.gz: e9d8569a3f7b96a0cdbbc7ecedc1f76b798c8f1f
3
+ metadata.gz: 1638ba1d1609d7d8a027f23a679da4fda6112ff2
4
+ data.tar.gz: cd6bd5a3a4a24d2e69174a76c4cff1bfe75d54ec
5
5
  SHA512:
6
- metadata.gz: 55780c3aec6537b3491fe4c960a7475a29c3ea529b337ad3f3556140c9faa949c2de44340837ef1d3f069b043aa2e80de05a790e575f0b868b5c3e382c076cb6
7
- data.tar.gz: aac072bbc8e7dc99c64ee282da1dd735c87d5f76518dab6e5c1ca3e86dfd11148b4b53e44ac40624b0db61a2fba61acc1b9fa2dacec595d71d4082505f3e3f3a
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
@@ -18,3 +18,4 @@ require "ldclient-rb/event_summarizer"
18
18
  require "ldclient-rb/events"
19
19
  require "ldclient-rb/redis_store"
20
20
  require "ldclient-rb/requestor"
21
+ require "ldclient-rb/file_data_source"
@@ -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 LaunchDarkly.
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
@@ -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
- requestor = Requestor.new(sdk_key, config)
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
- if @config.update_processor.nil?
48
- if @config.stream?
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?
@@ -26,7 +26,8 @@ module LaunchDarkly
26
26
  def stop
27
27
  if @stopped.make_true
28
28
  if @worker && @worker.alive?
29
- @worker.raise "shutting down client"
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
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "5.3.0"
2
+ VERSION = "5.4.1"
3
3
  end
@@ -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
- let(:processor) { subject.new(config, requestor) }
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
- ready = processor.start
26
- ready.wait
27
- expect(store.get(LaunchDarkly::FEATURES, "flagkey")).to eq(flag)
28
- expect(store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(segment)
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
- ready = processor.start
34
- ready.wait
35
- expect(processor.initialized?).to be true
36
- expect(store.initialized?).to be true
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
- ready = processor.start
44
- finished = ready.wait(0.2)
45
- expect(finished).to be false
46
- expect(processor.initialized?).to be false
47
- expect(store.initialized?).to be false
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
- ready = processor.start
55
- finished = ready.wait(0.2)
56
- expect(finished).to be true
57
- expect(processor.initialized?).to be false
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
- ready = processor.start
63
- finished = ready.wait(0.2)
64
- expect(finished).to be false
65
- expect(processor.initialized?).to be false
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.3.0
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-10-24 00:00:00.000000000 Z
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