diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e9ef70..7b947fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,8 +25,16 @@ jobs: uses: ruby/setup-ruby@v1 with: bundler-cache: true - - name: Run tests - run: bundle exec rspec + - name: Run core tests + run: bundle exec rspec spec + - name: Setup Redis adapter gems + uses: ruby/setup-ruby@v1 + with: + working-directory: rollout-redis + bundler-cache: true + - name: Run Redis adapter tests + working-directory: rollout-redis + run: bundle exec rspec - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9168a2..9c0103e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,32 @@ on: jobs: - test: + core: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run core tests + run: | + mkdir -p test_results + bundle exec rspec spec --format progress --format RspecJunitFormatter --out test_results/rspec.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: core-test-results-${{ matrix.ruby-version }} + path: test_results/rspec.xml + retention-days: 30 + + redis: runs-on: ubuntu-latest strategy: matrix: @@ -31,8 +56,10 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} + working-directory: rollout-redis bundler-cache: true - - name: Run tests + - name: Run Redis adapter tests + working-directory: rollout-redis run: | mkdir -p test_results bundle exec rspec --format progress --format RspecJunitFormatter --out test_results/rspec.xml @@ -40,6 +67,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results-${{ matrix.ruby-version }} - path: test_results/rspec.xml + name: redis-test-results-${{ matrix.ruby-version }} + path: rollout-redis/test_results/rspec.xml retention-days: 30 diff --git a/README.md b/README.md index 3a54548..adc2c28 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rollout -Fast feature flags based on Redis. +Fast feature flags. [![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout) [![CI](https://github.com/fetlife/rollout/actions/workflows/test.yml/badge.svg)](https://github.com/fetlife/rollout/actions/workflows/test.yml) @@ -11,6 +11,12 @@ Fast feature flags based on Redis. ```bash gem install rollout +gem install rollout-redis +``` + +```ruby +gem "rollout" +gem "rollout-redis" ``` ## How it works @@ -18,17 +24,12 @@ gem install rollout Initialize a rollout object. I assign it to a global var. ```ruby -require 'redis' +require "redis" +require "rollout" +require "rollout/redis" $redis = Redis.new -$rollout = Rollout.new($redis) -``` - -or even simpler - -```ruby -require 'redis' -$rollout = Rollout.new($redis) # Will use REDIS_URL env var or default redis url +$rollout = Rollout.new(backend: Rollout::Redis::Backend.new($redis)) ``` @@ -128,7 +129,10 @@ In some cases you might want to have a feature activated for a random set of users. It can come specially handy when using Rollout for split tests. ```ruby -$rollout = Rollout.new($redis, randomize_percentage: true) +$rollout = Rollout.new( + backend: Rollout::Redis::Backend.new($redis), + randomize_percentage: true, +) ``` When on `randomize_percentage` will make sure that 50% of users for feature A @@ -181,7 +185,7 @@ environments by using the ```ruby $ns = Redis::Namespace.new(Rails.env, redis: $redis) -$rollout = Rollout.new($ns) +$rollout = Rollout.new(backend: Rollout::Redis::Backend.new($ns)) $rollout.activate_group(:chat, :all) ``` @@ -209,12 +213,18 @@ This example would use the "development:feature:chat:groups" key. ## Testing -The suite flushes Redis database 7 before every example. Use a disposable +Core tests do not need Redis: + +```bash +bundle exec rake spec +``` + +Redis adapter tests flush database 7 before every example. Use a disposable instance, not a shared or production Redis. ```bash docker run --rm -p 6379:6379 redis:7-alpine -bundle exec rspec +bundle exec rake spec:redis ``` Optional connection settings: `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`. diff --git a/Rakefile b/Rakefile index 5bbe932..fa47f6b 100644 --- a/Rakefile +++ b/Rakefile @@ -3,7 +3,21 @@ require "bundler/gem_tasks" require "rspec/core/rake_task" -RSpec::Core::RakeTask.new(:spec) +RSpec::Core::RakeTask.new(:spec) do |task| + task.pattern = "spec/**/*_spec.rb" +end + +namespace :spec do + desc "Run Redis adapter tests" + task :redis do + gemfile = File.expand_path("rollout-redis/Gemfile", __dir__) + Dir.chdir("rollout-redis") do + Bundler.with_unbundled_env do + sh({ "BUNDLE_GEMFILE" => gemfile }, "bundle exec rspec") + end + end + end +end task default: :spec diff --git a/lib/rollout.rb b/lib/rollout.rb index c45b15f..9e483b9 100644 --- a/lib/rollout.rb +++ b/lib/rollout.rb @@ -2,7 +2,6 @@ require 'rollout/feature' require 'rollout/logging' -require 'rollout/redis_codec' require 'rollout/version' require 'zlib' require 'set' @@ -14,14 +13,14 @@ class Rollout RAND_BASE = (2**32 - 1) / 100.0 - attr_reader :options, :storage + attr_reader :options, :backend - def initialize(storage, opts = {}) - @storage = storage - @options = opts + def initialize(backend:, **options) + @backend = backend + @options = options @groups = { all: ->(_user) { true } } - extend(Logging) if opts[:logging] + extend(Logging) if options[:logging] end def groups @@ -39,10 +38,7 @@ def deactivate(feature) end def delete(feature) - features = (@storage.get(features_key) || '').split(',') - features.delete(feature.to_s) - @storage.set(features_key, features.join(',')) - @storage.del(key(feature)) + @backend.delete_feature(feature) if respond_to?(:logging) logging.delete(feature) @@ -138,9 +134,8 @@ def active_in_group?(group, user) end def get(feature) - payload = @storage.get(key(feature)) Feature.new( - state: RedisCodec.decode(feature, payload), + state: @backend.fetch_feature(feature), rollout: self, options: @options, name: feature, @@ -162,23 +157,13 @@ def clear_feature_data(feature) def multi_get(*features) return [] if features.empty? - feature_keys = features.map { |feature| key(feature) } - - @storage - .mget(*feature_keys) - .map - .with_index do |payload, index| - Feature.new( - state: RedisCodec.decode(features[index], payload), - rollout: self, - options: @options, - name: features[index], - ) - end + @backend.fetch_features(features).zip(features).map do |state, name| + Feature.new(state: state, rollout: self, options: @options, name: name) + end end def features - (@storage.get(features_key) || '').split(',').map(&:to_sym) + @backend.feature_names.map(&:to_sym) end def feature_states(user = nil) @@ -196,49 +181,52 @@ def active_features(user = nil) def clear! features.each do |feature| with_feature(feature, &:clear) - @storage.del(key(feature)) + @backend.delete_feature(feature) end - - @storage.del(features_key) + @backend.clear_features end def exists?(feature) - # since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean - # https://github.com/redis/redis-rb/pull/918 - if @storage.respond_to?(:exists?) - @storage.exists?(key(feature)) - else - @storage.exists(key(feature)) - end + @backend.feature_exists?(feature) end def with_feature(feature) - f = get(feature) + mutated = nil + before = nil + capture_logging = logging_capture? + notify = count_observers > 0 + snapshot = notify || capture_logging + + @backend.mutate_feature(feature) do |current_state| + mutated = Feature.new( + state: current_state, + rollout: self, + options: @options, + name: feature, + ) + before = mutated.deep_clone if snapshot + yield mutated + + event = capture_logging ? logging.event_for(before, mutated) : nil + result = { state: mutated.to_feature_state, event: event } + if event + result[:history_length] = logging.history_length + result[:global] = logging.global + end + result + end - if count_observers > 0 - before = f.deep_clone - yield(f) - save(f) + if notify changed - notify_observers(:update, before, f) - else - yield(f) - save(f) + notify_observers(:update, before, mutated) end - end - - private - def key(name) - "feature:#{name}" + mutated end - def features_key - 'feature:__features__' - end + private - def save(feature) - @storage.set(key(feature.name), RedisCodec.encode(feature.to_feature_state)) - @storage.set(features_key, (features | [feature.name.to_sym]).join(',')) + def logging_capture? + respond_to?(:logging) && logging.logging_enabled? end end diff --git a/lib/rollout/logging.rb b/lib/rollout/logging.rb index cc794db..0c905aa 100644 --- a/lib/rollout/logging.rb +++ b/lib/rollout/logging.rb @@ -1,13 +1,12 @@ +# frozen_string_literal: true + class Rollout module Logging def self.extended(rollout) options = rollout.options[:logging] options = options.is_a?(Hash) ? options.dup : {} - options[:storage] ||= rollout.storage - - logger = Logger.new(**options) - rollout.add_observer(logger, :log) + logger = Logger.new(backend: rollout.backend, **options) rollout.define_singleton_method(:logging) do logger end @@ -16,12 +15,6 @@ def self.extended(rollout) class Event attr_reader :feature, :name, :data, :context, :created_at - def self.from_raw(value, score) - hash = JSON.parse(value, symbolize_names: true) - - new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000))) - end - def initialize(feature: nil, name:, data:, context: {}, created_at:) @feature = feature @name = name @@ -53,45 +46,37 @@ def ==(other) end class Logger - def initialize(storage: nil, history_length: 50, global: false) + attr_reader :history_length, :global + + def initialize(backend:, history_length: 50, global: false) + @backend = backend @history_length = history_length - @storage = storage @global = global end def updated_at(feature_name) - storage_key = events_storage_key(feature_name) - _, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first - Time.at(-score.to_f / 1_000_000) if score + @backend.feature_updated_at(feature_name) end def last_event(feature_name) - storage_key = events_storage_key(feature_name) - value = @storage.zrange(storage_key, 0, 0, with_scores: true).first - Event.from_raw(*value) if value + events(feature_name, limit: 1).last end - def events(feature_name) - storage_key = events_storage_key(feature_name) - @storage - .zrange(storage_key, 0, -1, with_scores: true) - .map { |v| Event.from_raw(*v) } - .reverse + def events(feature_name, limit: nil) + @backend.feature_events(feature_name, limit: limit) end - def global_events - @storage - .zrange(global_events_storage_key, 0, -1, with_scores: true) - .map { |v| Event.from_raw(*v) } - .reverse + def global_events(limit: nil) + @backend.global_events(limit: limit) end def delete(feature_name) - storage_key = events_storage_key(feature_name) - @storage.del(storage_key) + @backend.delete_feature_events(feature_name) end - def update(before, after) + def event_for(before, after) + return unless logging_enabled? + before_hash = before.to_hash before_hash.delete(:data).each do |k, v| before_hash["data.#{k}"] = v @@ -116,41 +101,13 @@ def update(before, after) return if changed_count == 0 - event = Event.new( + Event.new( feature: after.name, name: :update, data: change, context: current_context, created_at: Time.now, ) - - storage_key = events_storage_key(after.name) - - @storage.zadd(storage_key, -event.timestamp, event.serialize) - @storage.zremrangebyrank(storage_key, @history_length, -1) - - if @global - @storage.zadd(global_events_storage_key, -event.timestamp, event.serialize) - @storage.zremrangebyrank(global_events_storage_key, @history_length, -1) - end - end - - def log(event, *args) - return unless logging_enabled? - - unless respond_to?(event) - raise ArgumentError, "Invalid log event: #{event}" - end - - expected_arity = method(event).arity - unless args.count == expected_arity - raise( - ArgumentError, - "Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}", - ) - end - - public_send(event, *args) end CONTEXT_THREAD_KEY = :rollout_logging_context @@ -171,29 +128,16 @@ def current_context end def without + previous = Thread.current[WITHOUT_THREAD_KEY] Thread.current[WITHOUT_THREAD_KEY] = true yield ensure - Thread.current[WITHOUT_THREAD_KEY] = nil + Thread.current[WITHOUT_THREAD_KEY] = previous end def logging_enabled? !Thread.current[WITHOUT_THREAD_KEY] end - - private - - def global_events_storage_key - "feature:_global_:logging:events" - end - - def events_storage_key(feature_name) - "feature:#{feature_name}:logging:events" - end - - def current_timestamp - (Time.now.to_f * 1_000_000).to_i - end end end end diff --git a/lib/rollout/redis_codec.rb b/lib/rollout/redis_codec.rb deleted file mode 100644 index 90a6be1..0000000 --- a/lib/rollout/redis_codec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'json' -require 'rollout/feature_state' - -class Rollout - class RedisCodec - def self.decode(name, payload) - if payload.nil? || payload.empty? - return FeatureState.new( - name: name, - percentage: 0, - users: [], - groups: [], - data: {}, - ) - end - - raw_percentage, raw_users, raw_groups, raw_data = payload.split('|', 4) - data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data) - - FeatureState.new( - name: name, - percentage: raw_percentage.to_f, - users: (raw_users || '').split(','), - groups: (raw_groups || '').split(','), - data: data, - ) - end - - def self.encode(feature_state) - "#{feature_state.percentage}|#{feature_state.users.join(',')}|#{feature_state.groups.join(',')}|#{feature_state.data.to_json}" - end - end -end diff --git a/lib/rollout/version.rb b/lib/rollout/version.rb index 4d97393..b44b362 100644 --- a/lib/rollout/version.rb +++ b/lib/rollout/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Rollout - VERSION = '2.6.2' + VERSION = '3.0.0' end diff --git a/rollout-redis/.rspec b/rollout-redis/.rspec new file mode 100644 index 0000000..83e16f8 --- /dev/null +++ b/rollout-redis/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/rollout-redis/Gemfile b/rollout-redis/Gemfile new file mode 100644 index 0000000..c99dca6 --- /dev/null +++ b/rollout-redis/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec + +gem 'rollout', path: '..' +gem 'rake' +gem 'rspec', '~> 3.13' +gem 'rspec_junit_formatter', '~> 0.6' +gem 'simplecov', '0.17' diff --git a/rollout-redis/lib/rollout/redis.rb b/rollout-redis/lib/rollout/redis.rb new file mode 100644 index 0000000..08e2343 --- /dev/null +++ b/rollout-redis/lib/rollout/redis.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'redis' +require 'rollout' +require 'rollout/redis/backend' + +class Rollout + module Redis + end +end diff --git a/rollout-redis/lib/rollout/redis/backend.rb b/rollout-redis/lib/rollout/redis/backend.rb new file mode 100644 index 0000000..f4102c6 --- /dev/null +++ b/rollout-redis/lib/rollout/redis/backend.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rollout/logging' +require 'rollout/redis/codec' + +class Rollout + module Redis + class Backend + FEATURES_KEY = 'feature:__features__' + + def initialize(client) + @client = client + end + + def fetch_feature(name) + Codec.decode(name, @client.get(key(name))) + end + + def fetch_features(names) + return [] if names.empty? + + payloads = @client.mget(*names.map { |name| key(name) }) + names.zip(payloads).map { |name, payload| Codec.decode(name, payload) } + end + + def feature_names + (@client.get(FEATURES_KEY) || '').split(',') + end + + def feature_exists?(name) + if @client.respond_to?(:exists?) + @client.exists?(key(name)) + else + @client.exists(key(name)) + end + end + + def save_feature(state) + @client.set(key(state.name), Codec.encode(state)) + names = feature_names.map(&:to_s) | [state.name.to_s] + @client.set(FEATURES_KEY, names.join(',')) + end + + def delete_feature(name) + names = feature_names + names.delete(name.to_s) + @client.set(FEATURES_KEY, names.join(',')) + @client.del(key(name)) + end + + def clear_features + @client.del(FEATURES_KEY) + end + + def mutate_feature(name) + mutation = yield fetch_feature(name) + save_feature(mutation.fetch(:state)) + event = mutation[:event] + if event + record_event( + event, + history_length: mutation.fetch(:history_length), + global: mutation[:global], + ) + end + mutation + end + + def record_event(event, history_length:, global: false) + storage_key = events_key(event.feature) + @client.zadd(storage_key, -event.timestamp, event.serialize) + @client.zremrangebyrank(storage_key, history_length, -1) + + return unless global + + @client.zadd(global_events_key, -event.timestamp, event.serialize) + @client.zremrangebyrank(global_events_key, history_length, -1) + end + + def feature_events(name, limit: nil) + events_from(events_key(name), limit: limit) + end + + def global_events(limit: nil) + events_from(global_events_key, limit: limit) + end + + def feature_updated_at(name) + _, score = @client.zrange(events_key(name), 0, 0, with_scores: true).first + Time.at(-score.to_f / 1_000_000) if score + end + + def delete_feature_events(name) + @client.del(events_key(name)) + end + + private + + def key(name) + "feature:#{name}" + end + + def events_key(name) + "feature:#{name}:logging:events" + end + + def global_events_key + 'feature:_global_:logging:events' + end + + def events_from(storage_key, limit: nil) + stop = event_range_stop(limit) + return [] if stop == :empty + + @client + .zrange(storage_key, 0, stop, with_scores: true) + .map { |value| Codec.decode_event(*value) } + .reverse + end + + def event_range_stop(limit) + return -1 if limit.nil? + raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer) + raise ArgumentError, "limit must be >= 0" if limit < 0 + return :empty if limit.zero? + + limit - 1 + end + end + end +end diff --git a/rollout-redis/lib/rollout/redis/codec.rb b/rollout-redis/lib/rollout/redis/codec.rb new file mode 100644 index 0000000..b8a180e --- /dev/null +++ b/rollout-redis/lib/rollout/redis/codec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'json' +require 'rollout/feature_state' +require 'rollout/logging' + +class Rollout + module Redis + class Codec + def self.decode(name, payload) + if payload.nil? || payload.empty? + return FeatureState.new( + name: name, + percentage: 0, + users: [], + groups: [], + data: {}, + ) + end + + raw_percentage, raw_users, raw_groups, raw_data = payload.split('|', 4) + data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data) + + FeatureState.new( + name: name, + percentage: raw_percentage.to_f, + users: (raw_users || '').split(','), + groups: (raw_groups || '').split(','), + data: data, + ) + end + + def self.encode(feature_state) + "#{feature_state.percentage}|#{feature_state.users.join(',')}|#{feature_state.groups.join(',')}|#{feature_state.data.to_json}" + end + + def self.decode_event(value, score) + hash = JSON.parse(value, symbolize_names: true) + + Logging::Event.new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000))) + end + end + end +end diff --git a/rollout-redis/rollout-redis.gemspec b/rollout-redis/rollout-redis.gemspec new file mode 100644 index 0000000..de72046 --- /dev/null +++ b/rollout-redis/rollout-redis.gemspec @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = 'rollout-redis' + spec.version = '0.1.0' + spec.authors = ['FetLife'] + spec.email = ['dev@fetlife.com'] + spec.description = 'Redis adapter for the rollout gem.' + spec.summary = 'Redis adapter for the rollout gem.' + spec.homepage = 'https://github.com/FetLife/rollout' + spec.license = 'MIT' + + spec.files = Dir.chdir(__dir__) do + Dir['lib/**/*.rb'] + end + spec.require_paths = ['lib'] + + spec.required_ruby_version = '>= 2.3' + + spec.add_dependency 'redis', '>= 4.0', '< 6' + spec.add_dependency 'rollout', '>= 3.0', '< 4' +end diff --git a/rollout-redis/spec/backend_contract_spec.rb b/rollout-redis/spec/backend_contract_spec.rb new file mode 100644 index 0000000..338c604 --- /dev/null +++ b/rollout-redis/spec/backend_contract_spec.rb @@ -0,0 +1,8 @@ +require "spec_helper" +require_relative "../../spec/support/backend_contract" + +RSpec.describe Rollout::Redis::Backend do + it_behaves_like "a rollout feature backend" do + let(:backend) { redis_backend } + end +end diff --git a/spec/rollout/redis_codec_spec.rb b/rollout-redis/spec/codec_spec.rb similarity index 67% rename from spec/rollout/redis_codec_spec.rb rename to rollout-redis/spec/codec_spec.rb index da93b3b..c3595e9 100644 --- a/spec/rollout/redis_codec_spec.rb +++ b/rollout-redis/spec/codec_spec.rb @@ -1,6 +1,6 @@ require "spec_helper" -RSpec.describe Rollout::RedisCodec do +RSpec.describe Rollout::Redis::Codec do it "decodes a missing payload as an empty feature" do state = described_class.decode(:chat, nil) @@ -60,4 +60,25 @@ expect(described_class.encode(state)).to eq payload end + + describe ".decode_event" do + it "decodes a persisted history member using the sorted-set score" do + created_at = Time.at(1_735_689_600) + value = JSON.dump( + feature: "chat", + name: "update", + data: { before: { percentage: 0 }, after: { percentage: 25 } }, + context: { actor: "lester" }, + created_at: Time.utc(2000, 1, 1), + ) + + event = described_class.decode_event(value, -(created_at.to_f * 1_000_000)) + + expect(event.feature).to eq "chat" + expect(event.name).to eq "update" + expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 }) + expect(event.context).to eq(actor: "lester") + expect(event.created_at.to_i).to eq created_at.to_i + end + end end diff --git a/rollout-redis/spec/history_spec.rb b/rollout-redis/spec/history_spec.rb new file mode 100644 index 0000000..019cf95 --- /dev/null +++ b/rollout-redis/spec/history_spec.rb @@ -0,0 +1,204 @@ +require "spec_helper" + +RSpec.describe "Rollout Redis history" do + let(:rollout) { Rollout.new(backend: redis_backend, logging: logging) } + let(:logging) { true } + let(:feature) { :foo } + + it "logs changes" do + expect(rollout.logging.last_event(feature)).to be_nil + + rollout.activate_percentage(feature, 50) + + expect(rollout.logging.updated_at(feature)).to_not be_nil + + first_event = rollout.logging.last_event(feature) + + expect(first_event.name).to eq "update" + expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 }) + + rollout.activate_percentage(feature, 75) + + second_event = rollout.logging.last_event(feature) + + expect(second_event.name).to eq "update" + expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 }) + + rollout.activate_group(feature, :hipsters) + + third_event = rollout.logging.last_event(feature) + + expect(third_event.name).to eq "update" + expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ["hipsters"] }) + + expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event] + end + + it "logs data changes" do + rollout.set_feature_data(feature, description: "foo") + + event = rollout.logging.last_event(feature) + + expect(event.name).to eq "update" + expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" }) + end + + context "history truncation" do + let(:logging) { { history_length: 1 } } + + it "keeps only the configured number of events" do + rollout.activate_percentage(feature, 25) + rollout.activate_percentage(feature, 30) + + expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [30] + end + end + + it "adds context to the event" do + rollout.logging.with_context(actor: "lester") do + rollout.activate_percentage(feature, 25) + end + + expect(rollout.logging.last_event(feature).context).to eq(actor: "lester") + end + + context "global logs" do + let(:logging) { { global: true } } + + it "logs changes across features" do + rollout.activate_percentage("foo", 25) + rollout.activate_percentage("bar", 30) + + expect(rollout.logging.global_events.map(&:feature)).to eq %w[foo bar] + end + + it "returns limited global events oldest-to-newest" do + rollout.activate_percentage("foo", 25) + rollout.activate_percentage("bar", 30) + rollout.activate_percentage("baz", 40) + + expect(rollout.logging.global_events(limit: 2).map(&:feature)).to eq %w[bar baz] + end + end + + it "does not log inside without" do + rollout.logging.without do + rollout.activate_percentage(feature, 25) + end + + expect(rollout.logging.last_event(feature)).to be_nil + end + + it "does not write a history event when nothing changes" do + rollout.activate_percentage(feature, 25) + + expect do + rollout.activate_percentage(feature, 25) + end.not_to change { rollout.logging.events(feature).count } + end + + it "records one event for a with_feature block" do + rollout.logging.with_context(actor: "alice") do + rollout.with_feature(feature) do |current| + current.percentage = 25.0 + current.groups = [:employees] + current.users = ["123"] + current.data.update(description: "New navigation") + end + end + + events = rollout.logging.events(feature) + expect(events.count).to eq 1 + expect(events.first.context).to eq(actor: "alice") + end + + it "removes feature history on delete" do + rollout.activate_percentage(feature, 25) + rollout.delete(feature) + + expect(rollout.logging.events(feature)).to eq [] + end + + it "keeps feature history on clear!" do + rollout.activate_percentage(feature, 25) + rollout.clear! + + expect(rollout.features).to eq [] + expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [25, 0] + end + + it "removes the features registry after clear!" do + rollout.activate(:chat) + rollout.clear! + + expect($redis.get("feature:__features__")).to be_nil + end + + it "removes an already empty features registry" do + $redis.set("feature:__features__", "") + rollout.clear! + + expect($redis.get("feature:__features__")).to be_nil + end + + it "keeps feature history when deleting without logging" do + rollout.activate_percentage(feature, 25) + + Rollout.new(backend: redis_backend).delete(feature) + + expect(rollout.exists?(feature)).to be_falsey + expect(rollout.logging.events(feature)).not_to eq [] + end + + it "logging.delete removes feature history only" do + rollout.activate_percentage(feature, 25) + rollout.logging.delete(feature) + + expect(rollout.logging.events(feature)).to eq [] + expect(rollout.logging.updated_at(feature)).to be_nil + expect(rollout.get(feature).percentage).to eq 25 + end + + it "backend delete_feature preserves history" do + rollout.activate_percentage(feature, 25) + rollout.backend.delete_feature(feature) + + expect(rollout.exists?(feature)).to be_falsey + expect(rollout.logging.events(feature)).not_to eq [] + end + + it "returns the newest events oldest-to-newest when limited" do + rollout.activate_percentage(feature, 25) + rollout.activate_percentage(feature, 50) + rollout.activate_percentage(feature, 75) + + expect(rollout.logging.events(feature, limit: 2).map { |event| event.data[:after][:percentage] }).to eq [50, 75] + expect(rollout.logging.events(feature, limit: 0)).to eq [] + expect(rollout.logging.last_event(feature).data[:after][:percentage]).to eq 75 + end + + it "does not decode older events when reading last_event" do + rollout.activate_percentage(feature, 25) + $redis.zadd("feature:#{feature}:logging:events", -1, "not-json") + + expect(rollout.logging.last_event(feature).data[:after][:percentage]).to eq 25 + end + + it "rejects an invalid history limit" do + expect { rollout.logging.events(feature, limit: -1) }.to raise_error(ArgumentError) + expect { rollout.backend.feature_events(feature, limit: 1.5) }.to raise_error(ArgumentError) + end + + context "persisted history keys" do + let(:logging) { { history_length: 2, global: true } } + + it "writes truncated per-feature and global sorted sets" do + rollout.activate_percentage(feature, 25) + rollout.activate_percentage(feature, 50) + rollout.activate_percentage(feature, 75) + + expect($redis.zcard("feature:#{feature}:logging:events")).to eq 2 + expect($redis.zcard("feature:_global_:logging:events")).to eq 2 + end + end +end diff --git a/rollout-redis/spec/rollout_integration_spec.rb b/rollout-redis/spec/rollout_integration_spec.rb new file mode 100644 index 0000000..cfc9964 --- /dev/null +++ b/rollout-redis/spec/rollout_integration_spec.rb @@ -0,0 +1,807 @@ +require "spec_helper" + + +RSpec.describe "Rollout" do + let(:rollout) { Rollout.new(backend: redis_backend) } + + describe "when a group is activated" do + before do + rollout.define_group(:fivesonly) { |user| user.id == 5 } + rollout.activate_group(:chat, :fivesonly) + end + + it "the feature is active for users for which the block evaluates to true" do + expect(rollout).to be_active(:chat, double(id: 5)) + end + + it "is not active for users for which the block evaluates to false" do + expect(rollout).not_to be_active(:chat, double(id: 1)) + end + + it "is not active if a group is found in Redis but not defined in Rollout" do + rollout.activate_group(:chat, :fake) + expect(rollout).not_to be_active(:chat, double(id: 1)) + end + end + + describe "the default all group" do + before do + rollout.activate_group(:chat, :all) + end + + it "evaluates to true no matter what" do + expect(rollout).to be_active(:chat, double(id: 0)) + end + end + + describe "deactivating a group" do + before do + rollout.define_group(:fivesonly) { |user| user.id == 5 } + rollout.activate_group(:chat, :all) + rollout.activate_group(:chat, :some) + rollout.activate_group(:chat, :fivesonly) + rollout.deactivate_group(:chat, :all) + rollout.deactivate_group(:chat, "some") + end + + it "deactivates the rules for that group" do + expect(rollout).not_to be_active(:chat, double(id: 10)) + end + + it "leaves the other groups active" do + expect(rollout.get(:chat).groups).to eq [:fivesonly] + end + + it "leaves the other groups active using sets" do + @options = rollout.instance_variable_get("@options") + @options[:use_sets] = true + expect(rollout.get(:chat).groups).to eq [:fivesonly].to_set + end + end + + describe "deactivating a feature completely" do + before do + rollout.define_group(:fivesonly) { |user| user.id == 5 } + rollout.activate_group(:chat, :all) + rollout.activate_group(:chat, :fivesonly) + rollout.activate_user(:chat, double(id: 51)) + rollout.activate_percentage(:chat, 100) + rollout.activate(:chat) + rollout.deactivate(:chat) + end + + it "removes all of the groups" do + expect(rollout).not_to be_active(:chat, double(id: 0)) + end + + it "removes all of the users" do + expect(rollout).not_to be_active(:chat, double(id: 51)) + end + + it "removes the percentage" do + expect(rollout).not_to be_active(:chat, double(id: 24)) + end + + it "removes globally" do + expect(rollout).not_to be_active(:chat) + end + end + + describe "activating a specific user" do + before do + rollout.activate_user(:chat, double(id: 42)) + end + + it "is active for that user" do + expect(rollout).to be_active(:chat, double(id: 42)) + end + + it "remains inactive for other users" do + expect(rollout).not_to be_active(:chat, double(id: 24)) + end + end + + describe "activating a specific user by ID" do + before do + rollout.activate_user(:chat, 42) + end + + it "is active for that user" do + expect(rollout).to be_active(:chat, double(id: 42)) + end + + it "remains inactive for other users" do + expect(rollout).not_to be_active(:chat, double(id: 24)) + end + end + + describe "activating a specific user with a string id" do + before do + rollout.activate_user(:chat, double(id: "user-72")) + end + + it "is active for that user" do + expect(rollout).to be_active(:chat, double(id: "user-72")) + end + + it "remains inactive for other users" do + expect(rollout).not_to be_active(:chat, double(id: "user-12")) + end + end + + describe "activating a group of users" do + context "specified by user objects" do + let(:users) { [double(id: 1), double(id: 2), double(id: 3)] } + + before { rollout.activate_users(:chat, users) } + + it "is active for the given users" do + users.each { |user| expect(rollout).to be_active(:chat, user) } + end + + it "remains inactive for other users" do + expect(rollout).not_to be_active(:chat, double(id: 4)) + end + end + + context "specified by user ids" do + let(:users) { [1, 2, 3] } + + before { rollout.activate_users(:chat, users) } + + it "is active for the given users" do + users.each { |user| expect(rollout).to be_active(:chat, user) } + end + + it "remains inactive for other users" do + expect(rollout).not_to be_active(:chat, 4) + end + end + end + + describe "deactivating a specific user" do + before do + rollout.activate_user(:chat, double(id: 42)) + rollout.activate_user(:chat, double(id: 4242)) + rollout.activate_user(:chat, double(id: 24)) + rollout.deactivate_user(:chat, double(id: 42)) + rollout.deactivate_user(:chat, double(id: "4242")) + end + + it "that user should no longer be active" do + expect(rollout).not_to be_active(:chat, double(id: 42)) + end + + it "remains active for other active users" do + @options = rollout.instance_variable_get("@options") + @options[:use_sets] = false + expect(rollout.get(:chat).users).to eq %w(24) + end + + it "remains active for other active users using sets" do + @options = rollout.instance_variable_get("@options") + @options[:use_sets] = true + + expect(rollout.get(:chat).users).to eq %w(24).to_set + end + end + + describe "deactivating a group of users" do + context "specified by user objects" do + let(:active_users) { [double(id: 1), double(id: 2)] } + let(:inactive_users) { [double(id: 3), double(id: 4)] } + + before do + rollout.activate_users(:chat, active_users + inactive_users) + rollout.deactivate_users(:chat, inactive_users) + end + + it "is active for the active users" do + active_users.each { |user| expect(rollout).to be_active(:chat, user) } + end + + it "is not active for inactive users" do + inactive_users.each { |user| expect(rollout).not_to be_active(:chat, user) } + end + end + + context "specified by user ids" do + let(:active_users) { [1, 2] } + let(:inactive_users) { [3, 4] } + + before do + rollout.activate_users(:chat, active_users + inactive_users) + rollout.deactivate_users(:chat, inactive_users) + end + + it "is active for the active users" do + active_users.each { |user| expect(rollout).to be_active(:chat, user) } + end + + it "is not active for inactive users" do + inactive_users.each { |user| expect(rollout).not_to be_active(:chat, user) } + end + end + end + + + describe 'set a group of users' do + it 'should replace the users with the given array' do + users = %w(1 2 3 4) + rollout.activate_users(:chat, %w(10 20 30)) + rollout.set_users(:chat, users) + expect(rollout.get(:chat).users).to eq(users) + end + end + + describe "activating a feature globally" do + before do + rollout.activate(:chat) + end + + it "activates the feature" do + expect(rollout).to be_active(:chat) + end + + it "sets @data to empty hash" do + expect(rollout.get(:chat).data).to eq({}) + end + end + + describe "activating a feature for a percentage of users" do + before do + rollout.activate_percentage(:chat, 20) + end + + it "activates the feature for that percentage of the users" do + expect((1..100).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(20) + end + end + + describe "activating a feature for a percentage of users" do + before do + rollout.activate_percentage(:chat, 20) + end + + it "activates the feature for that percentage of the users" do + expect((1..200).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(4).of(40) + end + end + + describe "activating a feature for a percentage of users" do + before do + rollout.activate_percentage(:chat, 5) + end + + it "activates the feature for that percentage of the users" do + expect((1..100).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(5) + end + end + + describe "activating a feature for a percentage of users" do + before do + rollout.activate_percentage(:chat, 0.1) + end + + it "activates the feature for that percentage of the users" do + expect((1..10_000).to_set.select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(10) + end + end + + describe "activating a feature for a percentage of users" do + before do + rollout.activate_percentage(:chat, 20) + rollout.activate_percentage(:beta, 20) + @options = rollout.instance_variable_get("@options") + end + + it "activates the feature for a random set of users when opt is set" do + @options[:randomize_percentage] = true + chat_users = (1..100).select { |id| rollout.active?(:chat, double(id: id)) } + beta_users = (1..100).select { |id| rollout.active?(:beta, double(id: id)) } + expect(chat_users).not_to eq beta_users + end + it "activates the feature for the same set of users when opt is not set" do + @options[:randomize_percentage] = false + chat_users = (1..100).select { |id| rollout.active?(:chat, double(id: id)) } + beta_users = (1..100).select { |id| rollout.active?(:beta, double(id: id)) } + expect(chat_users).to eq beta_users + end + end + + describe "exact CRC32 percentage assignment" do + it "keeps the same users across features when randomize_percentage is off" do + rollout.activate_percentage(:chat, 20) + rollout.activate_percentage(:beta, 20) + + expect(rollout.active?(:chat, double(id: 2))).to eq true + expect(rollout.active?(:chat, double(id: 6))).to eq true + expect(rollout.active?(:chat, double(id: 1))).to eq false + expect(rollout.active?(:beta, double(id: 2))).to eq true + expect(rollout.active?(:beta, double(id: 1))).to eq false + end + + it "changes assignment by feature name when randomize_percentage is on" do + randomized = Rollout.new(backend: redis_backend, randomize_percentage: true) + randomized.activate_percentage(:chat, 20) + randomized.activate_percentage(:beta, 20) + + expect(randomized.active?(:chat, double(id: 1))).to eq true + expect(randomized.active?(:beta, double(id: 1))).to eq false + expect(randomized.active?(:chat, double(id: 5))).to eq false + expect(randomized.active?(:beta, double(id: 5))).to eq true + end + end + + describe "activating a feature for a group as a string" do + before do + rollout.define_group(:admins) { |user| user.id == 5 } + rollout.activate_group(:chat, "admins") + end + + it "the feature is active for users for which the block evaluates to true" do + expect(rollout).to be_active(:chat, double(id: 5)) + end + + it "is not active for users for which the block evaluates to false" do + expect(rollout).not_to be_active(:chat, double(id: 1)) + end + end + + describe "deactivating the percentage of users" do + before do + rollout.activate_percentage(:chat, 100) + rollout.deactivate_percentage(:chat) + end + + it "becomes inactivate for all users" do + expect(rollout).not_to be_active(:chat, double(id: 24)) + end + end + + describe "deactivating the feature globally" do + before do + rollout.activate(:chat) + rollout.deactivate(:chat) + end + + it "becomes inactivate" do + expect(rollout).not_to be_active(:chat) + end + end + + describe "setting a feature on" do + before do + rollout.set(:chat, true) + end + + it "becomes activated" do + expect(rollout).to be_active(:chat) + end + end + + describe "setting a feature off" do + before do + rollout.set(:chat, false) + end + + it "becomes inactivated" do + expect(rollout).not_to be_active(:chat) + end + end + + describe "deleting a feature" do + before do + rollout.set(:chat, true) + end + + context "when feature was passed as string" do + it "should be removed from features list" do + expect(rollout.features.size).to eq 1 + rollout.delete('chat') + expect(rollout.features.size).to eq 0 + end + end + + it "should be removed from features list" do + expect(rollout.features.size).to eq 1 + rollout.delete(:chat) + expect(rollout.features.size).to eq 0 + end + + it "should have metadata cleared" do + expect(rollout.get(:chat).percentage).to eq 100 + rollout.delete(:chat) + expect(rollout.get(:chat).percentage).to eq 0 + end + end + + describe "keeps a list of features" do + it "saves the feature" do + rollout.activate(:chat) + expect(rollout.features).to be_include(:chat) + end + + it "does not contain doubles" do + rollout.activate(:chat) + rollout.activate(:chat) + expect(rollout.features.size).to eq(1) + end + + it "does not contain doubles when using string" do + rollout.activate(:chat) + rollout.activate("chat") + expect(rollout.features.size).to eq(1) + end + end + + describe "#get" do + before do + rollout.activate_percentage(:chat, 10) + rollout.activate_group(:chat, :caretakers) + rollout.activate_group(:chat, :greeters) + rollout.activate(:signup) + rollout.activate_user(:chat, double(id: 42)) + end + + it "returns the feature object" do + feature = rollout.get(:chat) + expect(feature.groups).to eq [:caretakers, :greeters] + expect(feature.percentage).to eq 10 + expect(feature.users).to eq %w(42) + expect(feature.to_hash).to eq( + groups: [:caretakers, :greeters], + percentage: 10, + users: %w(42), + data: {}, + ) + + feature = rollout.get(:signup) + expect(feature.groups).to be_empty + expect(feature.users).to be_empty + expect(feature.percentage).to eq(100) + end + + it "preserves the requested name type" do + expect(rollout.get("chat").name).to eq "chat" + expect(rollout.get(:chat).name).to eq :chat + end + + it "returns the feature objects using sets" do + @options = rollout.instance_variable_get("@options") + @options[:use_sets] = true + + feature = rollout.get(:chat) + expect(feature.groups).to eq [:caretakers, :greeters].to_set + expect(feature.percentage).to eq 10 + expect(feature.users).to eq %w(42).to_set + expect(feature.to_hash).to eq( + groups: [:caretakers, :greeters].to_set, + percentage: 10, + users: %w(42).to_set, + data: {}, + ) + + feature = rollout.get(:signup) + expect(feature.groups).to be_empty + expect(feature.users).to be_empty + expect(feature.percentage).to eq(100) + end + end + + describe "#clear" do + let(:features) { %w(signup beta alpha gm) } + + before do + features.each { |f| rollout.activate(f) } + + rollout.clear! + end + + it "each feature is cleared" do + features.each do |feature| + expect(rollout.get(feature).to_hash).to eq( + percentage: 0, + users: [], + groups: [], + data: {}, + ) + end + end + + it "each feature is cleared with sets" do + @options = rollout.instance_variable_get("@options") + @options[:use_sets] = true + features.each do |feature| + expect(rollout.get(feature).to_hash).to eq( + percentage: 0, + users: Set.new, + groups: Set.new, + data: {}, + ) + end + end + + it "removes all features" do + expect(rollout.features).to be_empty + end + end + + describe "#feature_states" do + let(:user_double) { double(id: 7) } + + before do + rollout.activate(:chat) + rollout.activate_user(:video, user_double) + rollout.deactivate(:vr) + end + + it "returns a hash" do + expect(rollout.feature_states).to be_a(Hash) + end + + context "with user argument" do + it "maps active feature as true" do + state = rollout.feature_states(user_double)[:video] + expect(state).to eq(true) + end + + it "maps inactive feature as false" do + state = rollout.feature_states[:vr] + expect(state).to eq(false) + end + end + + context "with no argument" do + it "maps active feature as true" do + state = rollout.feature_states[:chat] + expect(state).to eq(true) + end + + it "maps inactive feature as false" do + state = rollout.feature_states[:video] + expect(state).to eq(false) + end + end + end + + describe "#active_features" do + let(:user_double) { double(id: 19) } + + before do + rollout.activate(:chat) + rollout.activate_user(:video, user_double) + rollout.deactivate(:vr) + end + + it "returns an array" do + expect(rollout.active_features).to be_a(Array) + end + + context "with user argument" do + it "includes active feature" do + features = rollout.active_features(user_double) + expect(features).to include(:video) + expect(features).to include(:chat) + end + + it "excludes inactive feature" do + features = rollout.active_features(user_double) + expect(features).to_not include(:vr) + end + end + + context "with no argument" do + it "includes active feature" do + features = rollout.active_features + expect(features).to include(:chat) + end + + it "excludes inactive feature" do + features = rollout.active_features + expect(features).to_not include(:video) + end + end + end + + describe "#user_in_active_users?" do + it "returns true if activated for user" do + rollout.activate_user(:chat, double(id: 5)) + expect(rollout.user_in_active_users?(:chat, "5")).to eq(true) + end + + it "returns false if activated for group" do + rollout.activate_group(:chat, :all) + expect(rollout.user_in_active_users?(:chat, "5")).to eq(false) + end + end + + describe "#multi_get" do + before do + rollout.activate_percentage(:chat, 10) + rollout.activate_group(:chat, :caretakers) + rollout.activate_group(:videos, :greeters) + rollout.activate(:signup) + rollout.activate_user(:photos, double(id: 42)) + end + + it "returns an array of features" do + features = rollout.multi_get(:chat, :videos, :signup) + expect(features[0].name).to eq :chat + expect(features[0].groups).to eq [:caretakers] + expect(features[0].percentage).to eq 10 + expect(features[1].name).to eq :videos + expect(features[1].groups).to eq [:greeters] + expect(features[2].name).to eq :signup + expect(features[2].percentage).to eq 100 + expect(features.size).to eq 3 + end + + it "preserves the requested name types" do + expect(rollout.multi_get("chat", :videos).map(&:name)).to eq ["chat", :videos] + end + + describe 'when given feature keys is empty' do + it 'returns empty array' do + expect(rollout.multi_get(*[])).to match_array([]) + end + end + end + + describe "#set_feature_data" do + before do + rollout.set_feature_data(:chat, description: 'foo', release_date: 'bar') + end + + it 'sets the data attribute on feature' do + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + end + + it 'updates a data attribute' do + rollout.set_feature_data(:chat, description: 'baz') + expect(rollout.get(:chat).data).to include('description' => 'baz', 'release_date' => 'bar') + end + + it 'only sets data on specified feature' do + rollout.set_feature_data(:talk, image_url: 'kittens.png') + expect(rollout.get(:chat).data).not_to include('image_url' => 'kittens.png') + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + end + + it 'does not modify @data if param is nil' do + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + rollout.set_feature_data(:chat, nil) + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + end + + it 'does not modify @data if param is empty string' do + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + rollout.set_feature_data(:chat, " ") + expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + end + + it 'properly parses data when it contains a |' do + user = double("User", id: 8) + rollout.activate_user(:chat, user) + rollout.set_feature_data(:chat, "|call||text|" => "a|bunch|of|stuff") + expect(rollout.get(:chat).data).to include("|call||text|" => "a|bunch|of|stuff") + expect(rollout.active?(:chat, user)).to be true + end + end + + describe "#clear_feature_data" do + it 'resets data to empty string' do + rollout.set_feature_data(:chat, description: 'foo') + expect(rollout.get(:chat).data).to include('description' => 'foo') + rollout.clear_feature_data(:chat) + expect(rollout.get(:chat).data).to eq({}) + end + end + + describe "persisted redis format" do + it "writes the current feature payload and registry keys" do + rollout.activate_percentage(:chat, 20) + rollout.activate_user(:chat, 42) + rollout.activate_group(:chat, :employees) + rollout.set_feature_data(:chat, description: "foo") + + expect($redis.get("feature:chat")).to eq('20.0|42|employees|{"description":"foo"}') + expect($redis.get("feature:__features__")).to eq("chat") + end + + it "reads an existing payload without rewriting it" do + $redis.set("feature:chat", '10.5|7,8|greeters|{"description":"legacy"}') + $redis.set("feature:__features__", "chat") + + feature = rollout.get(:chat) + + expect(feature.percentage).to eq 10.5 + expect(feature.users).to eq %w[7 8] + expect(feature.groups).to eq [:greeters] + expect(feature.data).to eq("description" => "legacy") + expect($redis.get("feature:chat")).to eq('10.5|7,8|greeters|{"description":"legacy"}') + end + end + + describe "mutation semantics" do + it "saves multiple with_feature edits together" do + rollout.with_feature(:chat) do |feature| + feature.percentage = 25.0 + feature.groups = [:employees] + feature.users = ["123"] + feature.data.update(description: "New navigation") + end + + feature = rollout.get(:chat) + expect(feature.percentage).to eq 25.0 + expect(feature.groups).to eq [:employees] + expect(feature.users).to eq %w[123] + expect(feature.data).to eq("description" => "New navigation") + end + + it "does not save when the with_feature block raises" do + expect do + rollout.with_feature(:chat) do |feature| + feature.percentage = 100 + raise "boom" + end + end.to raise_error("boom") + + expect(rollout.get(:chat).percentage).to eq 0 + expect(rollout.exists?(:chat)).to eq false + end + + it "clears users, groups, percentage, and data on deactivate" do + rollout.activate_user(:chat, 42) + rollout.activate_group(:chat, :employees) + rollout.activate_percentage(:chat, 50) + rollout.set_feature_data(:chat, description: "foo") + + rollout.deactivate(:chat) + + expect(rollout.features).to eq [:chat] + expect(rollout.get(:chat).to_hash).to eq( + percentage: 0, + users: [], + groups: [], + data: {}, + ) + expect($redis.get("feature:chat")).to eq("0.0|||{}") + end + + it "keeps users, groups, and data on deactivate_percentage" do + rollout.activate_user(:chat, 42) + rollout.activate_group(:chat, :employees) + rollout.activate_percentage(:chat, 50) + rollout.set_feature_data(:chat, description: "foo") + + rollout.deactivate_percentage(:chat) + + expect(rollout.get(:chat).percentage).to eq 0 + expect(rollout.get(:chat).users).to eq %w[42] + expect(rollout.get(:chat).groups).to eq [:employees] + expect(rollout.get(:chat).data).to eq("description" => "foo") + end + + it "removes the feature on delete and leaves a missing feature inactive" do + rollout.activate(:chat) + rollout.delete(:chat) + + expect(rollout.features).to eq [] + expect(rollout.exists?(:chat)).to eq false + expect(rollout.get(:chat).percentage).to eq 0 + expect(rollout.active?(:chat)).to eq false + end + end + + describe 'Check if feature exists' do + it 'it should return true if the feature is exist' do + rollout.activate_percentage(:chat, 1) + expect(rollout.exists?(:chat)).to be true + end + + it 'it should return false if the feature is not exist' do + expect(rollout.exists?(:chat)).to be false + end + end +end diff --git a/rollout-redis/spec/spec_helper.rb b/rollout-redis/spec/spec_helper.rb new file mode 100644 index 0000000..d65ceae --- /dev/null +++ b/rollout-redis/spec/spec_helper.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'simplecov' + +SimpleCov.start + +require 'bundler/setup' +require 'redis' +require 'rollout' +require 'rollout/redis' + +$redis = Redis.new( + host: ENV.fetch('REDIS_HOST', '127.0.0.1'), + port: ENV.fetch('REDIS_PORT', '6379'), + db: ENV.fetch('REDIS_DB', '7'), +) + +def redis_backend + Rollout::Redis::Backend.new($redis) +end + +RSpec.configure do |config| + config.example_status_persistence_file_path = '.rspec_status' + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + config.before do |example| + next if example.metadata[:file_path].end_with?("codec_spec.rb") + + $redis.flushdb + end +end diff --git a/rollout.gemspec b/rollout.gemspec index 07e2bfb..0a6da12 100644 --- a/rollout.gemspec +++ b/rollout.gemspec @@ -8,12 +8,14 @@ Gem::Specification.new do |spec| spec.version = Rollout::VERSION spec.authors = ['James Golick'] spec.email = ['jamesgolick@gmail.com'] - spec.description = 'Feature flippers with redis.' - spec.summary = 'Feature flippers with redis.' + spec.description = 'Feature flippers.' + spec.summary = 'Feature flippers.' spec.homepage = 'https://github.com/FetLife/rollout' spec.license = 'MIT' - spec.files = `git ls-files`.split("\n") + spec.files = `git ls-files`.split("\n").reject do |file| + file.start_with?('rollout-redis/') + end spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } spec.require_paths = ['lib'] @@ -21,7 +23,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.3' spec.add_dependency 'observer' - spec.add_dependency 'redis', '>= 4.0', '< 6' spec.add_development_dependency 'rake' spec.add_development_dependency 'bundler', '>= 1.17' diff --git a/spec/rollout/feature_spec.rb b/spec/rollout/feature_spec.rb index ae900c7..ae14c0a 100644 --- a/spec/rollout/feature_spec.rb +++ b/spec/rollout/feature_spec.rb @@ -1,19 +1,25 @@ require "spec_helper" describe "Rollout::Feature" do - let(:rollout) { Rollout.new($redis) } - - def feature_for(state, options: {}) - Rollout::Feature.new(state: state, rollout: rollout, options: options) + def build_feature(name: :chat, percentage: 0, users: [], groups: [], data: {}, options: {}, rollout: nil) + rollout ||= Object.new + Rollout::Feature.new( + state: Rollout::FeatureState.new( + name: name, + percentage: percentage, + users: users, + groups: groups, + data: data, + ), + rollout: rollout, + options: options, + ) end describe "#add_user" do it "ids a user using id_user_by" do user = double("User", email: "test@test.com") - feature = feature_for( - Rollout::RedisCodec.decode(:chat, nil), - options: { id_user_by: :email }, - ) + feature = build_feature(options: { id_user_by: :email }) feature.add_user(user) expect(user).to have_received :email end @@ -21,15 +27,13 @@ def feature_for(state, options: {}) describe "#initialize" do it "uses the state's name" do - feature = feature_for(Rollout::FeatureState.new(name: :video, percentage: 0)) - - expect(feature.name).to eq :video + expect(build_feature(name: :video).name).to eq :video end it "preserves an explicit public name" do feature = Rollout::Feature.new( state: Rollout::FeatureState.new(name: :chat, percentage: 0), - rollout: rollout, + rollout: Object.new, name: "chat", ) feature.percentage = 50 @@ -40,7 +44,7 @@ def feature_for(state, options: {}) end it "clears feature attributes for an empty state" do - feature = feature_for(Rollout::RedisCodec.decode(:chat, nil)) + feature = build_feature expect(feature.groups).to be_empty expect(feature.users).to be_empty @@ -48,4 +52,34 @@ def feature_for(state, options: {}) expect(feature.data).to eq({}) end end + + describe "percentage assignment" do + it "keeps the same users across features when randomize_percentage is off" do + chat = build_feature(name: :chat, percentage: 20) + beta = build_feature(name: :beta, percentage: 20) + + expect(chat.active?(double(id: 2))).to eq true + expect(chat.active?(double(id: 6))).to eq true + expect(chat.active?(double(id: 1))).to eq false + expect(beta.active?(double(id: 2))).to eq true + expect(beta.active?(double(id: 1))).to eq false + end + + it "changes assignment by feature name when randomize_percentage is on" do + chat = build_feature(name: :chat, percentage: 20, options: { randomize_percentage: true }) + beta = build_feature(name: :beta, percentage: 20, options: { randomize_percentage: true }) + + expect(chat.active?(double(id: 1))).to eq true + expect(beta.active?(double(id: 1))).to eq false + expect(chat.active?(double(id: 5))).to eq false + expect(beta.active?(double(id: 5))).to eq true + end + end + + it "does not expose assign_state" do + feature = build_feature + + expect(feature).not_to respond_to(:assign_state) + expect(feature.private_methods).to include(:assign_state) + end end diff --git a/spec/rollout/feature_state_spec.rb b/spec/rollout/feature_state_spec.rb index 724314d..d651594 100644 --- a/spec/rollout/feature_state_spec.rb +++ b/spec/rollout/feature_state_spec.rb @@ -1,8 +1,6 @@ require "spec_helper" RSpec.describe Rollout::FeatureState do - let(:rollout) { Rollout.new($redis) } - def build_state(overrides = {}) described_class.new(**{ name: :chat, @@ -13,7 +11,7 @@ def build_state(overrides = {}) }.merge(overrides)) end - def feature_for(state, options: rollout.options) + def feature_for(state, options: {}, rollout: Object.new) Rollout::Feature.new(state: state, rollout: rollout, options: options) end @@ -95,21 +93,20 @@ def feature_for(state, options: rollout.options) describe "Feature conversion" do it "round-trips evaluation and metadata" do - rollout.define_group(:employees) { |user| user.id == 1 } - rollout.activate_percentage(:chat, 20) - rollout.activate_user(:chat, 42) - rollout.activate_group(:chat, :employees) - rollout.set_feature_data(:chat, description: "New navigation") - - feature = rollout.get(:chat) - state = feature.to_feature_state - restored = feature_for(state) - - expect(state.name).to eq "chat" - expect(state.percentage).to eq 20.0 - expect(state.users).to eq %w[42] - expect(state.groups).to eq %w[employees] - expect(state.data).to eq("description" => "New navigation") + rollout = Object.new + def rollout.active_in_group?(group, user) + group == :employees && user.id == 1 + end + + state = build_state(percentage: 20, users: ["42"], groups: ["employees"], data: { "description" => "New navigation" }) + feature = feature_for(state, rollout: rollout) + restored = feature_for(feature.to_feature_state, rollout: rollout) + + expect(feature.to_feature_state.name).to eq "chat" + expect(feature.to_feature_state.percentage).to eq 20.0 + expect(feature.to_feature_state.users).to eq %w[42] + expect(feature.to_feature_state.groups).to eq %w[employees] + expect(feature.to_feature_state.data).to eq("description" => "New navigation") expect(restored.name).to eq :chat expect(restored.active?(double(id: 1))).to eq feature.active?(double(id: 1)) @@ -118,13 +115,6 @@ def feature_for(state, options: rollout.options) expect(restored.to_hash).to eq feature.to_hash end - it "does not expose assign_state" do - feature = rollout.get(:chat) - - expect(feature).not_to respond_to(:assign_state) - expect(feature.private_methods).to include(:assign_state) - end - it "rejects unsupported metadata when converting a Feature" do feature = feature_for(build_state) feature.data["released_at"] = Time.utc(2026, 1, 1) @@ -135,8 +125,7 @@ def feature_for(state, options: rollout.options) end it "does not share nested data with the source feature" do - rollout.set_feature_data(:chat, labels: ["a"]) - feature = rollout.get(:chat) + feature = feature_for(build_state(data: { "labels" => ["a"] })) state = feature.to_feature_state feature.data["labels"] << "b" @@ -165,15 +154,12 @@ def feature_for(state, options: rollout.options) end it "preserves randomized percentage evaluation through a round trip" do - randomized = Rollout.new($redis, randomize_percentage: true) - randomized.activate_percentage(:chat, 20) - - feature = randomized.get(:chat) - restored = Rollout::Feature.new( - state: feature.to_feature_state, - rollout: randomized, - options: randomized.options, + options = { randomize_percentage: true } + feature = feature_for( + Rollout::FeatureState.new(name: :chat, percentage: 20), + options: options, ) + restored = feature_for(feature.to_feature_state, options: options) expect(restored.active?(double(id: 1))).to eq true expect(restored.active?(double(id: 2))).to eq false diff --git a/spec/rollout/logging_spec.rb b/spec/rollout/logging_spec.rb index 01e72d0..325fa06 100644 --- a/spec/rollout/logging_spec.rb +++ b/spec/rollout/logging_spec.rb @@ -1,215 +1,113 @@ -require 'spec_helper' - -RSpec.describe 'Rollout::Logging' do - let(:rollout) { Rollout.new($redis, logging: logging) } - let(:logging) { true } - let(:feature) { :foo } - - it 'logs changes' do - expect(rollout.logging.last_event(feature)).to be_nil - - rollout.activate_percentage(feature, 50) - - expect(rollout.logging.updated_at(feature)).to_not be_nil - - first_event = rollout.logging.last_event(feature) - - expect(first_event.name).to eq 'update' - expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 }) - - rollout.activate_percentage(feature, 75) - - second_event = rollout.logging.last_event(feature) - - expect(second_event.name).to eq 'update' - expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 }) - - rollout.activate_group(feature, :hipsters) - - third_event = rollout.logging.last_event(feature) - - expect(third_event.name).to eq 'update' - expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ['hipsters'] }) - - expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event] +require "spec_helper" + +RSpec.describe "Rollout::Logging" do + def build_feature(percentage: 0, groups: [], users: [], data: {}) + Rollout::Feature.new( + state: Rollout::FeatureState.new( + name: :foo, + percentage: percentage, + users: users, + groups: groups, + data: data, + ), + rollout: Object.new, + options: {}, + ) end - context 'logging data changes' do - it 'logs changes' do - expect(rollout.logging.last_event(feature)).to be_nil + let(:logger) { Rollout::Logging::Logger.new(backend: Object.new) } - rollout.set_feature_data(feature, description: "foo") + it "does not respond to logging unless enabled" do + rollout = Rollout.new(backend: Object.new) - event = rollout.logging.last_event(feature) - - expect(event).not_to be_nil - expect(event.name).to eq 'update' - expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" }) - end + expect(rollout).not_to respond_to :logging end - context 'no logging' do - let(:logging) { nil } + it "builds an event for percentage changes" do + event = logger.event_for(build_feature, build_feature(percentage: 50)) - it 'doesnt even respond to logging' do - expect(rollout).not_to respond_to :logging - end + expect(event.name).to eq :update + expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 }) end - context 'history truncation' do - let(:logging) { { history_length: 1 } } - - it 'logs changes' do - expect(rollout.logging.last_event(feature)).to be_nil - - rollout.activate_percentage(feature, 25) - - first_event = rollout.logging.last_event(feature) - - expect(first_event.name).to eq 'update' - expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 }) + it "builds an event for metadata changes" do + event = logger.event_for( + build_feature, + build_feature(data: { "description" => "foo" }), + ) - rollout.activate_percentage(feature, 30) - - second_event = rollout.logging.last_event(feature) - - expect(second_event.name).to eq 'update' - expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 }) - - expect(rollout.logging.events(feature)).to eq [second_event] - end + expect(event.data).to eq(before: { "data.description" => nil }, after: { "data.description" => "foo" }) end - context 'with context' do - let(:current_user) { double(nickname: 'lester') } - - it 'adds context to the event' do - rollout.logging.with_context(actor: current_user.nickname) do - rollout.activate_percentage(feature, 25) - end - - event = rollout.logging.last_event(feature) + it "does not build an event when nothing changes" do + feature = build_feature(percentage: 25) - expect(event.name).to eq 'update' - expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 }) - expect(event.context).to eq(actor: current_user.nickname) - end + expect(logger.event_for(feature, feature)).to be_nil end - context 'global logs' do - let(:logging) { { global: true } } - let(:feature_foo) { 'foo' } - let(:feature_bar) { 'bar' } - - it 'logs changes' do - expect(rollout.logging.last_event(feature_foo)).to be_nil - - rollout.activate_percentage(feature_foo, 25) - - event_foo = rollout.logging.last_event(feature_foo) - - expect(event_foo.feature).to eq feature_foo - expect(event_foo.name).to eq 'update' - expect(event_foo.data).to eq(before: { percentage: 0 }, after: { percentage: 25 }) - - expect(rollout.logging.events(feature_foo)).to eq [event_foo] - - rollout.activate_percentage(feature_bar, 30) - - event_bar = rollout.logging.last_event(feature_bar) - - expect(event_bar.feature).to eq feature_bar - expect(event_bar.name).to eq 'update' - expect(event_bar.data).to eq(before: { percentage: 0 }, after: { percentage: 30 }) - - expect(rollout.logging.events(feature_bar)).to eq [event_bar] - - expect(rollout.logging.global_events).to eq [event_foo, event_bar] + it "adds context to the event" do + event = nil + logger.with_context(actor: "lester") do + event = logger.event_for(build_feature, build_feature(percentage: 25)) end - end - - context 'no logging for block' do - it 'doesnt log' do - rollout.logging.without do - rollout.activate_percentage(feature, 25) - end - event = rollout.logging.last_event(feature) + expect(event.context).to eq(actor: "lester") + end - expect(event).to be_nil + it "does not build an event when logging is disabled" do + event = logger.without do + logger.event_for(build_feature, build_feature(percentage: 25)) end + + expect(event).to be_nil end - context 'no-op mutations' do - it 'does not write a history event when nothing changes' do - rollout.activate_percentage(feature, 25) + it "restores nested without state" do + nested_enabled = nil + outer_enabled = nil - expect do - rollout.activate_percentage(feature, 25) - end.not_to change { rollout.logging.events(feature).count } + logger.without do + logger.without {} + nested_enabled = logger.logging_enabled? end - end + outer_enabled = logger.logging_enabled? - context 'multi-field edits' do - it 'records one event for a with_feature block' do - rollout.logging.with_context(actor: 'alice') do - rollout.with_feature(feature) do |current| - current.percentage = 25.0 - current.groups = [:employees] - current.users = ['123'] - current.data.update(description: 'New navigation') - end - end - - events = rollout.logging.events(feature) - expect(events.count).to eq 1 - expect(events.first.context).to eq(actor: 'alice') - expect(events.first.data[:before].keys).to contain_exactly(:percentage, :groups, :users, :"data.description") - expect(events.first.data[:after]).to include( - percentage: 25.0, - groups: ['employees'], - users: ['123'], - "data.description": 'New navigation', - ) - end + expect(nested_enabled).to eq false + expect(outer_enabled).to eq true end - context 'delete versus clear' do - it 'removes feature history on delete' do - rollout.activate_percentage(feature, 25) - expect(rollout.logging.events(feature)).not_to be_empty - - rollout.delete(feature) - - expect(rollout.logging.events(feature)).to eq [] - end + it "forwards a history limit to the backend" do + backend = double("backend") + logger = Rollout::Logging::Logger.new(backend: backend) + events = [Object.new] - it 'keeps feature history on clear!' do - rollout.activate_percentage(feature, 25) + expect(backend).to receive(:feature_events).with(:chat, limit: 2).and_return(events) + expect(logger.events(:chat, limit: 2)).to eq events + end - rollout.clear! + it "requests one event for last_event" do + backend = double("backend") + logger = Rollout::Logging::Logger.new(backend: backend) + event = Object.new - expect(rollout.features).to eq [] - expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [25, 0] - end + expect(backend).to receive(:feature_events).with(:chat, limit: 1).and_return([event]) + expect(logger.last_event(:chat)).to eq event end - context 'persisted history keys' do - let(:logging) { { history_length: 2, global: true } } + it "forwards a global history limit to the backend" do + backend = double("backend") + logger = Rollout::Logging::Logger.new(backend: backend) + events = [Object.new] - it 'writes truncated per-feature and global sorted sets' do - rollout.activate_percentage(feature, 25) - rollout.activate_percentage(feature, 50) - rollout.activate_percentage(feature, 75) + expect(backend).to receive(:global_events).with(limit: 2).and_return(events) + expect(logger.global_events(limit: 2)).to eq events + end - feature_key = "feature:#{feature}:logging:events" - global_key = "feature:_global_:logging:events" + it "delegates history deletion" do + backend = double("backend") + logger = Rollout::Logging::Logger.new(backend: backend) - expect($redis.zcard(feature_key)).to eq 2 - expect($redis.zcard(global_key)).to eq 2 - expect(rollout.logging.events(feature).map { |event| event.data[:after][:percentage] }).to eq [50, 75] - expect(rollout.logging.global_events.map { |event| event.data[:after][:percentage] }).to eq [50, 75] - end + expect(backend).to receive(:delete_feature_events).with(:chat) + logger.delete(:chat) end end - diff --git a/spec/rollout_spec.rb b/spec/rollout_spec.rb index 247237c..e8442f2 100644 --- a/spec/rollout_spec.rb +++ b/spec/rollout_spec.rb @@ -1,806 +1,114 @@ require "spec_helper" -RSpec.describe "Rollout" do - let(:rollout) { Rollout.new($redis) } - - describe "when a group is activated" do - before do - rollout.define_group(:fivesonly) { |user| user.id == 5 } - rollout.activate_group(:chat, :fivesonly) - end - - it "the feature is active for users for which the block evaluates to true" do - expect(rollout).to be_active(:chat, double(id: 5)) - end - - it "is not active for users for which the block evaluates to false" do - expect(rollout).not_to be_active(:chat, double(id: 1)) - end - - it "is not active if a group is found in Redis but not defined in Rollout" do - rollout.activate_group(:chat, :fake) - expect(rollout).not_to be_active(:chat, double(id: 1)) - end - end - - describe "the default all group" do - before do - rollout.activate_group(:chat, :all) - end - - it "evaluates to true no matter what" do - expect(rollout).to be_active(:chat, double(id: 0)) - end - end - - describe "deactivating a group" do - before do - rollout.define_group(:fivesonly) { |user| user.id == 5 } - rollout.activate_group(:chat, :all) - rollout.activate_group(:chat, :some) - rollout.activate_group(:chat, :fivesonly) - rollout.deactivate_group(:chat, :all) - rollout.deactivate_group(:chat, "some") - end - - it "deactivates the rules for that group" do - expect(rollout).not_to be_active(:chat, double(id: 10)) - end - - it "leaves the other groups active" do - expect(rollout.get(:chat).groups).to eq [:fivesonly] - end - - it "leaves the other groups active using sets" do - @options = rollout.instance_variable_get("@options") - @options[:use_sets] = true - expect(rollout.get(:chat).groups).to eq [:fivesonly].to_set - end - end - - describe "deactivating a feature completely" do - before do - rollout.define_group(:fivesonly) { |user| user.id == 5 } - rollout.activate_group(:chat, :all) - rollout.activate_group(:chat, :fivesonly) - rollout.activate_user(:chat, double(id: 51)) - rollout.activate_percentage(:chat, 100) - rollout.activate(:chat) - rollout.deactivate(:chat) - end - - it "removes all of the groups" do - expect(rollout).not_to be_active(:chat, double(id: 0)) - end - - it "removes all of the users" do - expect(rollout).not_to be_active(:chat, double(id: 51)) - end - - it "removes the percentage" do - expect(rollout).not_to be_active(:chat, double(id: 24)) - end - - it "removes globally" do - expect(rollout).not_to be_active(:chat) - end - end - - describe "activating a specific user" do - before do - rollout.activate_user(:chat, double(id: 42)) - end - - it "is active for that user" do - expect(rollout).to be_active(:chat, double(id: 42)) - end - - it "remains inactive for other users" do - expect(rollout).not_to be_active(:chat, double(id: 24)) - end - end - - describe "activating a specific user by ID" do - before do - rollout.activate_user(:chat, 42) - end - - it "is active for that user" do - expect(rollout).to be_active(:chat, double(id: 42)) - end - - it "remains inactive for other users" do - expect(rollout).not_to be_active(:chat, double(id: 24)) - end - end - - describe "activating a specific user with a string id" do - before do - rollout.activate_user(:chat, double(id: "user-72")) - end - - it "is active for that user" do - expect(rollout).to be_active(:chat, double(id: "user-72")) - end - - it "remains inactive for other users" do - expect(rollout).not_to be_active(:chat, double(id: "user-12")) - end - end - - describe "activating a group of users" do - context "specified by user objects" do - let(:users) { [double(id: 1), double(id: 2), double(id: 3)] } - - before { rollout.activate_users(:chat, users) } - - it "is active for the given users" do - users.each { |user| expect(rollout).to be_active(:chat, user) } - end - - it "remains inactive for other users" do - expect(rollout).not_to be_active(:chat, double(id: 4)) - end - end - - context "specified by user ids" do - let(:users) { [1, 2, 3] } - - before { rollout.activate_users(:chat, users) } - - it "is active for the given users" do - users.each { |user| expect(rollout).to be_active(:chat, user) } - end - - it "remains inactive for other users" do - expect(rollout).not_to be_active(:chat, 4) - end - end - end - - describe "deactivating a specific user" do - before do - rollout.activate_user(:chat, double(id: 42)) - rollout.activate_user(:chat, double(id: 4242)) - rollout.activate_user(:chat, double(id: 24)) - rollout.deactivate_user(:chat, double(id: 42)) - rollout.deactivate_user(:chat, double(id: "4242")) - end - - it "that user should no longer be active" do - expect(rollout).not_to be_active(:chat, double(id: 42)) - end - - it "remains active for other active users" do - @options = rollout.instance_variable_get("@options") - @options[:use_sets] = false - expect(rollout.get(:chat).users).to eq %w(24) - end - - it "remains active for other active users using sets" do - @options = rollout.instance_variable_get("@options") - @options[:use_sets] = true - - expect(rollout.get(:chat).users).to eq %w(24).to_set - end - end - - describe "deactivating a group of users" do - context "specified by user objects" do - let(:active_users) { [double(id: 1), double(id: 2)] } - let(:inactive_users) { [double(id: 3), double(id: 4)] } - - before do - rollout.activate_users(:chat, active_users + inactive_users) - rollout.deactivate_users(:chat, inactive_users) - end - - it "is active for the active users" do - active_users.each { |user| expect(rollout).to be_active(:chat, user) } - end - - it "is not active for inactive users" do - inactive_users.each { |user| expect(rollout).not_to be_active(:chat, user) } - end - end - - context "specified by user ids" do - let(:active_users) { [1, 2] } - let(:inactive_users) { [3, 4] } - - before do - rollout.activate_users(:chat, active_users + inactive_users) - rollout.deactivate_users(:chat, inactive_users) - end - - it "is active for the active users" do - active_users.each { |user| expect(rollout).to be_active(:chat, user) } - end - - it "is not active for inactive users" do - inactive_users.each { |user| expect(rollout).not_to be_active(:chat, user) } - end - end - end - - - describe 'set a group of users' do - it 'should replace the users with the given array' do - users = %w(1 2 3 4) - rollout.activate_users(:chat, %w(10 20 30)) - rollout.set_users(:chat, users) - expect(rollout.get(:chat).users).to eq(users) - end - end - - describe "activating a feature globally" do - before do - rollout.activate(:chat) - end - - it "activates the feature" do - expect(rollout).to be_active(:chat) - end - - it "sets @data to empty hash" do - expect(rollout.get(:chat).data).to eq({}) - end - end - - describe "activating a feature for a percentage of users" do - before do - rollout.activate_percentage(:chat, 20) - end - - it "activates the feature for that percentage of the users" do - expect((1..100).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(20) - end - end - - describe "activating a feature for a percentage of users" do - before do - rollout.activate_percentage(:chat, 20) - end - - it "activates the feature for that percentage of the users" do - expect((1..200).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(4).of(40) - end - end - - describe "activating a feature for a percentage of users" do - before do - rollout.activate_percentage(:chat, 5) - end - - it "activates the feature for that percentage of the users" do - expect((1..100).select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(5) - end - end - - describe "activating a feature for a percentage of users" do - before do - rollout.activate_percentage(:chat, 0.1) - end - - it "activates the feature for that percentage of the users" do - expect((1..10_000).to_set.select { |id| rollout.active?(:chat, double(id: id)) }.length).to be_within(2).of(10) - end - end - - describe "activating a feature for a percentage of users" do - before do - rollout.activate_percentage(:chat, 20) - rollout.activate_percentage(:beta, 20) - @options = rollout.instance_variable_get("@options") - end - - it "activates the feature for a random set of users when opt is set" do - @options[:randomize_percentage] = true - chat_users = (1..100).select { |id| rollout.active?(:chat, double(id: id)) } - beta_users = (1..100).select { |id| rollout.active?(:beta, double(id: id)) } - expect(chat_users).not_to eq beta_users - end - it "activates the feature for the same set of users when opt is not set" do - @options[:randomize_percentage] = false - chat_users = (1..100).select { |id| rollout.active?(:chat, double(id: id)) } - beta_users = (1..100).select { |id| rollout.active?(:beta, double(id: id)) } - expect(chat_users).to eq beta_users - end - end - - describe "exact CRC32 percentage assignment" do - it "keeps the same users across features when randomize_percentage is off" do - rollout.activate_percentage(:chat, 20) - rollout.activate_percentage(:beta, 20) - - expect(rollout.active?(:chat, double(id: 2))).to eq true - expect(rollout.active?(:chat, double(id: 6))).to eq true - expect(rollout.active?(:chat, double(id: 1))).to eq false - expect(rollout.active?(:beta, double(id: 2))).to eq true - expect(rollout.active?(:beta, double(id: 1))).to eq false - end - - it "changes assignment by feature name when randomize_percentage is on" do - randomized = Rollout.new($redis, randomize_percentage: true) - randomized.activate_percentage(:chat, 20) - randomized.activate_percentage(:beta, 20) - - expect(randomized.active?(:chat, double(id: 1))).to eq true - expect(randomized.active?(:beta, double(id: 1))).to eq false - expect(randomized.active?(:chat, double(id: 5))).to eq false - expect(randomized.active?(:beta, double(id: 5))).to eq true - end - end - - describe "activating a feature for a group as a string" do - before do - rollout.define_group(:admins) { |user| user.id == 5 } - rollout.activate_group(:chat, "admins") - end - - it "the feature is active for users for which the block evaluates to true" do - expect(rollout).to be_active(:chat, double(id: 5)) - end - - it "is not active for users for which the block evaluates to false" do - expect(rollout).not_to be_active(:chat, double(id: 1)) - end - end - - describe "deactivating the percentage of users" do - before do - rollout.activate_percentage(:chat, 100) - rollout.deactivate_percentage(:chat) - end - - it "becomes inactivate for all users" do - expect(rollout).not_to be_active(:chat, double(id: 24)) - end - end - - describe "deactivating the feature globally" do - before do - rollout.activate(:chat) - rollout.deactivate(:chat) - end - - it "becomes inactivate" do - expect(rollout).not_to be_active(:chat) - end - end - - describe "setting a feature on" do - before do - rollout.set(:chat, true) - end - - it "becomes activated" do - expect(rollout).to be_active(:chat) - end - end - - describe "setting a feature off" do - before do - rollout.set(:chat, false) - end - - it "becomes inactivated" do - expect(rollout).not_to be_active(:chat) - end - end - - describe "deleting a feature" do - before do - rollout.set(:chat, true) - end - - context "when feature was passed as string" do - it "should be removed from features list" do - expect(rollout.features.size).to eq 1 - rollout.delete('chat') - expect(rollout.features.size).to eq 0 - end - end - - it "should be removed from features list" do - expect(rollout.features.size).to eq 1 - rollout.delete(:chat) - expect(rollout.features.size).to eq 0 - end - - it "should have metadata cleared" do - expect(rollout.get(:chat).percentage).to eq 100 - rollout.delete(:chat) - expect(rollout.get(:chat).percentage).to eq 0 - end - end - - describe "keeps a list of features" do - it "saves the feature" do - rollout.activate(:chat) - expect(rollout.features).to be_include(:chat) - end - - it "does not contain doubles" do - rollout.activate(:chat) - rollout.activate(:chat) - expect(rollout.features.size).to eq(1) - end - - it "does not contain doubles when using string" do - rollout.activate(:chat) - rollout.activate("chat") - expect(rollout.features.size).to eq(1) - end - end +RSpec.describe Rollout do + let(:rollout) { described_class.new(backend: RolloutMemoryBackend.new) } describe "#get" do - before do - rollout.activate_percentage(:chat, 10) - rollout.activate_group(:chat, :caretakers) - rollout.activate_group(:chat, :greeters) - rollout.activate(:signup) - rollout.activate_user(:chat, double(id: 42)) - end - - it "returns the feature object" do - feature = rollout.get(:chat) - expect(feature.groups).to eq [:caretakers, :greeters] - expect(feature.percentage).to eq 10 - expect(feature.users).to eq %w(42) - expect(feature.to_hash).to eq( - groups: [:caretakers, :greeters], - percentage: 10, - users: %w(42), - data: {}, - ) - - feature = rollout.get(:signup) - expect(feature.groups).to be_empty - expect(feature.users).to be_empty - expect(feature.percentage).to eq(100) - end - it "preserves the requested name type" do expect(rollout.get("chat").name).to eq "chat" expect(rollout.get(:chat).name).to eq :chat end - - it "returns the feature objects using sets" do - @options = rollout.instance_variable_get("@options") - @options[:use_sets] = true - - feature = rollout.get(:chat) - expect(feature.groups).to eq [:caretakers, :greeters].to_set - expect(feature.percentage).to eq 10 - expect(feature.users).to eq %w(42).to_set - expect(feature.to_hash).to eq( - groups: [:caretakers, :greeters].to_set, - percentage: 10, - users: %w(42).to_set, - data: {}, - ) - - feature = rollout.get(:signup) - expect(feature.groups).to be_empty - expect(feature.users).to be_empty - expect(feature.percentage).to eq(100) - end end - describe "#clear" do - let(:features) { %w(signup beta alpha gm) } - - before do - features.each { |f| rollout.activate(f) } - - rollout.clear! - end - - it "each feature is cleared" do - features.each do |feature| - expect(rollout.get(feature).to_hash).to eq( - percentage: 0, - users: [], - groups: [], - data: {}, - ) - end - end - - it "each feature is cleared with sets" do - @options = rollout.instance_variable_get("@options") - @options[:use_sets] = true - features.each do |feature| - expect(rollout.get(feature).to_hash).to eq( - percentage: 0, - users: Set.new, - groups: Set.new, - data: {}, - ) - end - end - - it "removes all features" do - expect(rollout.features).to be_empty + describe "#multi_get" do + it "preserves the requested name types" do + expect(rollout.multi_get("chat", :beta).map(&:name)).to eq ["chat", :beta] end end - describe "#feature_states" do - let(:user_double) { double(id: 7) } - - before do - rollout.activate(:chat) - rollout.activate_user(:video, user_double) - rollout.deactivate(:vr) - end - - it "returns a hash" do - expect(rollout.feature_states).to be_a(Hash) - end - - context "with user argument" do - it "maps active feature as true" do - state = rollout.feature_states(user_double)[:video] - expect(state).to eq(true) + describe "#with_feature" do + it "preserves the requested name type" do + feature = rollout.with_feature("chat") do |current| + current.percentage = 25 end - it "maps inactive feature as false" do - state = rollout.feature_states[:vr] - expect(state).to eq(false) - end + expect(feature.name).to eq "chat" + expect(rollout.get("chat").name).to eq "chat" end - context "with no argument" do - it "maps active feature as true" do - state = rollout.feature_states[:chat] - expect(state).to eq(true) - end + it "persists a mutation when nested without resets the thread flag" do + rollout = described_class.new(backend: RolloutMemoryBackend.new, logging: true) - it "maps inactive feature as false" do - state = rollout.feature_states[:video] - expect(state).to eq(false) + rollout.logging.without do + rollout.with_feature(:chat) do |feature| + feature.percentage = 25 + rollout.logging.without {} + end end - end - end - - describe "#active_features" do - let(:user_double) { double(id: 19) } - before do - rollout.activate(:chat) - rollout.activate_user(:video, user_double) - rollout.deactivate(:vr) + expect(rollout.get(:chat).percentage).to eq 25.0 end - it "returns an array" do - expect(rollout.active_features).to be_a(Array) - end - - context "with user argument" do - it "includes active feature" do - features = rollout.active_features(user_double) - expect(features).to include(:video) - expect(features).to include(:chat) - end + it "does not notify an observer registered during a mutation" do + observer = double("observer") + expect(observer).not_to receive(:update) - it "excludes inactive feature" do - features = rollout.active_features(user_double) - expect(features).to_not include(:vr) - end - end - - context "with no argument" do - it "includes active feature" do - features = rollout.active_features - expect(features).to include(:chat) - end - - it "excludes inactive feature" do - features = rollout.active_features - expect(features).to_not include(:video) + rollout.with_feature(:chat) do |feature| + feature.percentage = 25 + rollout.add_observer(observer) end end - end - - describe "#user_in_active_users?" do - it "returns true if activated for user" do - rollout.activate_user(:chat, double(id: 5)) - expect(rollout.user_in_active_users?(:chat, "5")).to eq(true) - end - it "returns false if activated for group" do - rollout.activate_group(:chat, :all) - expect(rollout.user_in_active_users?(:chat, "5")).to eq(false) - end - end - - describe "#multi_get" do - before do - rollout.activate_percentage(:chat, 10) - rollout.activate_group(:chat, :caretakers) - rollout.activate_group(:videos, :greeters) - rollout.activate(:signup) - rollout.activate_user(:photos, double(id: 42)) - end - - it "returns an array of features" do - features = rollout.multi_get(:chat, :videos, :signup) - expect(features[0].name).to eq :chat - expect(features[0].groups).to eq [:caretakers] - expect(features[0].percentage).to eq 10 - expect(features[1].name).to eq :videos - expect(features[1].groups).to eq [:greeters] - expect(features[2].name).to eq :signup - expect(features[2].percentage).to eq 100 - expect(features.size).to eq 3 - end - - it "preserves the requested name types" do - expect(rollout.multi_get("chat", :videos).map(&:name)).to eq ["chat", :videos] - end - - describe 'when given feature keys is empty' do - it 'returns empty array' do - expect(rollout.multi_get(*[])).to match_array([]) + it "notifies observers registered before a mutation" do + observer = double("observer") + expect(observer).to receive(:update) do |_event, before, after| + expect(before.name).to eq :chat + expect(before.percentage).to eq 0 + expect(after.percentage).to eq 25 end - end - end - describe "#set_feature_data" do - before do - rollout.set_feature_data(:chat, description: 'foo', release_date: 'bar') + rollout.add_observer(observer) + rollout.activate_percentage(:chat, 25) end - it 'sets the data attribute on feature' do - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') - end + it "does not persist when the mutation block raises" do + expect { + rollout.with_feature(:chat) { raise "nope" } + }.to raise_error("nope") - it 'updates a data attribute' do - rollout.set_feature_data(:chat, description: 'baz') - expect(rollout.get(:chat).data).to include('description' => 'baz', 'release_date' => 'bar') - end - - it 'only sets data on specified feature' do - rollout.set_feature_data(:talk, image_url: 'kittens.png') - expect(rollout.get(:chat).data).not_to include('image_url' => 'kittens.png') - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') - end - - it 'does not modify @data if param is nil' do - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') - rollout.set_feature_data(:chat, nil) - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') + expect(rollout.exists?(:chat)).to eq false end - it 'does not modify @data if param is empty string' do - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') - rollout.set_feature_data(:chat, " ") - expect(rollout.get(:chat).data).to include('description' => 'foo', 'release_date' => 'bar') - end + it "records a history event only when logging is enabled and state changes" do + backend = RolloutMemoryBackend.new + rollout = described_class.new(backend: backend, logging: true) - it 'properly parses data when it contains a |' do - user = double("User", id: 8) - rollout.activate_user(:chat, user) - rollout.set_feature_data(:chat, "|call||text|" => "a|bunch|of|stuff") - expect(rollout.get(:chat).data).to include("|call||text|" => "a|bunch|of|stuff") - expect(rollout.active?(:chat, user)).to be true - end - end + rollout.activate_percentage(:chat, 25) + expect(backend.feature_events(:chat).count).to eq 1 - describe "#clear_feature_data" do - it 'resets data to empty string' do - rollout.set_feature_data(:chat, description: 'foo') - expect(rollout.get(:chat).data).to include('description' => 'foo') - rollout.clear_feature_data(:chat) - expect(rollout.get(:chat).data).to eq({}) + rollout.activate_percentage(:chat, 25) + expect(backend.feature_events(:chat).count).to eq 1 end end - describe "persisted redis format" do - it "writes the current feature payload and registry keys" do - rollout.activate_percentage(:chat, 20) - rollout.activate_user(:chat, 42) - rollout.activate_group(:chat, :employees) - rollout.set_feature_data(:chat, description: "foo") - - expect($redis.get("feature:chat")).to eq('20.0|42|employees|{"description":"foo"}') - expect($redis.get("feature:__features__")).to eq("chat") - end - - it "reads an existing payload without rewriting it" do - $redis.set("feature:chat", '10.5|7,8|greeters|{"description":"legacy"}') - $redis.set("feature:__features__", "chat") - - feature = rollout.get(:chat) + describe "#clear!" do + it "asks the backend to clear remaining registry state" do + backend = RolloutMemoryBackend.new + expect(backend).to receive(:clear_features).and_call_original - expect(feature.percentage).to eq 10.5 - expect(feature.users).to eq %w[7 8] - expect(feature.groups).to eq [:greeters] - expect(feature.data).to eq("description" => "legacy") - expect($redis.get("feature:chat")).to eq('10.5|7,8|greeters|{"description":"legacy"}') + described_class.new(backend: backend).clear! end end - describe "mutation semantics" do - it "saves multiple with_feature edits together" do - rollout.with_feature(:chat) do |feature| - feature.percentage = 25.0 - feature.groups = [:employees] - feature.users = ["123"] - feature.data.update(description: "New navigation") - end + describe "#delete" do + it "does not delete feature history when logging is disabled" do + backend = RolloutMemoryBackend.new + logged = described_class.new(backend: backend, logging: true) + logged.activate_percentage(:chat, 25) - feature = rollout.get(:chat) - expect(feature.percentage).to eq 25.0 - expect(feature.groups).to eq [:employees] - expect(feature.users).to eq %w[123] - expect(feature.data).to eq("description" => "New navigation") - end + described_class.new(backend: backend).delete(:chat) - it "does not save when the with_feature block raises" do - expect do - rollout.with_feature(:chat) do |feature| - feature.percentage = 100 - raise "boom" - end - end.to raise_error("boom") - - expect(rollout.get(:chat).percentage).to eq 0 - expect(rollout.exists?(:chat)).to eq false + expect(logged.exists?(:chat)).to eq false + expect(logged.logging.events(:chat)).not_to eq [] end - it "clears users, groups, percentage, and data on deactivate" do - rollout.activate_user(:chat, 42) - rollout.activate_group(:chat, :employees) - rollout.activate_percentage(:chat, 50) - rollout.set_feature_data(:chat, description: "foo") - - rollout.deactivate(:chat) - - expect(rollout.features).to eq [:chat] - expect(rollout.get(:chat).to_hash).to eq( - percentage: 0, - users: [], - groups: [], - data: {}, - ) - expect($redis.get("feature:chat")).to eq("0.0|||{}") - end - - it "keeps users, groups, and data on deactivate_percentage" do - rollout.activate_user(:chat, 42) - rollout.activate_group(:chat, :employees) - rollout.activate_percentage(:chat, 50) - rollout.set_feature_data(:chat, description: "foo") - - rollout.deactivate_percentage(:chat) - - expect(rollout.get(:chat).percentage).to eq 0 - expect(rollout.get(:chat).users).to eq %w[42] - expect(rollout.get(:chat).groups).to eq [:employees] - expect(rollout.get(:chat).data).to eq("description" => "foo") - end - - it "removes the feature on delete and leaves a missing feature inactive" do - rollout.activate(:chat) + it "deletes feature history when logging is enabled" do + backend = RolloutMemoryBackend.new + rollout = described_class.new(backend: backend, logging: true) + rollout.activate_percentage(:chat, 25) rollout.delete(:chat) - expect(rollout.features).to eq [] - expect(rollout.exists?(:chat)).to eq false - expect(rollout.get(:chat).percentage).to eq 0 - expect(rollout.active?(:chat)).to eq false - end - end - - describe 'Check if feature exists' do - it 'it should return true if the feature is exist' do - rollout.activate_percentage(:chat, 1) - expect(rollout.exists?(:chat)).to be true - end - - it 'it should return false if the feature is not exist' do - expect(rollout.exists?(:chat)).to be false + expect(rollout.logging.events(:chat)).to eq [] end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5667333..e62b526 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,23 +5,79 @@ SimpleCov.start require 'bundler/setup' -require 'redis' require 'rollout' -$redis = Redis.new( - host: ENV.fetch('REDIS_HOST', '127.0.0.1'), - port: ENV.fetch('REDIS_PORT', '6379'), - db: ENV.fetch('REDIS_DB', '7'), -) +class RolloutMemoryBackend + def initialize + @features = {} + @events = Hash.new { |hash, key| hash[key] = [] } + end + + def fetch_feature(name) + @features[name.to_s] || Rollout::FeatureState.new(name: name, percentage: 0) + end + + def fetch_features(names) + names.map { |name| fetch_feature(name) } + end + + def feature_names + @features.keys + end + + def feature_exists?(name) + @features.key?(name.to_s) + end + + def save_feature(state) + @features[state.name] = state + end + + def delete_feature(name) + @features.delete(name.to_s) + end + + def clear_features + end + + def mutate_feature(name) + mutation = yield fetch_feature(name) + save_feature(mutation.fetch(:state)) + event = mutation[:event] + @events[name.to_s] << event if event + mutation + end + + def feature_events(name, limit: nil) + limited_events(@events[name.to_s], limit) + end + + def global_events(limit: nil) + limited_events([], limit) + end + + def feature_updated_at(_name) + end + + def delete_feature_events(name) + @events.delete(name.to_s) + end + + private + + def limited_events(events, limit) + return events if limit.nil? + raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer) + raise ArgumentError, "limit must be >= 0" if limit < 0 + + events.last(limit) + end +end RSpec.configure do |config| config.example_status_persistence_file_path = '.rspec_status' - # config.disable_monkey_patching! - config.expect_with :rspec do |c| c.syntax = :expect end - - config.before { $redis.flushdb } end diff --git a/spec/support/backend_contract.rb b/spec/support/backend_contract.rb new file mode 100644 index 0000000..dec28f2 --- /dev/null +++ b/spec/support/backend_contract.rb @@ -0,0 +1,54 @@ +RSpec.shared_examples "a rollout feature backend" do + def empty_state(name) + Rollout::FeatureState.new(name: name, percentage: 0) + end + + it "returns an empty state for a missing feature" do + state = backend.fetch_feature(:chat) + + expect(state).to eq empty_state(:chat) + end + + it "preserves fetch_features order and duplicates" do + backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: 25)) + + states = backend.fetch_features([:chat, :missing, :chat]) + + expect(states.map(&:name)).to eq %w[chat missing chat] + expect(states.map(&:percentage)).to eq [25.0, 0.0, 25.0] + end + + it "returns no features for an empty fetch_features request" do + expect(backend.fetch_features([])).to eq [] + end + + it "does not share nested data with a fetched state" do + backend.save_feature( + Rollout::FeatureState.new(name: :chat, percentage: 0, data: { "labels" => ["a"] }), + ) + + backend.fetch_feature(:chat).data["labels"] << "b" + + expect(backend.fetch_feature(:chat).data).to eq("labels" => ["a"]) + end + + it "tracks existence and names after save and delete" do + backend.save_feature(Rollout::FeatureState.new(name: :chat, percentage: 25)) + + expect(backend.feature_exists?(:chat)).to be_truthy + expect(backend.feature_names).to eq ["chat"] + + backend.delete_feature(:chat) + + expect(backend.feature_exists?(:chat)).to be_falsey + expect(backend.fetch_feature(:chat)).to eq empty_state(:chat) + end + + it "does not persist when mutate_feature's block raises" do + expect { + backend.mutate_feature(:chat) { raise "nope" } + }.to raise_error("nope") + + expect(backend.feature_exists?(:chat)).to be_falsey + end +end