launchdarkly-server-sdk 6.2.2 → 6.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/ldclient-rb/config.rb +81 -4
- data/lib/ldclient-rb/evaluation_detail.rb +67 -8
- data/lib/ldclient-rb/file_data_source.rb +9 -300
- data/lib/ldclient-rb/impl/big_segments.rb +117 -0
- data/lib/ldclient-rb/impl/diagnostic_events.rb +1 -1
- data/lib/ldclient-rb/impl/evaluator.rb +80 -28
- data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +82 -18
- data/lib/ldclient-rb/impl/integrations/file_data_source.rb +212 -0
- data/lib/ldclient-rb/impl/integrations/redis_impl.rb +84 -31
- data/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb +40 -0
- data/lib/ldclient-rb/impl/repeating_task.rb +47 -0
- data/lib/ldclient-rb/impl/util.rb +4 -1
- data/lib/ldclient-rb/integrations/consul.rb +7 -0
- data/lib/ldclient-rb/integrations/dynamodb.rb +47 -2
- data/lib/ldclient-rb/integrations/file_data.rb +108 -0
- data/lib/ldclient-rb/integrations/redis.rb +41 -1
- data/lib/ldclient-rb/integrations/test_data/flag_builder.rb +438 -0
- data/lib/ldclient-rb/integrations/test_data.rb +209 -0
- data/lib/ldclient-rb/integrations/util/store_wrapper.rb +5 -0
- data/lib/ldclient-rb/integrations.rb +2 -51
- data/lib/ldclient-rb/interfaces.rb +152 -2
- data/lib/ldclient-rb/ldclient.rb +21 -7
- data/lib/ldclient-rb/polling.rb +22 -41
- data/lib/ldclient-rb/util.rb +1 -1
- data/lib/ldclient-rb/version.rb +1 -1
- metadata +31 -132
- data/.circleci/config.yml +0 -40
- data/.github/ISSUE_TEMPLATE/bug_report.md +0 -37
- data/.github/ISSUE_TEMPLATE/config.yml +0 -5
- data/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- data/.github/pull_request_template.md +0 -21
- data/.gitignore +0 -16
- data/.hound.yml +0 -2
- data/.ldrelease/build-docs.sh +0 -18
- data/.ldrelease/circleci/linux/execute.sh +0 -18
- data/.ldrelease/circleci/mac/execute.sh +0 -18
- data/.ldrelease/circleci/template/build.sh +0 -29
- data/.ldrelease/circleci/template/publish.sh +0 -23
- data/.ldrelease/circleci/template/set-gem-home.sh +0 -7
- data/.ldrelease/circleci/template/test.sh +0 -10
- data/.ldrelease/circleci/template/update-version.sh +0 -8
- data/.ldrelease/circleci/windows/execute.ps1 +0 -19
- data/.ldrelease/config.yml +0 -29
- data/.rspec +0 -2
- data/.rubocop.yml +0 -600
- data/.simplecov +0 -4
- data/CHANGELOG.md +0 -363
- data/CODEOWNERS +0 -1
- data/CONTRIBUTING.md +0 -37
- data/Gemfile +0 -3
- data/azure-pipelines.yml +0 -51
- data/docs/Makefile +0 -26
- data/docs/index.md +0 -9
- data/launchdarkly-server-sdk.gemspec +0 -45
- data/spec/config_spec.rb +0 -63
- data/spec/diagnostic_events_spec.rb +0 -163
- data/spec/evaluation_detail_spec.rb +0 -135
- data/spec/event_sender_spec.rb +0 -197
- data/spec/event_summarizer_spec.rb +0 -63
- data/spec/events_spec.rb +0 -607
- data/spec/expiring_cache_spec.rb +0 -76
- data/spec/feature_store_spec_base.rb +0 -213
- data/spec/file_data_source_spec.rb +0 -283
- data/spec/fixtures/feature.json +0 -37
- data/spec/fixtures/feature1.json +0 -36
- data/spec/fixtures/user.json +0 -9
- data/spec/flags_state_spec.rb +0 -81
- data/spec/http_util.rb +0 -132
- data/spec/impl/evaluator_bucketing_spec.rb +0 -216
- data/spec/impl/evaluator_clause_spec.rb +0 -55
- data/spec/impl/evaluator_operators_spec.rb +0 -141
- data/spec/impl/evaluator_rule_spec.rb +0 -128
- data/spec/impl/evaluator_segment_spec.rb +0 -125
- data/spec/impl/evaluator_spec.rb +0 -349
- data/spec/impl/evaluator_spec_base.rb +0 -75
- data/spec/impl/event_factory_spec.rb +0 -108
- data/spec/impl/model/serialization_spec.rb +0 -41
- data/spec/in_memory_feature_store_spec.rb +0 -12
- data/spec/integrations/consul_feature_store_spec.rb +0 -40
- data/spec/integrations/dynamodb_feature_store_spec.rb +0 -103
- data/spec/integrations/store_wrapper_spec.rb +0 -276
- data/spec/launchdarkly-server-sdk_spec.rb +0 -13
- data/spec/launchdarkly-server-sdk_spec_autoloadtest.rb +0 -9
- data/spec/ldclient_end_to_end_spec.rb +0 -157
- data/spec/ldclient_spec.rb +0 -635
- data/spec/newrelic_spec.rb +0 -5
- data/spec/polling_spec.rb +0 -120
- data/spec/redis_feature_store_spec.rb +0 -121
- data/spec/requestor_spec.rb +0 -209
- data/spec/segment_store_spec_base.rb +0 -95
- data/spec/simple_lru_cache_spec.rb +0 -24
- data/spec/spec_helper.rb +0 -9
- data/spec/store_spec.rb +0 -10
- data/spec/stream_spec.rb +0 -45
- data/spec/user_filter_spec.rb +0 -91
- data/spec/util_spec.rb +0 -17
- data/spec/version_spec.rb +0 -7
@@ -0,0 +1,117 @@
|
|
1
|
+
require "ldclient-rb/config"
|
2
|
+
require "ldclient-rb/expiring_cache"
|
3
|
+
require "ldclient-rb/impl/repeating_task"
|
4
|
+
require "ldclient-rb/interfaces"
|
5
|
+
require "ldclient-rb/util"
|
6
|
+
|
7
|
+
require "digest"
|
8
|
+
|
9
|
+
module LaunchDarkly
|
10
|
+
module Impl
|
11
|
+
BigSegmentMembershipResult = Struct.new(:membership, :status)
|
12
|
+
|
13
|
+
class BigSegmentStoreManager
|
14
|
+
# use this as a singleton whenever a membership query returns nil; it's safe to reuse it because
|
15
|
+
# we will never modify the membership properties after they're queried
|
16
|
+
EMPTY_MEMBERSHIP = {}
|
17
|
+
|
18
|
+
def initialize(big_segments_config, logger)
|
19
|
+
@store = big_segments_config.store
|
20
|
+
@stale_after_millis = big_segments_config.stale_after * 1000
|
21
|
+
@status_provider = BigSegmentStoreStatusProviderImpl.new(-> { get_status })
|
22
|
+
@logger = logger
|
23
|
+
@last_status = nil
|
24
|
+
|
25
|
+
if !@store.nil?
|
26
|
+
@cache = ExpiringCache.new(big_segments_config.user_cache_size, big_segments_config.user_cache_time)
|
27
|
+
@poll_worker = RepeatingTask.new(big_segments_config.status_poll_interval, 0, -> { poll_store_and_update_status }, logger)
|
28
|
+
@poll_worker.start
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :status_provider
|
33
|
+
|
34
|
+
def stop
|
35
|
+
@poll_worker.stop if !@poll_worker.nil?
|
36
|
+
@store.stop if !@store.nil?
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_user_membership(user_key)
|
40
|
+
return nil if !@store
|
41
|
+
membership = @cache[user_key]
|
42
|
+
if !membership
|
43
|
+
begin
|
44
|
+
membership = @store.get_membership(BigSegmentStoreManager.hash_for_user_key(user_key))
|
45
|
+
membership = EMPTY_MEMBERSHIP if membership.nil?
|
46
|
+
@cache[user_key] = membership
|
47
|
+
rescue => e
|
48
|
+
LaunchDarkly::Util.log_exception(@logger, "Big Segment store membership query returned error", e)
|
49
|
+
return BigSegmentMembershipResult.new(nil, BigSegmentsStatus::STORE_ERROR)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
poll_store_and_update_status if !@last_status
|
53
|
+
if !@last_status.available
|
54
|
+
return BigSegmentMembershipResult.new(membership, BigSegmentsStatus::STORE_ERROR)
|
55
|
+
end
|
56
|
+
BigSegmentMembershipResult.new(membership, @last_status.stale ? BigSegmentsStatus::STALE : BigSegmentsStatus::HEALTHY)
|
57
|
+
end
|
58
|
+
|
59
|
+
def get_status
|
60
|
+
@last_status || poll_store_and_update_status
|
61
|
+
end
|
62
|
+
|
63
|
+
def poll_store_and_update_status
|
64
|
+
new_status = Interfaces::BigSegmentStoreStatus.new(false, false) # default to "unavailable" if we don't get a new status below
|
65
|
+
if !@store.nil?
|
66
|
+
begin
|
67
|
+
metadata = @store.get_metadata
|
68
|
+
new_status = Interfaces::BigSegmentStoreStatus.new(true, !metadata || is_stale(metadata.last_up_to_date))
|
69
|
+
rescue => e
|
70
|
+
LaunchDarkly::Util.log_exception(@logger, "Big Segment store status query returned error", e)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
@last_status = new_status
|
74
|
+
@status_provider.update_status(new_status)
|
75
|
+
|
76
|
+
new_status
|
77
|
+
end
|
78
|
+
|
79
|
+
def is_stale(timestamp)
|
80
|
+
!timestamp || ((Impl::Util.current_time_millis - timestamp) >= @stale_after_millis)
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.hash_for_user_key(user_key)
|
84
|
+
Digest::SHA256.base64digest(user_key)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
#
|
89
|
+
# Default implementation of the BigSegmentStoreStatusProvider interface.
|
90
|
+
#
|
91
|
+
# There isn't much to this because the real implementation is in BigSegmentStoreManager - we pass in a lambda
|
92
|
+
# that allows us to get the current status from that class. Also, the standard Observer methods such as
|
93
|
+
# add_observer are provided for us because BigSegmentStoreStatusProvider mixes in Observer, so all we need to
|
94
|
+
# to do make notifications happen is to call the Observer methods "changed" and "notify_observers".
|
95
|
+
#
|
96
|
+
class BigSegmentStoreStatusProviderImpl
|
97
|
+
include LaunchDarkly::Interfaces::BigSegmentStoreStatusProvider
|
98
|
+
|
99
|
+
def initialize(status_fn)
|
100
|
+
@status_fn = status_fn
|
101
|
+
@last_status = nil
|
102
|
+
end
|
103
|
+
|
104
|
+
def status
|
105
|
+
@status_fn.call
|
106
|
+
end
|
107
|
+
|
108
|
+
def update_status(new_status)
|
109
|
+
if !@last_status || new_status != @last_status
|
110
|
+
@last_status = new_status
|
111
|
+
changed
|
112
|
+
notify_observers(new_status)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -79,7 +79,7 @@ module LaunchDarkly
|
|
79
79
|
streamingDisabled: !config.stream?,
|
80
80
|
userKeysCapacity: config.user_keys_capacity,
|
81
81
|
userKeysFlushIntervalMillis: self.seconds_to_millis(config.user_keys_flush_interval),
|
82
|
-
usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY'),
|
82
|
+
usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'),
|
83
83
|
usingRelayDaemon: config.use_ldd?,
|
84
84
|
}
|
85
85
|
ret
|
@@ -16,16 +16,28 @@ module LaunchDarkly
|
|
16
16
|
# flag data - or nil if the flag is unknown or deleted
|
17
17
|
# @param get_segment [Function] similar to `get_flag`, but is used to query a user segment.
|
18
18
|
# @param logger [Logger] the client's logger
|
19
|
-
def initialize(get_flag, get_segment, logger)
|
19
|
+
def initialize(get_flag, get_segment, get_big_segments_membership, logger)
|
20
20
|
@get_flag = get_flag
|
21
21
|
@get_segment = get_segment
|
22
|
+
@get_big_segments_membership = get_big_segments_membership
|
22
23
|
@logger = logger
|
23
24
|
end
|
24
25
|
|
25
|
-
# Used internally to hold an evaluation result and
|
26
|
-
#
|
27
|
-
#
|
28
|
-
|
26
|
+
# Used internally to hold an evaluation result and additional state that may be accumulated during an
|
27
|
+
# evaluation. It's simpler and a bit more efficient to represent these as mutable properties rather than
|
28
|
+
# trying to use a pure functional approach, and since we're not exposing this object to any application code
|
29
|
+
# or retaining it anywhere, we don't have to be quite as strict about immutability.
|
30
|
+
#
|
31
|
+
# The big_segments_status and big_segments_membership properties are not used by the caller; they are used
|
32
|
+
# during an evaluation to cache the result of any Big Segments query that we've done for this user, because
|
33
|
+
# we don't want to do multiple queries for the same user if multiple Big Segments are referenced in the same
|
34
|
+
# evaluation.
|
35
|
+
EvalResult = Struct.new(
|
36
|
+
:detail, # the EvaluationDetail representing the evaluation result
|
37
|
+
:events, # an array of evaluation events generated by prerequisites, or nil
|
38
|
+
:big_segments_status,
|
39
|
+
:big_segments_membership
|
40
|
+
)
|
29
41
|
|
30
42
|
# Helper function used internally to construct an EvaluationDetail for an error result.
|
31
43
|
def self.error_result(errorKind, value = nil)
|
@@ -42,30 +54,38 @@ module LaunchDarkly
|
|
42
54
|
# evaluated; the caller is responsible for constructing the feature event for the top-level evaluation
|
43
55
|
# @return [EvalResult] the evaluation result
|
44
56
|
def evaluate(flag, user, event_factory)
|
57
|
+
result = EvalResult.new
|
45
58
|
if user.nil? || user[:key].nil?
|
46
|
-
|
59
|
+
result.detail = Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED)
|
60
|
+
return result
|
47
61
|
end
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
62
|
+
|
63
|
+
detail = eval_internal(flag, user, result, event_factory)
|
64
|
+
if !result.big_segments_status.nil?
|
65
|
+
# If big_segments_status is non-nil at the end of the evaluation, it means a query was done at
|
66
|
+
# some point and we will want to include the status in the evaluation reason.
|
67
|
+
detail = EvaluationDetail.new(detail.value, detail.variation_index,
|
68
|
+
detail.reason.with_big_segments_status(result.big_segments_status))
|
55
69
|
end
|
70
|
+
result.detail = detail
|
71
|
+
return result
|
72
|
+
end
|
56
73
|
|
57
|
-
|
58
|
-
|
74
|
+
def self.make_big_segment_ref(segment) # method is visible for testing
|
75
|
+
# The format of Big Segment references is independent of what store implementation is being
|
76
|
+
# used; the store implementation receives only this string and does not know the details of
|
77
|
+
# the data model. The Relay Proxy will use the same format when writing to the store.
|
78
|
+
"#{segment[:key]}.g#{segment[:generation]}"
|
59
79
|
end
|
60
80
|
|
61
81
|
private
|
62
82
|
|
63
|
-
def eval_internal(flag, user,
|
83
|
+
def eval_internal(flag, user, state, event_factory)
|
64
84
|
if !flag[:on]
|
65
85
|
return get_off_value(flag, EvaluationReason::off)
|
66
86
|
end
|
67
87
|
|
68
|
-
prereq_failure_reason = check_prerequisites(flag, user,
|
88
|
+
prereq_failure_reason = check_prerequisites(flag, user, state, event_factory)
|
69
89
|
if !prereq_failure_reason.nil?
|
70
90
|
return get_off_value(flag, prereq_failure_reason)
|
71
91
|
end
|
@@ -83,7 +103,7 @@ module LaunchDarkly
|
|
83
103
|
rules = flag[:rules] || []
|
84
104
|
rules.each_index do |i|
|
85
105
|
rule = rules[i]
|
86
|
-
if rule_match_user(rule, user)
|
106
|
+
if rule_match_user(rule, user, state)
|
87
107
|
reason = rule[:_reason] # try to use cached reason for this rule
|
88
108
|
reason = EvaluationReason::rule_match(i, rule[:id]) if reason.nil?
|
89
109
|
return get_value_for_variation_or_rollout(flag, rule, user, reason)
|
@@ -98,7 +118,7 @@ module LaunchDarkly
|
|
98
118
|
return EvaluationDetail.new(nil, nil, EvaluationReason::fallthrough)
|
99
119
|
end
|
100
120
|
|
101
|
-
def check_prerequisites(flag, user,
|
121
|
+
def check_prerequisites(flag, user, state, event_factory)
|
102
122
|
(flag[:prerequisites] || []).each do |prerequisite|
|
103
123
|
prereq_ok = true
|
104
124
|
prereq_key = prerequisite[:key]
|
@@ -109,14 +129,15 @@ module LaunchDarkly
|
|
109
129
|
prereq_ok = false
|
110
130
|
else
|
111
131
|
begin
|
112
|
-
prereq_res = eval_internal(prereq_flag, user,
|
132
|
+
prereq_res = eval_internal(prereq_flag, user, state, event_factory)
|
113
133
|
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
|
114
134
|
# off variation was. But we still need to evaluate it in order to generate an event.
|
115
135
|
if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation]
|
116
136
|
prereq_ok = false
|
117
137
|
end
|
118
138
|
event = event_factory.new_eval_event(prereq_flag, user, prereq_res, nil, flag)
|
119
|
-
events.
|
139
|
+
state.events = [] if state.events.nil?
|
140
|
+
state.events.push(event)
|
120
141
|
rescue => exn
|
121
142
|
Util.log_exception(@logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn)
|
122
143
|
prereq_ok = false
|
@@ -130,23 +151,23 @@ module LaunchDarkly
|
|
130
151
|
nil
|
131
152
|
end
|
132
153
|
|
133
|
-
def rule_match_user(rule, user)
|
154
|
+
def rule_match_user(rule, user, state)
|
134
155
|
return false if !rule[:clauses]
|
135
156
|
|
136
157
|
(rule[:clauses] || []).each do |clause|
|
137
|
-
return false if !clause_match_user(clause, user)
|
158
|
+
return false if !clause_match_user(clause, user, state)
|
138
159
|
end
|
139
160
|
|
140
161
|
return true
|
141
162
|
end
|
142
163
|
|
143
|
-
def clause_match_user(clause, user)
|
164
|
+
def clause_match_user(clause, user, state)
|
144
165
|
# In the case of a segment match operator, we check if the user is in any of the segments,
|
145
166
|
# and possibly negate
|
146
167
|
if clause[:op].to_sym == :segmentMatch
|
147
168
|
result = (clause[:values] || []).any? { |v|
|
148
169
|
segment = @get_segment.call(v)
|
149
|
-
!segment.nil? && segment_match_user(segment, user)
|
170
|
+
!segment.nil? && segment_match_user(segment, user, state)
|
150
171
|
}
|
151
172
|
clause[:negate] ? !result : result
|
152
173
|
else
|
@@ -168,11 +189,42 @@ module LaunchDarkly
|
|
168
189
|
clause[:negate] ? !result : result
|
169
190
|
end
|
170
191
|
|
171
|
-
def segment_match_user(segment, user)
|
192
|
+
def segment_match_user(segment, user, state)
|
172
193
|
return false unless user[:key]
|
194
|
+
segment[:unbounded] ? big_segment_match_user(segment, user, state) : simple_segment_match_user(segment, user, true)
|
195
|
+
end
|
173
196
|
|
174
|
-
|
175
|
-
|
197
|
+
def big_segment_match_user(segment, user, state)
|
198
|
+
if !segment[:generation]
|
199
|
+
# Big segment queries can only be done if the generation is known. If it's unset,
|
200
|
+
# that probably means the data store was populated by an older SDK that doesn't know
|
201
|
+
# about the generation property and therefore dropped it from the JSON data. We'll treat
|
202
|
+
# that as a "not configured" condition.
|
203
|
+
state.big_segments_status = BigSegmentsStatus::NOT_CONFIGURED
|
204
|
+
return false
|
205
|
+
end
|
206
|
+
if !state.big_segments_status
|
207
|
+
result = @get_big_segments_membership.nil? ? nil : @get_big_segments_membership.call(user[:key])
|
208
|
+
if result
|
209
|
+
state.big_segments_membership = result.membership
|
210
|
+
state.big_segments_status = result.status
|
211
|
+
else
|
212
|
+
state.big_segments_membership = nil
|
213
|
+
state.big_segments_status = BigSegmentsStatus::NOT_CONFIGURED
|
214
|
+
end
|
215
|
+
end
|
216
|
+
segment_ref = Evaluator.make_big_segment_ref(segment)
|
217
|
+
membership = state.big_segments_membership
|
218
|
+
included = membership.nil? ? nil : membership[segment_ref]
|
219
|
+
return included if !included.nil?
|
220
|
+
simple_segment_match_user(segment, user, false)
|
221
|
+
end
|
222
|
+
|
223
|
+
def simple_segment_match_user(segment, user, use_includes_and_excludes)
|
224
|
+
if use_includes_and_excludes
|
225
|
+
return true if segment[:included].include?(user[:key])
|
226
|
+
return false if segment[:excluded].include?(user[:key])
|
227
|
+
end
|
176
228
|
|
177
229
|
(segment[:rules] || []).each do |r|
|
178
230
|
return true if segment_rule_match_user(r, user, segment[:key], segment[:salt])
|
@@ -4,10 +4,7 @@ module LaunchDarkly
|
|
4
4
|
module Impl
|
5
5
|
module Integrations
|
6
6
|
module DynamoDB
|
7
|
-
|
8
|
-
# Internal implementation of the DynamoDB feature store, intended to be used with CachingStoreWrapper.
|
9
|
-
#
|
10
|
-
class DynamoDBFeatureStoreCore
|
7
|
+
class DynamoDBStoreImplBase
|
11
8
|
begin
|
12
9
|
require "aws-sdk-dynamodb"
|
13
10
|
AWS_SDK_ENABLED = true
|
@@ -19,29 +16,50 @@ module LaunchDarkly
|
|
19
16
|
AWS_SDK_ENABLED = false
|
20
17
|
end
|
21
18
|
end
|
22
|
-
|
19
|
+
|
23
20
|
PARTITION_KEY = "namespace"
|
24
21
|
SORT_KEY = "key"
|
25
22
|
|
26
|
-
VERSION_ATTRIBUTE = "version"
|
27
|
-
ITEM_JSON_ATTRIBUTE = "item"
|
28
|
-
|
29
23
|
def initialize(table_name, opts)
|
30
24
|
if !AWS_SDK_ENABLED
|
31
|
-
raise RuntimeError.new("can't use
|
25
|
+
raise RuntimeError.new("can't use #{description} without the aws-sdk or aws-sdk-dynamodb gem")
|
32
26
|
end
|
33
|
-
|
27
|
+
|
34
28
|
@table_name = table_name
|
35
|
-
@prefix = opts[:prefix]
|
29
|
+
@prefix = opts[:prefix] ? (opts[:prefix] + ":") : ""
|
36
30
|
@logger = opts[:logger] || Config.default_logger
|
37
|
-
|
31
|
+
|
38
32
|
if !opts[:existing_client].nil?
|
39
33
|
@client = opts[:existing_client]
|
40
34
|
else
|
41
35
|
@client = Aws::DynamoDB::Client.new(opts[:dynamodb_opts] || {})
|
42
36
|
end
|
37
|
+
|
38
|
+
@logger.info("${description}: using DynamoDB table \"#{table_name}\"")
|
39
|
+
end
|
40
|
+
|
41
|
+
def stop
|
42
|
+
# AWS client doesn't seem to have a close method
|
43
|
+
end
|
43
44
|
|
44
|
-
|
45
|
+
protected def description
|
46
|
+
"DynamoDB"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
#
|
51
|
+
# Internal implementation of the DynamoDB feature store, intended to be used with CachingStoreWrapper.
|
52
|
+
#
|
53
|
+
class DynamoDBFeatureStoreCore < DynamoDBStoreImplBase
|
54
|
+
VERSION_ATTRIBUTE = "version"
|
55
|
+
ITEM_JSON_ATTRIBUTE = "item"
|
56
|
+
|
57
|
+
def initialize(table_name, opts)
|
58
|
+
super(table_name, opts)
|
59
|
+
end
|
60
|
+
|
61
|
+
def description
|
62
|
+
"DynamoDBFeatureStore"
|
45
63
|
end
|
46
64
|
|
47
65
|
def init_internal(all_data)
|
@@ -124,14 +142,10 @@ module LaunchDarkly
|
|
124
142
|
!resp.item.nil? && resp.item.length > 0
|
125
143
|
end
|
126
144
|
|
127
|
-
def stop
|
128
|
-
# AWS client doesn't seem to have a close method
|
129
|
-
end
|
130
|
-
|
131
145
|
private
|
132
146
|
|
133
147
|
def prefixed_namespace(base_str)
|
134
|
-
|
148
|
+
@prefix + base_str
|
135
149
|
end
|
136
150
|
|
137
151
|
def namespace_for_kind(kind)
|
@@ -208,6 +222,56 @@ module LaunchDarkly
|
|
208
222
|
end
|
209
223
|
end
|
210
224
|
|
225
|
+
class DynamoDBBigSegmentStore < DynamoDBStoreImplBase
|
226
|
+
KEY_METADATA = 'big_segments_metadata';
|
227
|
+
KEY_USER_DATA = 'big_segments_user';
|
228
|
+
ATTR_SYNC_TIME = 'synchronizedOn';
|
229
|
+
ATTR_INCLUDED = 'included';
|
230
|
+
ATTR_EXCLUDED = 'excluded';
|
231
|
+
|
232
|
+
def initialize(table_name, opts)
|
233
|
+
super(table_name, opts)
|
234
|
+
end
|
235
|
+
|
236
|
+
def description
|
237
|
+
"DynamoDBBigSegmentStore"
|
238
|
+
end
|
239
|
+
|
240
|
+
def get_metadata
|
241
|
+
key = @prefix + KEY_METADATA
|
242
|
+
data = @client.get_item(
|
243
|
+
table_name: @table_name,
|
244
|
+
key: {
|
245
|
+
PARTITION_KEY => key,
|
246
|
+
SORT_KEY => key
|
247
|
+
}
|
248
|
+
)
|
249
|
+
timestamp = data.item && data.item[ATTR_SYNC_TIME] ?
|
250
|
+
data.item[ATTR_SYNC_TIME] : nil
|
251
|
+
LaunchDarkly::Interfaces::BigSegmentStoreMetadata.new(timestamp)
|
252
|
+
end
|
253
|
+
|
254
|
+
def get_membership(user_hash)
|
255
|
+
data = @client.get_item(
|
256
|
+
table_name: @table_name,
|
257
|
+
key: {
|
258
|
+
PARTITION_KEY => @prefix + KEY_USER_DATA,
|
259
|
+
SORT_KEY => user_hash
|
260
|
+
})
|
261
|
+
return nil if !data.item
|
262
|
+
excluded_refs = data.item[ATTR_EXCLUDED] || []
|
263
|
+
included_refs = data.item[ATTR_INCLUDED] || []
|
264
|
+
if excluded_refs.empty? && included_refs.empty?
|
265
|
+
nil
|
266
|
+
else
|
267
|
+
membership = {}
|
268
|
+
excluded_refs.each { |ref| membership[ref] = false }
|
269
|
+
included_refs.each { |ref| membership[ref] = true }
|
270
|
+
membership
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
211
275
|
class DynamoDBUtil
|
212
276
|
#
|
213
277
|
# Calls client.batch_write_item as many times as necessary to submit all of the given requests.
|
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'ldclient-rb/in_memory_store'
|
2
|
+
require 'ldclient-rb/util'
|
3
|
+
|
4
|
+
require 'concurrent/atomics'
|
5
|
+
require 'json'
|
6
|
+
require 'yaml'
|
7
|
+
require 'pathname'
|
8
|
+
|
9
|
+
module LaunchDarkly
|
10
|
+
module Impl
|
11
|
+
module Integrations
|
12
|
+
class FileDataSourceImpl
|
13
|
+
# To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
|
14
|
+
# file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
|
15
|
+
# gem has been provided by the host app.
|
16
|
+
@@have_listen = false
|
17
|
+
begin
|
18
|
+
require 'listen'
|
19
|
+
@@have_listen = true
|
20
|
+
rescue LoadError
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(feature_store, logger, options={})
|
24
|
+
@feature_store = feature_store
|
25
|
+
@logger = logger
|
26
|
+
@paths = options[:paths] || []
|
27
|
+
if @paths.is_a? String
|
28
|
+
@paths = [ @paths ]
|
29
|
+
end
|
30
|
+
@auto_update = options[:auto_update]
|
31
|
+
if @auto_update && @@have_listen && !options[:force_polling] # force_polling is used only for tests
|
32
|
+
# We have seen unreliable behavior in the 'listen' gem in JRuby 9.1 (https://github.com/guard/listen/issues/449).
|
33
|
+
# Therefore, on that platform we'll fall back to file polling instead.
|
34
|
+
if defined?(JRUBY_VERSION) && JRUBY_VERSION.start_with?("9.1.")
|
35
|
+
@use_listen = false
|
36
|
+
else
|
37
|
+
@use_listen = true
|
38
|
+
end
|
39
|
+
end
|
40
|
+
@poll_interval = options[:poll_interval] || 1
|
41
|
+
@initialized = Concurrent::AtomicBoolean.new(false)
|
42
|
+
@ready = Concurrent::Event.new
|
43
|
+
end
|
44
|
+
|
45
|
+
def initialized?
|
46
|
+
@initialized.value
|
47
|
+
end
|
48
|
+
|
49
|
+
def start
|
50
|
+
ready = Concurrent::Event.new
|
51
|
+
|
52
|
+
# We will return immediately regardless of whether the file load succeeded or failed -
|
53
|
+
# the difference can be detected by checking "initialized?"
|
54
|
+
ready.set
|
55
|
+
|
56
|
+
load_all
|
57
|
+
|
58
|
+
if @auto_update
|
59
|
+
# If we're going to watch files, then the start event will be set the first time we get
|
60
|
+
# a successful load.
|
61
|
+
@listener = start_listener
|
62
|
+
end
|
63
|
+
|
64
|
+
ready
|
65
|
+
end
|
66
|
+
|
67
|
+
def stop
|
68
|
+
@listener.stop if !@listener.nil?
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def load_all
|
74
|
+
all_data = {
|
75
|
+
FEATURES => {},
|
76
|
+
SEGMENTS => {}
|
77
|
+
}
|
78
|
+
@paths.each do |path|
|
79
|
+
begin
|
80
|
+
load_file(path, all_data)
|
81
|
+
rescue => exn
|
82
|
+
LaunchDarkly::Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", exn)
|
83
|
+
return
|
84
|
+
end
|
85
|
+
end
|
86
|
+
@feature_store.init(all_data)
|
87
|
+
@initialized.make_true
|
88
|
+
end
|
89
|
+
|
90
|
+
def load_file(path, all_data)
|
91
|
+
parsed = parse_content(IO.read(path))
|
92
|
+
(parsed[:flags] || {}).each do |key, flag|
|
93
|
+
add_item(all_data, FEATURES, flag)
|
94
|
+
end
|
95
|
+
(parsed[:flagValues] || {}).each do |key, value|
|
96
|
+
add_item(all_data, FEATURES, make_flag_with_value(key.to_s, value))
|
97
|
+
end
|
98
|
+
(parsed[:segments] || {}).each do |key, segment|
|
99
|
+
add_item(all_data, SEGMENTS, segment)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse_content(content)
|
104
|
+
# We can use the Ruby YAML parser for both YAML and JSON (JSON is a subset of YAML and while
|
105
|
+
# not all YAML parsers handle it correctly, we have verified that the Ruby one does, at least
|
106
|
+
# for all the samples of actual flag data that we've tested).
|
107
|
+
symbolize_all_keys(YAML.safe_load(content))
|
108
|
+
end
|
109
|
+
|
110
|
+
def symbolize_all_keys(value)
|
111
|
+
# This is necessary because YAML.load doesn't have an option for parsing keys as symbols, and
|
112
|
+
# the SDK expects all objects to be formatted that way.
|
113
|
+
if value.is_a?(Hash)
|
114
|
+
value.map{ |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h
|
115
|
+
elsif value.is_a?(Array)
|
116
|
+
value.map{ |v| symbolize_all_keys(v) }
|
117
|
+
else
|
118
|
+
value
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def add_item(all_data, kind, item)
|
123
|
+
items = all_data[kind]
|
124
|
+
raise ArgumentError, "Received unknown item kind #{kind} in add_data" if items.nil? # shouldn't be possible since we preinitialize the hash
|
125
|
+
key = item[:key].to_sym
|
126
|
+
if !items[key].nil?
|
127
|
+
raise ArgumentError, "#{kind[:namespace]} key \"#{item[:key]}\" was used more than once"
|
128
|
+
end
|
129
|
+
items[key] = item
|
130
|
+
end
|
131
|
+
|
132
|
+
def make_flag_with_value(key, value)
|
133
|
+
{
|
134
|
+
key: key,
|
135
|
+
on: true,
|
136
|
+
fallthrough: { variation: 0 },
|
137
|
+
variations: [ value ]
|
138
|
+
}
|
139
|
+
end
|
140
|
+
|
141
|
+
def start_listener
|
142
|
+
resolved_paths = @paths.map { |p| Pathname.new(File.absolute_path(p)).realpath.to_s }
|
143
|
+
if @use_listen
|
144
|
+
start_listener_with_listen_gem(resolved_paths)
|
145
|
+
else
|
146
|
+
FileDataSourcePoller.new(resolved_paths, @poll_interval, self.method(:load_all), @logger)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def start_listener_with_listen_gem(resolved_paths)
|
151
|
+
path_set = resolved_paths.to_set
|
152
|
+
dir_paths = resolved_paths.map{ |p| File.dirname(p) }.uniq
|
153
|
+
opts = { latency: @poll_interval }
|
154
|
+
l = Listen.to(*dir_paths, opts) do |modified, added, removed|
|
155
|
+
paths = modified + added + removed
|
156
|
+
if paths.any? { |p| path_set.include?(p) }
|
157
|
+
load_all
|
158
|
+
end
|
159
|
+
end
|
160
|
+
l.start
|
161
|
+
l
|
162
|
+
end
|
163
|
+
|
164
|
+
#
|
165
|
+
# Used internally by FileDataSource to track data file changes if the 'listen' gem is not available.
|
166
|
+
#
|
167
|
+
class FileDataSourcePoller
|
168
|
+
def initialize(resolved_paths, interval, reloader, logger)
|
169
|
+
@stopped = Concurrent::AtomicBoolean.new(false)
|
170
|
+
get_file_times = Proc.new do
|
171
|
+
ret = {}
|
172
|
+
resolved_paths.each do |path|
|
173
|
+
begin
|
174
|
+
ret[path] = File.mtime(path)
|
175
|
+
rescue Errno::ENOENT
|
176
|
+
ret[path] = nil
|
177
|
+
end
|
178
|
+
end
|
179
|
+
ret
|
180
|
+
end
|
181
|
+
last_times = get_file_times.call
|
182
|
+
@thread = Thread.new do
|
183
|
+
while true
|
184
|
+
sleep interval
|
185
|
+
break if @stopped.value
|
186
|
+
begin
|
187
|
+
new_times = get_file_times.call
|
188
|
+
changed = false
|
189
|
+
last_times.each do |path, old_time|
|
190
|
+
new_time = new_times[path]
|
191
|
+
if !new_time.nil? && new_time != old_time
|
192
|
+
changed = true
|
193
|
+
break
|
194
|
+
end
|
195
|
+
end
|
196
|
+
reloader.call if changed
|
197
|
+
rescue => exn
|
198
|
+
LaunchDarkly::Util.log_exception(logger, "Unexpected exception in FileDataSourcePoller", exn)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def stop
|
205
|
+
@stopped.make_true
|
206
|
+
@thread.run # wakes it up if it's sleeping
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|