launchdarkly-server-sdk 5.5.7

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 (87) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +134 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +15 -0
  6. data/.hound.yml +2 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +600 -0
  9. data/.simplecov +4 -0
  10. data/.yardopts +9 -0
  11. data/CHANGELOG.md +261 -0
  12. data/CODEOWNERS +1 -0
  13. data/CONTRIBUTING.md +37 -0
  14. data/Gemfile +3 -0
  15. data/Gemfile.lock +102 -0
  16. data/LICENSE.txt +13 -0
  17. data/README.md +56 -0
  18. data/Rakefile +5 -0
  19. data/azure-pipelines.yml +51 -0
  20. data/ext/mkrf_conf.rb +11 -0
  21. data/launchdarkly-server-sdk.gemspec +40 -0
  22. data/lib/ldclient-rb.rb +29 -0
  23. data/lib/ldclient-rb/cache_store.rb +45 -0
  24. data/lib/ldclient-rb/config.rb +411 -0
  25. data/lib/ldclient-rb/evaluation.rb +455 -0
  26. data/lib/ldclient-rb/event_summarizer.rb +55 -0
  27. data/lib/ldclient-rb/events.rb +468 -0
  28. data/lib/ldclient-rb/expiring_cache.rb +77 -0
  29. data/lib/ldclient-rb/file_data_source.rb +312 -0
  30. data/lib/ldclient-rb/flags_state.rb +76 -0
  31. data/lib/ldclient-rb/impl.rb +13 -0
  32. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  33. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  34. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  35. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  36. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  37. data/lib/ldclient-rb/in_memory_store.rb +100 -0
  38. data/lib/ldclient-rb/integrations.rb +55 -0
  39. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  40. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  41. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  42. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  43. data/lib/ldclient-rb/interfaces.rb +153 -0
  44. data/lib/ldclient-rb/ldclient.rb +424 -0
  45. data/lib/ldclient-rb/memoized_value.rb +32 -0
  46. data/lib/ldclient-rb/newrelic.rb +17 -0
  47. data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
  48. data/lib/ldclient-rb/polling.rb +78 -0
  49. data/lib/ldclient-rb/redis_store.rb +87 -0
  50. data/lib/ldclient-rb/requestor.rb +101 -0
  51. data/lib/ldclient-rb/simple_lru_cache.rb +25 -0
  52. data/lib/ldclient-rb/stream.rb +141 -0
  53. data/lib/ldclient-rb/user_filter.rb +51 -0
  54. data/lib/ldclient-rb/util.rb +50 -0
  55. data/lib/ldclient-rb/version.rb +3 -0
  56. data/scripts/gendocs.sh +11 -0
  57. data/scripts/release.sh +27 -0
  58. data/spec/config_spec.rb +63 -0
  59. data/spec/evaluation_spec.rb +739 -0
  60. data/spec/event_summarizer_spec.rb +63 -0
  61. data/spec/events_spec.rb +642 -0
  62. data/spec/expiring_cache_spec.rb +76 -0
  63. data/spec/feature_store_spec_base.rb +213 -0
  64. data/spec/file_data_source_spec.rb +255 -0
  65. data/spec/fixtures/feature.json +37 -0
  66. data/spec/fixtures/feature1.json +36 -0
  67. data/spec/fixtures/user.json +9 -0
  68. data/spec/flags_state_spec.rb +81 -0
  69. data/spec/http_util.rb +109 -0
  70. data/spec/in_memory_feature_store_spec.rb +12 -0
  71. data/spec/integrations/consul_feature_store_spec.rb +42 -0
  72. data/spec/integrations/dynamodb_feature_store_spec.rb +105 -0
  73. data/spec/integrations/store_wrapper_spec.rb +276 -0
  74. data/spec/ldclient_spec.rb +471 -0
  75. data/spec/newrelic_spec.rb +5 -0
  76. data/spec/polling_spec.rb +120 -0
  77. data/spec/redis_feature_store_spec.rb +95 -0
  78. data/spec/requestor_spec.rb +214 -0
  79. data/spec/segment_store_spec_base.rb +95 -0
  80. data/spec/simple_lru_cache_spec.rb +24 -0
  81. data/spec/spec_helper.rb +9 -0
  82. data/spec/store_spec.rb +10 -0
  83. data/spec/stream_spec.rb +60 -0
  84. data/spec/user_filter_spec.rb +91 -0
  85. data/spec/util_spec.rb +17 -0
  86. data/spec/version_spec.rb +7 -0
  87. metadata +375 -0
@@ -0,0 +1,32 @@
1
+
2
+ module LaunchDarkly
3
+ # Simple implementation of a thread-safe memoized value whose generator function will never be
4
+ # run more than once, and whose value can be overridden by explicit assignment.
5
+ # Note that we no longer use this class and it will be removed in a future version.
6
+ # @private
7
+ class MemoizedValue
8
+ def initialize(&generator)
9
+ @generator = generator
10
+ @mutex = Mutex.new
11
+ @inited = false
12
+ @value = nil
13
+ end
14
+
15
+ def get
16
+ @mutex.synchronize do
17
+ if !@inited
18
+ @value = @generator.call
19
+ @inited = true
20
+ end
21
+ end
22
+ @value
23
+ end
24
+
25
+ def set(value)
26
+ @mutex.synchronize do
27
+ @value = value
28
+ @inited = true
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ module LaunchDarkly
2
+ # @private
3
+ class LDNewRelic
4
+ begin
5
+ require "newrelic_rpm"
6
+ NR_ENABLED = defined?(::NewRelic::Agent.add_custom_parameters)
7
+ rescue ScriptError, StandardError
8
+ NR_ENABLED = false
9
+ end
10
+
11
+ def self.annotate_transaction(key, value)
12
+ if NR_ENABLED
13
+ ::NewRelic::Agent.add_custom_parameters(key.to_s => value.to_s)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,46 @@
1
+ require "concurrent"
2
+ require "concurrent/atomics"
3
+ require "concurrent/executors"
4
+ require "thread"
5
+
6
+ module LaunchDarkly
7
+ # Simple wrapper for a FixedThreadPool that rejects new jobs if all the threads are busy, rather
8
+ # than blocking. Also provides a way to wait for all jobs to finish without shutting down.
9
+ # @private
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
@@ -0,0 +1,78 @@
1
+ require "concurrent/atomics"
2
+ require "thread"
3
+
4
+ module LaunchDarkly
5
+ # @private
6
+ class PollingProcessor
7
+ def initialize(config, requestor)
8
+ @config = config
9
+ @requestor = requestor
10
+ @initialized = Concurrent::AtomicBoolean.new(false)
11
+ @started = Concurrent::AtomicBoolean.new(false)
12
+ @stopped = Concurrent::AtomicBoolean.new(false)
13
+ @ready = Concurrent::Event.new
14
+ end
15
+
16
+ def initialized?
17
+ @initialized.value
18
+ end
19
+
20
+ def start
21
+ return @ready unless @started.make_true
22
+ @config.logger.info { "[LDClient] Initializing polling connection" }
23
+ create_worker
24
+ @ready
25
+ end
26
+
27
+ def stop
28
+ if @stopped.make_true
29
+ if @worker && @worker.alive? && @worker != Thread.current
30
+ @worker.run # causes the thread to wake up if it's currently in a sleep
31
+ @worker.join
32
+ end
33
+ @config.logger.info { "[LDClient] Polling connection stopped" }
34
+ end
35
+ end
36
+
37
+ def poll
38
+ all_data = @requestor.request_all_data
39
+ if all_data
40
+ @config.feature_store.init({
41
+ FEATURES => all_data[:flags],
42
+ SEGMENTS => all_data[:segments]
43
+ })
44
+ if @initialized.make_true
45
+ @config.logger.info { "[LDClient] Polling connection initialized" }
46
+ @ready.set
47
+ end
48
+ end
49
+ end
50
+
51
+ def create_worker
52
+ @worker = Thread.new do
53
+ @config.logger.debug { "[LDClient] Starting polling worker" }
54
+ while !@stopped.value do
55
+ started_at = Time.now
56
+ begin
57
+ poll
58
+ rescue UnexpectedResponseError => e
59
+ message = Util.http_error_message(e.status, "polling request", "will retry")
60
+ @config.logger.error { "[LDClient] #{message}" };
61
+ if !Util.http_error_recoverable?(e.status)
62
+ @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set
63
+ stop
64
+ end
65
+ rescue StandardError => exn
66
+ Util.log_exception(@config.logger, "Exception while polling", exn)
67
+ end
68
+ delta = @config.poll_interval - (Time.now - started_at)
69
+ if delta > 0
70
+ sleep(delta)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ private :poll, :create_worker
77
+ end
78
+ end
@@ -0,0 +1,87 @@
1
+ require "ldclient-rb/interfaces"
2
+ require "ldclient-rb/impl/integrations/redis_impl"
3
+
4
+ module LaunchDarkly
5
+ #
6
+ # An implementation of the LaunchDarkly client's feature store that uses a Redis
7
+ # instance. This object holds feature flags and related data received from the
8
+ # streaming API. Feature data can also be further cached in memory to reduce overhead
9
+ # of calls to Redis.
10
+ #
11
+ # To use this class, you must first have the `redis` and `connection-pool` gems
12
+ # installed. Then, create an instance and store it in the `feature_store` property
13
+ # of your client configuration.
14
+ #
15
+ # @deprecated Use the factory method in {LaunchDarkly::Integrations::Redis} instead. This specific
16
+ # implementation class may be changed or removed in the future.
17
+ #
18
+ class RedisFeatureStore
19
+ include LaunchDarkly::Interfaces::FeatureStore
20
+
21
+ # Note that this class is now just a facade around CachingStoreWrapper, which is in turn delegating
22
+ # to RedisFeatureStoreCore where the actual database logic is. This class was retained for historical
23
+ # reasons, so that existing code can still call RedisFeatureStore.new. In the future, we will migrate
24
+ # away from exposing these concrete classes and use factory methods instead.
25
+
26
+ #
27
+ # Constructor for a RedisFeatureStore instance.
28
+ #
29
+ # @param opts [Hash] the configuration options
30
+ # @option opts [String] :redis_url URL of the Redis instance (shortcut for omitting redis_opts)
31
+ # @option opts [Hash] :redis_opts options to pass to the Redis constructor (if you want to specify more than just redis_url)
32
+ # @option opts [String] :prefix namespace prefix to add to all hash keys used by LaunchDarkly
33
+ # @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger`
34
+ # @option opts [Integer] :max_connections size of the Redis connection pool
35
+ # @option opts [Integer] :expiration expiration time for the in-memory cache, in seconds; 0 for no local caching
36
+ # @option opts [Integer] :capacity maximum number of feature flags (or related objects) to cache locally
37
+ # @option opts [Object] :pool custom connection pool, if desired
38
+ #
39
+ def initialize(opts = {})
40
+ core = LaunchDarkly::Impl::Integrations::Redis::RedisFeatureStoreCore.new(opts)
41
+ @wrapper = LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts)
42
+ end
43
+
44
+ #
45
+ # Default value for the `redis_url` constructor parameter; points to an instance of Redis
46
+ # running at `localhost` with its default port.
47
+ #
48
+ def self.default_redis_url
49
+ LaunchDarkly::Integrations::Redis::default_redis_url
50
+ end
51
+
52
+ #
53
+ # Default value for the `prefix` constructor parameter.
54
+ #
55
+ def self.default_prefix
56
+ LaunchDarkly::Integrations::Redis::default_prefix
57
+ end
58
+
59
+ def get(kind, key)
60
+ @wrapper.get(kind, key)
61
+ end
62
+
63
+ def all(kind)
64
+ @wrapper.all(kind)
65
+ end
66
+
67
+ def delete(kind, key, version)
68
+ @wrapper.delete(kind, key, version)
69
+ end
70
+
71
+ def init(all_data)
72
+ @wrapper.init(all_data)
73
+ end
74
+
75
+ def upsert(kind, item)
76
+ @wrapper.upsert(kind, item)
77
+ end
78
+
79
+ def initialized?
80
+ @wrapper.initialized?
81
+ end
82
+
83
+ def stop
84
+ @wrapper.stop
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,101 @@
1
+ require "concurrent/atomics"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module LaunchDarkly
6
+ # @private
7
+ class UnexpectedResponseError < StandardError
8
+ def initialize(status)
9
+ @status = status
10
+ super("HTTP error #{status}")
11
+ end
12
+
13
+ def status
14
+ @status
15
+ end
16
+ end
17
+
18
+ # @private
19
+ class Requestor
20
+ CacheEntry = Struct.new(:etag, :body)
21
+
22
+ def initialize(sdk_key, config)
23
+ @sdk_key = sdk_key
24
+ @config = config
25
+ @client = Util.new_http_client(@config.base_uri, @config)
26
+ @cache = @config.cache_store
27
+ end
28
+
29
+ def request_flag(key)
30
+ make_request("/sdk/latest-flags/" + key)
31
+ end
32
+
33
+ def request_segment(key)
34
+ make_request("/sdk/latest-segments/" + key)
35
+ end
36
+
37
+ def request_all_data()
38
+ make_request("/sdk/latest-all")
39
+ end
40
+
41
+ def stop
42
+ begin
43
+ @client.finish
44
+ rescue
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def make_request(path)
51
+ @client.start if !@client.started?
52
+ uri = URI(@config.base_uri + path)
53
+ req = Net::HTTP::Get.new(uri)
54
+ req["Authorization"] = @sdk_key
55
+ req["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
56
+ req["Connection"] = "keep-alive"
57
+ cached = @cache.read(uri)
58
+ if !cached.nil?
59
+ req["If-None-Match"] = cached.etag
60
+ end
61
+ res = @client.request(req)
62
+ status = res.code.to_i
63
+ @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{res.to_hash}\n\tbody: #{res.body}" }
64
+
65
+ if status == 304 && !cached.nil?
66
+ body = cached.body
67
+ else
68
+ @cache.delete(uri)
69
+ if status < 200 || status >= 300
70
+ raise UnexpectedResponseError.new(status)
71
+ end
72
+ body = fix_encoding(res.body, res["content-type"])
73
+ etag = res["etag"]
74
+ @cache.write(uri, CacheEntry.new(etag, body)) if !etag.nil?
75
+ end
76
+ JSON.parse(body, symbolize_names: true)
77
+ end
78
+
79
+ def fix_encoding(body, content_type)
80
+ return body if content_type.nil?
81
+ media_type, charset = parse_content_type(content_type)
82
+ return body if charset.nil?
83
+ body.force_encoding(Encoding::find(charset)).encode(Encoding::UTF_8)
84
+ end
85
+
86
+ def parse_content_type(value)
87
+ return [nil, nil] if value.nil? || value == ''
88
+ parts = value.split(/; */)
89
+ return [value, nil] if parts.count < 2
90
+ charset = nil
91
+ parts.each do |part|
92
+ fields = part.split('=')
93
+ if fields.count >= 2 && fields[0] == 'charset'
94
+ charset = fields[1]
95
+ break
96
+ end
97
+ end
98
+ return [parts[0], charset]
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,25 @@
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
+ # @private
6
+ class SimpleLRUCacheSet
7
+ def initialize(capacity)
8
+ @values = {}
9
+ @capacity = capacity
10
+ end
11
+
12
+ # Adds a value to the cache or marks it recent if it was already there. Returns true if already there.
13
+ def add(value)
14
+ found = true
15
+ @values.delete(value) { found = false }
16
+ @values[value] = true
17
+ @values.shift if @values.length > @capacity
18
+ found
19
+ end
20
+
21
+ def clear
22
+ @values = {}
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,141 @@
1
+ require "concurrent/atomics"
2
+ require "json"
3
+ require "ld-eventsource"
4
+
5
+ module LaunchDarkly
6
+ # @private
7
+ PUT = :put
8
+ # @private
9
+ PATCH = :patch
10
+ # @private
11
+ DELETE = :delete
12
+ # @private
13
+ INDIRECT_PUT = :'indirect/put'
14
+ # @private
15
+ INDIRECT_PATCH = :'indirect/patch'
16
+ # @private
17
+ READ_TIMEOUT_SECONDS = 300 # 5 minutes; the stream should send a ping every 3 minutes
18
+
19
+ # @private
20
+ KEY_PATHS = {
21
+ FEATURES => "/flags/",
22
+ SEGMENTS => "/segments/"
23
+ }
24
+
25
+ # @private
26
+ class StreamProcessor
27
+ def initialize(sdk_key, config, requestor)
28
+ @sdk_key = sdk_key
29
+ @config = config
30
+ @feature_store = config.feature_store
31
+ @requestor = requestor
32
+ @initialized = Concurrent::AtomicBoolean.new(false)
33
+ @started = Concurrent::AtomicBoolean.new(false)
34
+ @stopped = Concurrent::AtomicBoolean.new(false)
35
+ @ready = Concurrent::Event.new
36
+ end
37
+
38
+ def initialized?
39
+ @initialized.value
40
+ end
41
+
42
+ def start
43
+ return @ready unless @started.make_true
44
+
45
+ @config.logger.info { "[LDClient] Initializing stream connection" }
46
+
47
+ headers = {
48
+ 'Authorization' => @sdk_key,
49
+ 'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION
50
+ }
51
+ opts = {
52
+ headers: headers,
53
+ read_timeout: READ_TIMEOUT_SECONDS,
54
+ logger: @config.logger
55
+ }
56
+ @es = SSE::Client.new(@config.stream_uri + "/all", **opts) do |conn|
57
+ conn.on_event { |event| process_message(event) }
58
+ conn.on_error { |err|
59
+ case err
60
+ when SSE::Errors::HTTPStatusError
61
+ status = err.status
62
+ message = Util.http_error_message(status, "streaming connection", "will retry")
63
+ @config.logger.error { "[LDClient] #{message}" }
64
+ if !Util.http_error_recoverable?(status)
65
+ @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set
66
+ stop
67
+ end
68
+ end
69
+ }
70
+ end
71
+
72
+ @ready
73
+ end
74
+
75
+ def stop
76
+ if @stopped.make_true
77
+ @es.close
78
+ @config.logger.info { "[LDClient] Stream connection stopped" }
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def process_message(message)
85
+ method = message.type
86
+ @config.logger.debug { "[LDClient] Stream received #{method} message: #{message.data}" }
87
+ if method == PUT
88
+ message = JSON.parse(message.data, symbolize_names: true)
89
+ @feature_store.init({
90
+ FEATURES => message[:data][:flags],
91
+ SEGMENTS => message[:data][:segments]
92
+ })
93
+ @initialized.make_true
94
+ @config.logger.info { "[LDClient] Stream initialized" }
95
+ @ready.set
96
+ elsif method == PATCH
97
+ data = JSON.parse(message.data, symbolize_names: true)
98
+ for kind in [FEATURES, SEGMENTS]
99
+ key = key_for_path(kind, data[:path])
100
+ if key
101
+ @feature_store.upsert(kind, data[:data])
102
+ break
103
+ end
104
+ end
105
+ elsif method == DELETE
106
+ data = JSON.parse(message.data, symbolize_names: true)
107
+ for kind in [FEATURES, SEGMENTS]
108
+ key = key_for_path(kind, data[:path])
109
+ if key
110
+ @feature_store.delete(kind, key, data[:version])
111
+ break
112
+ end
113
+ end
114
+ elsif method == INDIRECT_PUT
115
+ all_data = @requestor.request_all_data
116
+ @feature_store.init({
117
+ FEATURES => all_data[:flags],
118
+ SEGMENTS => all_data[:segments]
119
+ })
120
+ @initialized.make_true
121
+ @config.logger.info { "[LDClient] Stream initialized (via indirect message)" }
122
+ elsif method == INDIRECT_PATCH
123
+ key = key_for_path(FEATURES, message.data)
124
+ if key
125
+ @feature_store.upsert(FEATURES, @requestor.request_flag(key))
126
+ else
127
+ key = key_for_path(SEGMENTS, message.data)
128
+ if key
129
+ @feature_store.upsert(SEGMENTS, @requestor.request_segment(key))
130
+ end
131
+ end
132
+ else
133
+ @config.logger.warn { "[LDClient] Unknown message received: #{method}" }
134
+ end
135
+ end
136
+
137
+ def key_for_path(kind, path)
138
+ path.start_with?(KEY_PATHS[kind]) ? path[KEY_PATHS[kind].length..-1] : nil
139
+ end
140
+ end
141
+ end