diff --git a/circle.yml b/circle.yml index 7d82cb78..87a582d4 100644 --- a/circle.yml +++ b/circle.yml @@ -1,21 +1,23 @@ machine: environment: - RUBIES: "ruby-2.4.1;ruby-2.2.3;ruby-2.1.7;ruby-2.0.0;ruby-1.9.3;jruby-1.7.22" + RUBIES: "ruby-2.4.2 ruby-2.2.7 ruby-2.1.9 ruby-2.0.0 ruby-1.9.3 jruby-1.7.22 jruby-9.0.5.0 jruby-9.1.13.0" services: - redis dependencies: cache_directories: - - '../.rvm/rubies' + - '/opt/circleci/.rvm/rubies' override: - - > - rubiesArray=(${RUBIES//;/ }); - for i in "${rubiesArray[@]}"; + - | + set -e + for i in $RUBIES; do rvm install $i; rvm use $i; - gem install jruby-openssl; # required by bundler, no effect on Ruby MRI + if [[ $i == jruby* ]]; then + gem install jruby-openssl; # required by bundler, no effect on Ruby MRI + fi gem install bundler; bundle install; mv Gemfile.lock "Gemfile.lock.$i" @@ -23,9 +25,9 @@ dependencies: test: override: - - > - rubiesArray=(${RUBIES//;/ }); - for i in "${rubiesArray[@]}"; + - | + set -e + for i in $RUBIES; do rvm use $i; cp "Gemfile.lock.$i" Gemfile.lock; diff --git a/ldclient-rb.gemspec b/ldclient-rb.gemspec index 1a89c256..940d4fe3 100644 --- a/ldclient-rb.gemspec +++ b/ldclient-rb.gemspec @@ -30,8 +30,13 @@ Gem::Specification.new do |spec| spec.add_development_dependency "moneta", "~> 1.0.0" spec.add_runtime_dependency "json", [">= 1.8", "< 3"] - spec.add_runtime_dependency "faraday", [">= 0.9", "< 2"] - spec.add_runtime_dependency "faraday-http-cache", [">= 1.3.0", "< 3"] + if RUBY_VERSION >= "2.1.0" + spec.add_runtime_dependency "faraday", [">= 0.9", "< 2"] + spec.add_runtime_dependency "faraday-http-cache", [">= 1.3.0", "< 3"] + else + spec.add_runtime_dependency "faraday", [">= 0.9", "< 0.14.0"] + spec.add_runtime_dependency "faraday-http-cache", [">= 1.3.0", "< 2"] + end spec.add_runtime_dependency "semantic", "~> 1.6.0" spec.add_runtime_dependency "thread_safe", "~> 0.3" spec.add_runtime_dependency "net-http-persistent", "~> 2.9" diff --git a/lib/ldclient-rb.rb b/lib/ldclient-rb.rb index ed28621f..ce943d13 100644 --- a/lib/ldclient-rb.rb +++ b/lib/ldclient-rb.rb @@ -3,12 +3,12 @@ require "ldclient-rb/ldclient" require "ldclient-rb/cache_store" require "ldclient-rb/memoized_value" +require "ldclient-rb/in_memory_store" require "ldclient-rb/config" require "ldclient-rb/newrelic" require "ldclient-rb/stream" require "ldclient-rb/polling" require "ldclient-rb/event_serializer" require "ldclient-rb/events" -require "ldclient-rb/feature_store" -require "ldclient-rb/redis_feature_store" +require "ldclient-rb/redis_store" require "ldclient-rb/requestor" diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index adb50ff8..5a6e7c26 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -34,6 +34,8 @@ class Config # @option opts [Object] :cache_store A cache store for the Faraday HTTP caching # library. Defaults to the Rails cache in a Rails environment, or a # thread-safe in-memory store otherwise. + # @option opts [Object] :feature_store A store for feature flags and related data. Defaults to an in-memory + # cache, or you can use RedisFeatureStore. # @option opts [Boolean] :use_ldd (false) Whether you are using the LaunchDarkly relay proxy in # daemon mode. In this configuration, the client will not use a streaming connection to listen # for updates, but instead will get feature state from a Redis instance. The `stream` and @@ -171,7 +173,6 @@ def offline? # attr_reader :feature_store - # The proxy configuration string # attr_reader :proxy diff --git a/lib/ldclient-rb/evaluation.rb b/lib/ldclient-rb/evaluation.rb index 9b4e1871..5ff32dc4 100644 --- a/lib/ldclient-rb/evaluation.rb +++ b/lib/ldclient-rb/evaluation.rb @@ -22,16 +22,18 @@ module Evaluation end SEMVER_OPERAND = lambda do |v| + semver = nil if v.is_a? String for _ in 0..2 do begin - return Semantic::Version.new(v) + semver = Semantic::Version.new(v) + break # Some versions of jruby cannot properly handle a return here and return from the method that calls this lambda rescue ArgumentError v = addZeroVersionComponent(v) end end end - nil + semver end def self.addZeroVersionComponent(v) @@ -98,7 +100,11 @@ def self.comparator(converter) semVerLessThan: comparator(SEMVER_OPERAND) { |n| n < 0 }, semVerGreaterThan: - comparator(SEMVER_OPERAND) { |n| n > 0 } + comparator(SEMVER_OPERAND) { |n| n > 0 }, + segmentMatch: + lambda do |a, b| + false # we should never reach this - instead we special-case this operator in clause_match_user + end } class EvaluationError < StandardError @@ -136,54 +142,46 @@ def evaluate(flag, user, store) def eval_internal(flag, user, store, events) failed_prereq = false # Evaluate prerequisites, if any - if !flag[:prerequisites].nil? - flag[:prerequisites].each do |prerequisite| - prereq_flag = store.get(prerequisite[:key]) + (flag[:prerequisites] || []).each do |prerequisite| + prereq_flag = store.get(FEATURES, prerequisite[:key]) - if prereq_flag.nil? || !prereq_flag[:on] - failed_prereq = true - else - begin - prereq_res = eval_internal(prereq_flag, user, store, events) - variation = get_variation(prereq_flag, prerequisite[:variation]) - events.push(kind: "feature", key: prereq_flag[:key], value: prereq_res, version: prereq_flag[:version], prereqOf: flag[:key]) - if prereq_res.nil? || prereq_res != variation - failed_prereq = true - end - rescue => exn - @config.logger.error("[LDClient] Error evaluating prerequisite: #{exn.inspect}") + if prereq_flag.nil? || !prereq_flag[:on] + failed_prereq = true + else + begin + prereq_res = eval_internal(prereq_flag, user, store, events) + variation = get_variation(prereq_flag, prerequisite[:variation]) + events.push(kind: "feature", key: prereq_flag[:key], value: prereq_res, version: prereq_flag[:version], prereqOf: flag[:key]) + if prereq_res.nil? || prereq_res != variation failed_prereq = true end + rescue => exn + @config.logger.error("[LDClient] Error evaluating prerequisite: #{exn.inspect}") + failed_prereq = true end end + end - if failed_prereq - return nil - end + if failed_prereq + return nil end # The prerequisites were satisfied. # Now walk through the evaluation steps and get the correct # variation index - eval_rules(flag, user) + eval_rules(flag, user, store) end - def eval_rules(flag, user) + def eval_rules(flag, user, store) # Check user target matches - if !flag[:targets].nil? - flag[:targets].each do |target| - if !target[:values].nil? - target[:values].each do |value| - return get_variation(flag, target[:variation]) if value == user[:key] - end - end + (flag[:targets] || []).each do |target| + (target[:values] || []).each do |value| + return get_variation(flag, target[:variation]) if value == user[:key] end end - + # Check custom rules - if !flag[:rules].nil? - flag[:rules].each do |rule| - return variation_for_user(rule, user, flag) if rule_match_user(rule, user) - end + (flag[:rules] || []).each do |rule| + return variation_for_user(rule, user, flag) if rule_match_user(rule, user, store) end # Check the fallthrough rule @@ -202,17 +200,30 @@ def get_variation(flag, index) flag[:variations][index] end - def rule_match_user(rule, user) + def rule_match_user(rule, user, store) return false if !rule[:clauses] - rule[:clauses].each do |clause| - return false if !clause_match_user(clause, user) + (rule[:clauses] || []).each do |clause| + return false if !clause_match_user(clause, user, store) end return true end - def clause_match_user(clause, user) + def clause_match_user(clause, user, store) + # In the case of a segment match operator, we check if the user is in any of the segments, + # and possibly negate + if clause[:op].to_sym == :segmentMatch + (clause[:values] || []).each do |v| + segment = store.get(SEGMENTS, v) + return maybe_negate(clause, true) if !segment.nil? && segment_match_user(segment, user) + end + return maybe_negate(clause, false) + end + clause_match_user_no_segments(clause, user) + end + + def clause_match_user_no_segments(clause, user) val = user_value(user, clause[:attribute]) return false if val.nil? @@ -250,6 +261,33 @@ def variation_for_user(rule, user, flag) end end + def segment_match_user(segment, user) + return false unless user[:key] + + return true if segment[:included].include?(user[:key]) + return false if segment[:excluded].include?(user[:key]) + + (segment[:rules] || []).each do |r| + return true if segment_rule_match_user(r, user, segment[:key], segment[:salt]) + end + + return false + end + + def segment_rule_match_user(rule, user, segment_key, salt) + (rule[:clauses] || []).each do |c| + return false unless clause_match_user_no_segments(c, user) + end + + # If the weight is absent, this rule matches + return true if !rule[:weight] + + # All of the clauses are met. See if the user buckets in + bucket = bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt) + weight = rule[:weight].to_f / 100000.0 + return bucket < weight + end + def bucket_user(user, key, bucket_by, salt) return nil unless user[:key] diff --git a/lib/ldclient-rb/feature_store.rb b/lib/ldclient-rb/feature_store.rb deleted file mode 100644 index f1a17446..00000000 --- a/lib/ldclient-rb/feature_store.rb +++ /dev/null @@ -1,63 +0,0 @@ -require "concurrent/atomics" - -module LaunchDarkly - class InMemoryFeatureStore - def initialize - @features = Hash.new - @lock = Concurrent::ReadWriteLock.new - @initialized = Concurrent::AtomicBoolean.new(false) - end - - def get(key) - @lock.with_read_lock do - f = @features[key.to_sym] - (f.nil? || f[:deleted]) ? nil : f - end - end - - def all - @lock.with_read_lock do - @features.select { |_k, f| not f[:deleted] } - end - end - - def delete(key, version) - @lock.with_write_lock do - old = @features[key.to_sym] - - if !old.nil? && old[:version] < version - old[:deleted] = true - old[:version] = version - @features[key.to_sym] = old - elsif old.nil? - @features[key.to_sym] = { deleted: true, version: version } - end - end - end - - def init(fs) - @lock.with_write_lock do - @features.replace(fs) - @initialized.make_true - end - end - - def upsert(key, feature) - @lock.with_write_lock do - old = @features[key.to_sym] - - if old.nil? || old[:version] < feature[:version] - @features[key.to_sym] = feature - end - end - end - - def initialized? - @initialized.value - end - - def stop - # nothing to do - end - end -end diff --git a/lib/ldclient-rb/in_memory_store.rb b/lib/ldclient-rb/in_memory_store.rb new file mode 100644 index 00000000..e3e85879 --- /dev/null +++ b/lib/ldclient-rb/in_memory_store.rb @@ -0,0 +1,89 @@ +require "concurrent/atomics" + +module LaunchDarkly + + # These constants denote the types of data that can be stored in the feature store. If + # we add another storable data type in the future, as long as it follows the same pattern + # (having "key", "version", and "deleted" properties), we only need to add a corresponding + # constant here and the existing store should be able to handle it. + FEATURES = { + namespace: "features" + }.freeze + + SEGMENTS = { + namespace: "segments" + }.freeze + + # + # Default implementation of the LaunchDarkly client's feature store, using an in-memory + # cache. This object holds feature flags and related data received from the + # streaming API. + # + class InMemoryFeatureStore + def initialize + @items = Hash.new + @lock = Concurrent::ReadWriteLock.new + @initialized = Concurrent::AtomicBoolean.new(false) + end + + def get(kind, key) + @lock.with_read_lock do + coll = @items[kind] + f = coll.nil? ? nil : coll[key.to_sym] + (f.nil? || f[:deleted]) ? nil : f + end + end + + def all(kind) + @lock.with_read_lock do + coll = @items[kind] + (coll.nil? ? Hash.new : coll).select { |_k, f| not f[:deleted] } + end + end + + def delete(kind, key, version) + @lock.with_write_lock do + coll = @items[kind] + if coll.nil? + coll = Hash.new + @items[kind] = coll + end + old = coll[key.to_sym] + + if old.nil? || old[:version] < version + coll[key.to_sym] = { deleted: true, version: version } + end + end + end + + def init(all_data) + @lock.with_write_lock do + @items.replace(all_data) + @initialized.make_true + end + end + + def upsert(kind, item) + @lock.with_write_lock do + coll = @items[kind] + if coll.nil? + coll = Hash.new + @items[kind] = coll + end + old = coll[item[:key].to_sym] + + if old.nil? || old[:version] < item[:version] + coll[item[:key].to_sym] = item + end + end + end + + def initialized? + @initialized.value + end + + def stop + # nothing to do + end + end +end diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index 70239a26..973b043c 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -130,7 +130,7 @@ def variation(key, user, default) end sanitize_user(user) - feature = @store.get(key) + feature = @store.get(FEATURES, key) if feature.nil? @config.logger.info("[LDClient] Unknown feature flag #{key}. Returning default value") @@ -197,7 +197,7 @@ def all_flags(user) end begin - features = @store.all + features = @store.all(FEATURES) # TODO rescue if necessary Hash[features.map{ |k, f| [k, evaluate(f, user, @store)[:value]] }] diff --git a/lib/ldclient-rb/polling.rb b/lib/ldclient-rb/polling.rb index 060e72bf..00cf5e8e 100644 --- a/lib/ldclient-rb/polling.rb +++ b/lib/ldclient-rb/polling.rb @@ -31,9 +31,12 @@ def stop end def poll - flags = @requestor.request_all_flags - if flags - @config.feature_store.init(flags) + all_data = @requestor.request_all_data + if all_data + @config.feature_store.init({ + FEATURES => all_data[:flags], + SEGMENTS => all_data[:segments] + }) if @initialized.make_true @config.logger.info("[LDClient] Polling connection initialized") end diff --git a/lib/ldclient-rb/redis_feature_store.rb b/lib/ldclient-rb/redis_store.rb similarity index 67% rename from lib/ldclient-rb/redis_feature_store.rb rename to lib/ldclient-rb/redis_store.rb index 6d1eae06..2374eb23 100644 --- a/lib/ldclient-rb/redis_feature_store.rb +++ b/lib/ldclient-rb/redis_store.rb @@ -5,7 +5,8 @@ module LaunchDarkly # # An implementation of the LaunchDarkly client's feature store that uses a Redis - # instance. Feature data can also be further cached in memory to reduce overhead + # instance. This object holds feature flags and related data received from the + # streaming API. Feature data can also be further cached in memory to reduce overhead # of calls to Redis. # # To use this class, you must first have the `redis`, `connection-pool`, and `moneta` @@ -32,7 +33,7 @@ class RedisFeatureStore # @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger` # @option opts [Integer] :max_connections size of the Redis connection pool # @option opts [Integer] :expiration expiration time for the in-memory cache, in seconds; 0 for no local caching - # @option opts [Integer] :capacity maximum number of feature flags to cache locally + # @option opts [Integer] :capacity maximum number of feature flags (or related objects) to cache locally # @option opts [Object] :pool custom connection pool, used for testing only # def initialize(opts = {}) @@ -52,7 +53,6 @@ def initialize(opts = {}) end @prefix = opts[:prefix] || RedisFeatureStore.default_prefix @logger = opts[:logger] || Config.default_logger - @features_key = @prefix + ':features' @expiration_seconds = opts[:expiration] || 15 @capacity = opts[:capacity] || 1000 @@ -91,44 +91,44 @@ def self.default_prefix 'launchdarkly' end - def get(key) - f = @cache[key.to_sym] + def get(kind, key) + f = @cache[cache_key(kind, key)] if f.nil? - @logger.debug("RedisFeatureStore: no cache hit for #{key}, requesting from Redis") + @logger.debug("RedisFeatureStore: no cache hit for #{key} in '#{kind[:namespace]}', requesting from Redis") f = with_connection do |redis| begin - get_redis(redis,key.to_sym) + get_redis(kind, redis, key.to_sym) rescue => e - @logger.error("RedisFeatureStore: could not retrieve feature #{key} from Redis, with error: #{e}") + @logger.error("RedisFeatureStore: could not retrieve #{key} from Redis in '#{kind[:namespace]}', with error: #{e}") nil end end if !f.nil? - put_cache(key.to_sym, f) + put_cache(kind, key, f) end end if f.nil? - @logger.debug("RedisFeatureStore: feature #{key} not found") + @logger.debug("RedisFeatureStore: #{key} not found in '#{kind[:namespace]}'") nil elsif f[:deleted] - @logger.debug("RedisFeatureStore: feature #{key} was deleted, returning nil") + @logger.debug("RedisFeatureStore: #{key} was deleted in '#{kind[:namespace]}', returning nil") nil else f end end - def all + def all(kind) fs = {} with_connection do |redis| begin - hashfs = redis.hgetall(@features_key) + hashfs = redis.hgetall(items_key(kind)) rescue => e - @logger.error("RedisFeatureStore: could not retrieve all flags from Redis with error: #{e}; returning none") + @logger.error("RedisFeatureStore: could not retrieve all '#{kind[:namespace]}' items from Redis with error: #{e}; returning none") hashfs = {} end - hashfs.each do |k, jsonFeature| - f = JSON.parse(jsonFeature, symbolize_names: true) + hashfs.each do |k, jsonItem| + f = JSON.parse(jsonItem, symbolize_names: true) if !f[:deleted] fs[k.to_sym] = f end @@ -137,43 +137,47 @@ def all fs end - def delete(key, version) + def delete(kind, key, version) with_connection do |redis| - f = get_redis(redis, key) + f = get_redis(kind, redis, key) if f.nil? - put_redis_and_cache(redis, key, { deleted: true, version: version }) + put_redis_and_cache(kind, redis, key, { deleted: true, version: version }) else if f[:version] < version f1 = f.clone f1[:deleted] = true f1[:version] = version - put_redis_and_cache(redis, key, f1) + put_redis_and_cache(kind, redis, key, f1) else - @logger.warn("RedisFeatureStore: attempted to delete flag: #{key} version: #{f[:version]} \ - with a version that is the same or older: #{version}") + @logger.warn("RedisFeatureStore: attempted to delete #{key} version: #{f[:version]} \ + in '#{kind[:namespace]}' with a version that is the same or older: #{version}") end end end end - def init(fs) + def init(all_data) @cache.clear + count = 0 with_connection do |redis| - redis.multi do |multi| - multi.del(@features_key) - fs.each { |k, f| put_redis_and_cache(multi, k, f) } + all_data.each do |kind, items| + redis.multi do |multi| + multi.del(items_key(kind)) + count = count + items.count + items.each { |k, v| put_redis_and_cache(kind, multi, k, v) } + end end end @inited.set(true) - @logger.info("RedisFeatureStore: initialized with #{fs.count} feature flags") + @logger.info("RedisFeatureStore: initialized with #{count} items") end - def upsert(key, feature) + def upsert(kind, item) with_connection do |redis| - redis.watch(@features_key) do - old = get_redis(redis, key) - if old.nil? || (old[:version] < feature[:version]) - put_redis_and_cache(redis, key, feature) + redis.watch(items_key(kind)) do + old = get_redis(kind, redis, item[:key]) + if old.nil? || (old[:version] < item[:version]) + put_redis_and_cache(kind, redis, item[:key], item) end redis.unwatch end @@ -198,35 +202,43 @@ def clear_local_cache() private + def items_key(kind) + @prefix + ":" + kind[:namespace] + end + + def cache_key(kind, key) + kind[:namespace] + ":" + key.to_s + end + def with_connection @pool.with { |redis| yield(redis) } end - def get_redis(redis, key) + def get_redis(kind, redis, key) begin - json_feature = redis.hget(@features_key, key) - JSON.parse(json_feature, symbolize_names: true) if json_feature + json_item = redis.hget(items_key(kind), key) + JSON.parse(json_item, symbolize_names: true) if json_item rescue => e - @logger.error("RedisFeatureStore: could not retrieve feature #{key} from Redis, error: #{e}") + @logger.error("RedisFeatureStore: could not retrieve #{key} from Redis, error: #{e}") nil end end - def put_cache(key, value) - @cache.store(key, value, expires: @expiration_seconds) + def put_cache(kind, key, value) + @cache.store(cache_key(kind, key), value, expires: @expiration_seconds) end - def put_redis_and_cache(redis, key, feature) + def put_redis_and_cache(kind, redis, key, item) begin - redis.hset(@features_key, key, feature.to_json) + redis.hset(items_key(kind), key, item.to_json) rescue => e @logger.error("RedisFeatureStore: could not store #{key} in Redis, error: #{e}") end - put_cache(key.to_sym, feature) + put_cache(kind, key.to_sym, item) end def query_inited - with_connection { |redis| redis.exists(@features_key) } + with_connection { |redis| redis.exists(items_key(FEATURES)) } end end end diff --git a/lib/ldclient-rb/requestor.rb b/lib/ldclient-rb/requestor.rb index ecd23a54..40928806 100644 --- a/lib/ldclient-rb/requestor.rb +++ b/lib/ldclient-rb/requestor.rb @@ -26,6 +26,18 @@ def request_flag(key) make_request("/sdk/latest-flags/" + key) end + def request_all_segments() + make_request("/sdk/latest-segments") + end + + def request_segment(key) + make_request("/sdk/latest-segments/" + key) + end + + def request_all_data() + make_request("/sdk/latest-all") + end + def make_request(path) uri = @config.base_uri + path res = @client.get (uri) do |req| diff --git a/lib/ldclient-rb/stream.rb b/lib/ldclient-rb/stream.rb index 0d5766bf..671a80e5 100644 --- a/lib/ldclient-rb/stream.rb +++ b/lib/ldclient-rb/stream.rb @@ -10,11 +10,16 @@ module LaunchDarkly INDIRECT_PATCH = :'indirect/patch' READ_TIMEOUT_SECONDS = 300 # 5 minutes; the stream should send a ping every 3 minutes + KEY_PATHS = { + FEATURES => "/flags/", + SEGMENTS => "/segments/" + } + class StreamProcessor def initialize(sdk_key, config, requestor) @sdk_key = sdk_key @config = config - @store = config.feature_store + @feature_store = config.feature_store @requestor = requestor @initialized = Concurrent::AtomicBoolean.new(false) @started = Concurrent::AtomicBoolean.new(false) @@ -36,7 +41,7 @@ def start 'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION } opts = {:headers => headers, :with_credentials => true, :proxy => @config.proxy, :read_timeout => READ_TIMEOUT_SECONDS} - @es = Celluloid::EventSource.new(@config.stream_uri + "/flags", opts) do |conn| + @es = Celluloid::EventSource.new(@config.stream_uri + "/all", opts) do |conn| conn.on(PUT) { |message| process_message(message, PUT) } conn.on(PATCH) { |message| process_message(message, PATCH) } conn.on(DELETE) { |message| process_message(message, DELETE) } @@ -66,30 +71,61 @@ def stop end end + private + def process_message(message, method) @config.logger.debug("[LDClient] Stream received #{method} message: #{message.data}") if method == PUT message = JSON.parse(message.data, symbolize_names: true) - @store.init(message) + @feature_store.init({ + FEATURES => message[:data][:flags], + SEGMENTS => message[:data][:segments] + }) @initialized.make_true @config.logger.info("[LDClient] Stream initialized") elsif method == PATCH message = JSON.parse(message.data, symbolize_names: true) - @store.upsert(message[:path][1..-1], message[:data]) + for kind in [FEATURES, SEGMENTS] + key = key_for_path(kind, message[:path]) + if key + @feature_store.upsert(kind, message[:data]) + break + end + end elsif method == DELETE message = JSON.parse(message.data, symbolize_names: true) - @store.delete(message[:path][1..-1], message[:version]) + for kind in [FEATURES, SEGMENTS] + key = key_for_path(kind, message[:path]) + if key + @feature_store.delete(kind, key, message[:version]) + break + end + end elsif method == INDIRECT_PUT - @store.init(@requestor.request_all_flags) + all_data = @requestor.request_all_data + @feature_store.init({ + FEATURES => all_data[:flags], + SEGMENTS => all_data[:segments] + }) @initialized.make_true @config.logger.info("[LDClient] Stream initialized (via indirect message)") elsif method == INDIRECT_PATCH - @store.upsert(message.data, @requestor.request_flag(message.data)) + key = feature_key_for_path(message.data) + if key + @feature_store.upsert(FEATURES, @requestor.request_flag(key)) + else + key = segment_key_for_path(message.data) + if key + @feature_store.upsert(SEGMENTS, key, @requestor.request_segment(key)) + end + end else @config.logger.warn("[LDClient] Unknown message received: #{method}") end end - private :process_message + def key_for_path(kind, path) + path.start_with?(KEY_PATHS[kind]) ? path[KEY_PATHS[kind].length..-1] : nil + end end end diff --git a/spec/evaluation_spec.rb b/spec/evaluation_spec.rb index 092136fa..b8f4ea59 100644 --- a/spec/evaluation_spec.rb +++ b/spec/evaluation_spec.rb @@ -4,6 +4,14 @@ subject { LaunchDarkly::Evaluation } let(:features) { LaunchDarkly::InMemoryFeatureStore.new } + let(:user) { + { + key: "userkey", + email: "test@example.com", + name: "Bob" + } + } + include LaunchDarkly::Evaluation describe "evaluate" do @@ -60,7 +68,7 @@ variations: ['d', 'e'], version: 2 } - features.upsert('feature1', flag1) + features.upsert(LaunchDarkly::FEATURES, flag1) user = { key: 'x' } events_should_be = [{kind: 'feature', key: 'feature1', value: 'd', version: 2, prereqOf: 'feature0'}] expect(evaluate(flag, user, features)).to eq({value: 'b', events: events_should_be}) @@ -83,7 +91,7 @@ variations: ['d', 'e'], version: 2 } - features.upsert('feature1', flag1) + features.upsert(LaunchDarkly::FEATURES, flag1) user = { key: 'x' } events_should_be = [{kind: 'feature', key: 'feature1', value: 'e', version: 2, prereqOf: 'feature0'}] expect(evaluate(flag, user, features)).to eq({value: 'a', events: events_should_be}) @@ -133,19 +141,47 @@ it "can match built-in attribute" do user = { key: 'x', name: 'Bob' } clause = { attribute: 'name', op: 'in', values: ['Bob'] } - expect(clause_match_user(clause, user)).to be true + expect(clause_match_user(clause, user, features)).to be true end it "can match custom attribute" do user = { key: 'x', name: 'Bob', custom: { legs: 4 } } clause = { attribute: 'legs', op: 'in', values: [4] } - expect(clause_match_user(clause, user)).to be true + expect(clause_match_user(clause, user, features)).to be true end it "returns false for missing attribute" do user = { key: 'x', name: 'Bob' } clause = { attribute: 'legs', op: 'in', values: [4] } - expect(clause_match_user(clause, user)).to be false + expect(clause_match_user(clause, user, features)).to be false + end + + it "can be negated" do + user = { key: 'x', name: 'Bob' } + clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true } + expect(clause_match_user(clause, user, features)).to be false + end + + it "retrieves segment from segment store for segmentMatch operator" do + segment = { + key: 'segkey', + included: [ 'userkey' ], + version: 1, + deleted: false + } + features.upsert(LaunchDarkly::SEGMENTS, segment) + + user = { key: 'userkey' } + clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } + + expect(clause_match_user(clause, user, features)).to be true + end + + it "falls through with no errors if referenced segment is not found" do + user = { key: 'userkey' } + clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } + + expect(clause_match_user(clause, user, features)).to be false end it "can be negated" do @@ -153,7 +189,7 @@ clause = { attribute: 'name', op: 'in', values: ['Bob'] } expect { clause[:negate] = true - }.to change {clause_match_user(clause, user)}.from(true).to(false) + }.to change {clause_match_user(clause, user, features)}.from(true).to(false) end end @@ -255,7 +291,7 @@ it "should return #{shouldBe} for #{value1} #{op} #{value2}" do user = { key: 'x', custom: { foo: value1 } } clause = { attribute: 'foo', op: op, values: [value2] } - expect(clause_match_user(clause, user)).to be shouldBe + expect(clause_match_user(clause, user, features)).to be shouldBe end end end @@ -313,4 +349,165 @@ expect(result).to eq(0.0) end end + + def make_flag(key) + { + key: key, + rules: [], + variations: [ false, true ], + on: true, + fallthrough: { variation: 0 }, + version: 1 + } + end + + def make_segment(key) + { + key: key, + included: [], + excluded: [], + salt: 'abcdef', + version: 1 + } + end + + def make_segment_match_clause(segment) + { + op: :segmentMatch, + values: [ segment[:key] ], + negate: false + } + end + + def make_user_matching_clause(user, attr) + { + attribute: attr.to_s, + op: :in, + values: [ user[attr.to_sym] ], + negate: false + } + end + + describe 'segment matching' do + it 'explicitly includes user' do + segment = make_segment('segkey') + segment[:included] = [ user[:key] ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it 'explicitly excludes user' do + segment = make_segment('segkey') + segment[:excluded] = [ user[:key] ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be false + end + + it 'both includes and excludes user; include takes priority' do + segment = make_segment('segkey') + segment[:included] = [ user[:key] ] + segment[:excluded] = [ user[:key] ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it 'matches user by rule when weight is absent' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it 'matches user by rule when weight is nil' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: nil + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it 'matches user with full rollout' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: 100000 + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it "doesn't match user with zero rollout" do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: 0 + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be false + end + + it "matches user with multiple clauses" do + segClause1 = make_user_matching_clause(user, :email) + segClause2 = make_user_matching_clause(user, :name) + segRule = { + clauses: [ segClause1, segClause2 ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be true + end + + it "doesn't match user with multiple clauses if a clause doesn't match" do + segClause1 = make_user_matching_clause(user, :email) + segClause2 = make_user_matching_clause(user, :name) + segClause2[:values] = [ 'wrong' ] + segRule = { + clauses: [ segClause1, segClause2 ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + features.upsert(LaunchDarkly::SEGMENTS, segment) + clause = make_segment_match_clause(segment) + + result = clause_match_user(clause, user, features) + expect(result).to be false + end + end end diff --git a/spec/feature_store_spec_base.rb b/spec/feature_store_spec_base.rb index d589acab..d6c1cedc 100644 --- a/spec/feature_store_spec_base.rb +++ b/spec/feature_store_spec_base.rb @@ -31,7 +31,7 @@ let!(:store) do s = create_store_method.call() - s.init({ key0 => feature0 }) + s.init(LaunchDarkly::FEATURES => { key0 => feature0 }) s end @@ -48,15 +48,15 @@ def new_version_plus(f, deltaVersion, attrs = {}) end it "can get existing feature with symbol key" do - expect(store.get(key0)).to eq feature0 + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0 end it "can get existing feature with string key" do - expect(store.get(key0.to_s)).to eq feature0 + expect(store.get(LaunchDarkly::FEATURES, key0.to_s)).to eq feature0 end it "gets nil for nonexisting feature" do - expect(store.get('nope')).to be_nil + expect(store.get(LaunchDarkly::FEATURES, 'nope')).to be_nil end it "can get all features" do @@ -64,8 +64,8 @@ def new_version_plus(f, deltaVersion, attrs = {}) feature1[:key] = "test-feature-flag1" feature1[:version] = 5 feature1[:on] = false - store.upsert(:"test-feature-flag1", feature1) - expect(store.all).to eq ({ key0 => feature0, :"test-feature-flag1" => feature1 }) + store.upsert(LaunchDarkly::FEATURES, feature1) + expect(store.all(LaunchDarkly::FEATURES)).to eq ({ key0 => feature0, :"test-feature-flag1" => feature1 }) end it "can add new feature" do @@ -73,40 +73,40 @@ def new_version_plus(f, deltaVersion, attrs = {}) feature1[:key] = "test-feature-flag1" feature1[:version] = 5 feature1[:on] = false - store.upsert(:"test-feature-flag1", feature1) - expect(store.get(:"test-feature-flag1")).to eq feature1 + store.upsert(LaunchDarkly::FEATURES, feature1) + expect(store.get(LaunchDarkly::FEATURES, :"test-feature-flag1")).to eq feature1 end it "can update feature with newer version" do f1 = new_version_plus(feature0, 1, { on: !feature0[:on] }) - store.upsert(key0, f1) - expect(store.get(key0)).to eq f1 + store.upsert(LaunchDarkly::FEATURES, f1) + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq f1 end it "cannot update feature with same version" do f1 = new_version_plus(feature0, 0, { on: !feature0[:on] }) - store.upsert(key0, f1) - expect(store.get(key0)).to eq feature0 + store.upsert(LaunchDarkly::FEATURES, f1) + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0 end it "cannot update feature with older version" do f1 = new_version_plus(feature0, -1, { on: !feature0[:on] }) - store.upsert(key0, f1) - expect(store.get(key0)).to eq feature0 + store.upsert(LaunchDarkly::FEATURES, f1) + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0 end it "can delete feature with newer version" do - store.delete(key0, feature0[:version] + 1) - expect(store.get(key0)).to be_nil + store.delete(LaunchDarkly::FEATURES, key0, feature0[:version] + 1) + expect(store.get(LaunchDarkly::FEATURES, key0)).to be_nil end it "cannot delete feature with same version" do - store.delete(key0, feature0[:version]) - expect(store.get(key0)).to eq feature0 + store.delete(LaunchDarkly::FEATURES, key0, feature0[:version]) + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0 end it "cannot delete feature with older version" do - store.delete(key0, feature0[:version] - 1) - expect(store.get(key0)).to eq feature0 + store.delete(LaunchDarkly::FEATURES, key0, feature0[:version] - 1) + expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0 end end diff --git a/spec/segment_store_spec_base.rb b/spec/segment_store_spec_base.rb new file mode 100644 index 00000000..02ecd448 --- /dev/null +++ b/spec/segment_store_spec_base.rb @@ -0,0 +1,95 @@ +require "spec_helper" + +RSpec.shared_examples "segment_store" do |create_store_method| + + let(:segment0) { + { + key: "test-segment", + version: 11, + salt: "718ea30a918a4eba8734b57ab1a93227", + rules: [] + } + } + let(:key0) { segment0[:key].to_sym } + + let!(:store) do + s = create_store_method.call() + s.init({ key0 => segment0 }) + s + end + + def new_version_plus(f, deltaVersion, attrs = {}) + f1 = f.clone + f1[:version] = f[:version] + deltaVersion + f1.update(attrs) + f1 + end + + + it "is initialized" do + expect(store.initialized?).to eq true + end + + it "can get existing feature with symbol key" do + expect(store.get(key0)).to eq segment0 + end + + it "can get existing feature with string key" do + expect(store.get(key0.to_s)).to eq segment0 + end + + it "gets nil for nonexisting feature" do + expect(store.get('nope')).to be_nil + end + + it "can get all features" do + feature1 = segment0.clone + feature1[:key] = "test-feature-flag1" + feature1[:version] = 5 + feature1[:on] = false + store.upsert(:"test-feature-flag1", feature1) + expect(store.all).to eq ({ key0 => segment0, :"test-feature-flag1" => feature1 }) + end + + it "can add new feature" do + feature1 = segment0.clone + feature1[:key] = "test-feature-flag1" + feature1[:version] = 5 + feature1[:on] = false + store.upsert(:"test-feature-flag1", feature1) + expect(store.get(:"test-feature-flag1")).to eq feature1 + end + + it "can update feature with newer version" do + f1 = new_version_plus(segment0, 1, { on: !segment0[:on] }) + store.upsert(key0, f1) + expect(store.get(key0)).to eq f1 + end + + it "cannot update feature with same version" do + f1 = new_version_plus(segment0, 0, { on: !segment0[:on] }) + store.upsert(key0, f1) + expect(store.get(key0)).to eq segment0 + end + + it "cannot update feature with older version" do + f1 = new_version_plus(segment0, -1, { on: !segment0[:on] }) + store.upsert(key0, f1) + expect(store.get(key0)).to eq segment0 + end + + it "can delete feature with newer version" do + store.delete(key0, segment0[:version] + 1) + expect(store.get(key0)).to be_nil + end + + it "cannot delete feature with same version" do + store.delete(key0, segment0[:version]) + expect(store.get(key0)).to eq segment0 + end + + it "cannot delete feature with older version" do + store.delete(key0, segment0[:version] - 1) + expect(store.get(key0)).to eq segment0 + end +end diff --git a/spec/stream_spec.rb b/spec/stream_spec.rb index ce6c4185..e4495b52 100644 --- a/spec/stream_spec.rb +++ b/spec/stream_spec.rb @@ -3,20 +3,23 @@ describe LaunchDarkly::InMemoryFeatureStore do subject { LaunchDarkly::InMemoryFeatureStore } + + include LaunchDarkly + let(:store) { subject.new } let(:key) { :asdf } - let(:feature) { { value: "qwer", version: 0 } } + let(:feature) { { key: "asdf", value: "qwer", version: 0 } } describe '#all' do it "will get all keys" do - store.upsert(key, feature) - data = store.all + store.upsert(LaunchDarkly::FEATURES, feature) + data = store.all(LaunchDarkly::FEATURES) expect(data).to eq(key => feature) end it "will not get deleted keys" do - store.upsert(key, feature) - store.delete(key, 1) - data = store.all + store.upsert(LaunchDarkly::FEATURES, feature) + store.delete(LaunchDarkly::FEATURES, key, 1) + data = store.all(LaunchDarkly::FEATURES) expect(data).to eq({}) end end @@ -37,21 +40,33 @@ let(:processor) { subject.new("sdk_key", config, requestor) } describe '#process_message' do - let(:put_message) { OpenStruct.new({data: '{"key": {"value": "asdf"}}'}) } - let(:patch_message) { OpenStruct.new({data: '{"path": "akey", "data": {"value": "asdf", "version": 1}}'}) } - let(:delete_message) { OpenStruct.new({data: '{"path": "akey", "version": 2}'}) } + let(:put_message) { OpenStruct.new({data: '{"data":{"flags":{"asdf": {"key": "asdf"}},"segments":{"segkey": {"key": "segkey"}}}}'}) } + let(:patch_flag_message) { OpenStruct.new({data: '{"path": "/flags/key", "data": {"key": "asdf", "version": 1}}'}) } + let(:patch_seg_message) { OpenStruct.new({data: '{"path": "/segments/key", "data": {"key": "asdf", "version": 1}}'}) } + let(:delete_flag_message) { OpenStruct.new({data: '{"path": "/flags/key", "version": 2}'}) } + let(:delete_seg_message) { OpenStruct.new({data: '{"path": "/segments/key", "version": 2}'}) } it "will accept PUT methods" do processor.send(:process_message, put_message, LaunchDarkly::PUT) - expect(processor.instance_variable_get(:@store).get("key")).to eq(value: "asdf") + expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(key: "asdf") + expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(key: "segkey") + end + it "will accept PATCH methods for flags" do + processor.send(:process_message, patch_flag_message, LaunchDarkly::PATCH) + expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(key: "asdf", version: 1) + end + it "will accept PATCH methods for segments" do + processor.send(:process_message, patch_seg_message, LaunchDarkly::PATCH) + expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "asdf")).to eq(key: "asdf", version: 1) end - it "will accept PATCH methods" do - processor.send(:process_message, patch_message, LaunchDarkly::PATCH) - expect(processor.instance_variable_get(:@store).get("key")).to eq(value: "asdf", version: 1) + it "will accept DELETE methods for flags" do + processor.send(:process_message, patch_flag_message, LaunchDarkly::PATCH) + processor.send(:process_message, delete_flag_message, LaunchDarkly::DELETE) + expect(config.feature_store.get(LaunchDarkly::FEATURES, "key")).to eq(nil) end - it "will accept DELETE methods" do - processor.send(:process_message, patch_message, LaunchDarkly::PATCH) - processor.send(:process_message, delete_message, LaunchDarkly::DELETE) - expect(processor.instance_variable_get(:@store).get("key")).to eq(nil) + it "will accept DELETE methods for segments" do + processor.send(:process_message, patch_seg_message, LaunchDarkly::PATCH) + processor.send(:process_message, delete_seg_message, LaunchDarkly::DELETE) + expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "key")).to eq(nil) end it "will log a warning if the method is not recognized" do expect(processor.instance_variable_get(:@config).logger).to receive :warn